Files
oci-portal-dash/src/components/tenant/MiscTab.vue
T
Wang Defa 5464d2dfea
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
发布 0.1.0:通知渠道、告警规则与多项体验修复
2026-07-10 17:31:19 +08:00

412 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
ensureLogWebhook,
getLogRelay,
getLogWebhook,
revokeLogWebhook,
setupLogRelay,
teardownLogRelay,
} from '@/api/logevents'
import {
getIdentitySetting,
getNotificationRecipients,
listPasswordPolicies,
updateIdentitySetting,
updateNotificationRecipients,
} from '@/api/tenant'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import PolicyEditModal from '@/components/tenant/PolicyEditModal.vue'
import { useAsync } from '@/composables/useAsync'
import type { PasswordPolicy } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number; domainId?: string }>()
const message = useToast()
const policies = useAsync(() => listPasswordPolicies(props.cfgId, props.domainId))
const recipients = useAsync(() => getNotificationRecipients(props.cfgId, props.domainId))
const identity = useAsync(() => getIdentitySetting(props.cfgId, props.domainId))
const webhook = useAsync(() => getLogWebhook(props.cfgId))
const relay = useAsync(() => getLogRelay(props.cfgId))
const savingIdentity = ref(false)
const savingWebhook = ref(false)
const settingUpRelay = ref(false)
const tearingRelay = ref(false)
// OCI 侧链路四资源,按数据流向排成管线;任一资源存在即视为「已建(或残留)」
const relayRows = computed(() => {
const d = relay.data.value
if (!d) return []
return [
{ label: 'Topic', res: d.topic },
{ label: '订阅 · HTTPS', res: d.subscription },
{ label: 'Policy', res: d.policy },
{ label: 'Connector', res: d.connector },
]
})
const relayBuilt = computed(() => relayRows.value.some((r) => r.res.id !== ''))
/** 主卡状态 chip:运行中 / 仅回调地址 / 未启用 */
const relayChip = computed(() => {
if (relay.data.value?.ready) return { label: '链路运行中', dot: 'bg-ok' }
if (webhook.data.value?.exists) return { label: '回调地址已生成', dot: 'bg-warn' }
return { label: '未启用', dot: 'bg-ink-3/50' }
})
function relayDotClass(res: { id: string; state: string }): string {
if (!res.id) return 'bg-ink-3/40'
if (res.state === 'ACTIVE' || !res.state) return 'bg-ok'
if (['PENDING', 'CREATING', 'UPDATING'].includes(res.state)) return 'bg-warn'
return 'bg-ink-3/40'
}
async function createRelay() {
settingUpRelay.value = true
try {
await setupLogRelay(props.cfgId)
message.success('回传链路已建立,关键事件将自动回传')
void relay.run()
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
void relay.run({ silent: true })
} finally {
settingUpRelay.value = false
}
}
async function destroyRelay() {
tearingRelay.value = true
try {
await teardownLogRelay(props.cfgId)
message.success('回传链路已销毁,回调地址已撤销')
void relay.run()
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '销毁失败')
void relay.run({ silent: true })
} finally {
tearingRelay.value = false
}
}
const emails = ref<string[]>([])
const saving = ref(false)
const showPolicyEdit = ref(false)
const editingPolicy = ref<PasswordPolicy | null>(null)
function openPolicyEdit(p: PasswordPolicy) {
editingPolicy.value = p
showPolicyEdit.value = true
}
watch(
() => recipients.data.value,
(data) => {
if (data) emails.value = [...data.recipients]
},
)
async function saveRecipients() {
saving.value = true
try {
await updateNotificationRecipients(props.cfgId, emails.value, props.domainId)
message.success(emails.value.length ? '通知收件人已保存' : '已关闭 test mode,恢复默认发送')
void recipients.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function toggleEmailRequired(v: boolean) {
savingIdentity.value = true
try {
await updateIdentitySetting(props.cfgId, v, props.domainId)
message.success(v ? '已开启:添加用户 / JIT 预配必须提供邮箱' : '已关闭:邮箱变为可选')
void identity.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingIdentity.value = false
}
}
async function createWebhook() {
savingWebhook.value = true
try {
await ensureLogWebhook(props.cfgId)
message.success('回调地址已生成')
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '生成失败')
} finally {
savingWebhook.value = false
}
}
async function removeWebhook() {
savingWebhook.value = true
try {
await revokeLogWebhook(props.cfgId)
message.success('回调地址已撤销,旧地址随即失效')
void webhook.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '撤销失败')
} finally {
savingWebhook.value = false
}
}
async function copyWebhookPath() {
const path = webhook.data.value?.webhook?.path
if (!path) return
try {
await navigator.clipboard.writeText(path)
message.success('已复制回调路径,请以面板公网域名拼接完整 URL')
} catch {
message.error('复制失败,请手动选择文本复制')
}
}
function policyDesc(p: PasswordPolicy): string {
const parts = [`最短 ${p.minLength ?? 0} 位`]
parts.push(p.passwordExpiresAfter ? `${p.passwordExpiresAfter} 天过期` : '永不过期')
parts.push(
p.numPasswordsInHistory ? `不可复用最近 ${p.numPasswordsInHistory} 个密码` : '可复用以前的密码',
)
return `${p.passwordStrength} · ${parts.join(' · ')}`
}
</script>
<template>
<div class="flex flex-col gap-4">
<!-- 日志回传:全宽主卡,管线四节点 + 回调地址 + 事件清单 -->
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">日志回传</div>
<div class="mt-0.5 text-xs text-ink-3">
OCI 审计关键事件推送到面板:Connector Hub Notifications 面板 webhook
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
>
<span class="h-1.5 w-1.5 rounded-full" :class="relayChip.dot" />
{{ relayChip.label }}
</span>
<NPopconfirm v-if="relayBuilt" @positive-click="destroyRelay">
<template #trigger>
<NButton size="tiny" type="error" quaternary :loading="tearingRelay">
销毁链路
</NButton>
</template>
将删除租户内的 ConnectorPolicy订阅与 Topic 并撤销回调地址,确认销毁?
</NPopconfirm>
<NButton
v-else
size="small"
type="primary"
:loading="settingUpRelay"
@click="createRelay"
>
一键创建链路
</NButton>
</div>
</div>
<div v-if="webhook.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<template v-else>
<div v-if="settingUpRelay" class="px-4.5 pt-3 text-xs text-warn">
正在创建 Topic 订阅(等待自动确认)→ Policy Connector,约需 1-2 分钟,请勿离开
</div>
<div v-else-if="relay.error.value" class="px-4.5 pt-3 text-xs text-warn">
{{ relay.error.value }}
</div>
<div
v-else-if="relayBuilt"
class="grid grid-cols-4 gap-2.5 px-4.5 pt-3.5 max-md:grid-cols-2"
>
<div
v-for="row in relayRows"
:key="row.label"
class="relative rounded-lg bg-wash px-3 py-2.5 after:absolute after:top-1/2 after:-right-[9px] after:-translate-y-1/2 after:text-xs after:text-ink-3 after:content-['→'] last:after:hidden max-md:after:hidden"
>
<div class="text-[11px] tracking-wider text-ink-3 uppercase">{{ row.label }}</div>
<div class="mt-1 flex items-center gap-1.5 text-[12.5px] font-semibold">
<span class="h-[7px] w-[7px] flex-none rounded-full" :class="relayDotClass(row.res)" />
{{ row.res.id ? row.res.state || '存在' : '未创建' }}
</div>
</div>
</div>
<div v-else class="px-4.5 pt-3.5 text-xs leading-relaxed text-ink-3">
在租户内自动创建 TopicCUSTOM_HTTPS 订阅IAM Policy Service Connector(_Audit
审计日志,含子区间),订阅确认由面板自动完成,全程约 1-2 分钟
</div>
<div
v-if="webhook.data.value?.exists"
class="mx-4.5 mt-3 flex items-center gap-2.5 rounded-md bg-wash px-3 py-2"
>
<span class="flex-none text-xs text-ink-3">回调地址</span>
<span class="mono min-w-0 flex-1 text-[12px] break-all text-ink-2">
{{ webhook.data.value.webhook?.path }}
</span>
<NButton size="tiny" quaternary class="flex-none" @click="copyWebhookPath">复制</NButton>
<NPopconfirm @positive-click="removeWebhook">
<template #trigger>
<NButton size="tiny" type="error" quaternary class="flex-none" :loading="savingWebhook">
撤销
</NButton>
</template>
撤销后旧回调地址立即 404,OCI 侧订阅将投递失败,确认撤销?
</NPopconfirm>
</div>
<div v-else class="mx-4.5 mt-3 flex flex-wrap items-center gap-2.5 rounded-md bg-wash px-3 py-2">
<span class="min-w-0 flex-1 text-xs text-ink-3">
推荐直接一键创建链路;仅需回调地址手动配置 OCI 侧时,可单独生成
</span>
<NButton size="tiny" :loading="savingWebhook" @click="createWebhook">
生成回调地址
</NButton>
</div>
<div class="px-4.5 pt-3.5 pb-4">
<div class="text-xs font-semibold text-ink-2">
回传事件清单(OCI Log Filter 收窄,仅以下关键事件回传,不产生噪声流量)
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="ev in relay.data.value?.events ?? []"
:key="ev"
class="mono rounded bg-wash px-1.5 py-0.5 text-[11px] text-ink-2"
>
{{ ev }}
</span>
</div>
</div>
</template>
<FootNote>
关键事件入库后另经通知管理 云端事件按类推送,可在设置中关闭;回调地址内含随机凭据,
请勿外传,复制路径后以面板公网域名拼接完整 URL
</FootNote>
</div>
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<!-- 密码策略与身份 -->
<div class="panel">
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">密码策略与身份</div>
<div class="text-[12.5px] text-ink-3">Default 身份域 · IAM 规则</div>
</div>
<div v-if="policies.loading.value || identity.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else class="px-4.5 py-1.5">
<div
v-for="p in policies.data.value ?? []"
:key="p.id"
class="flex items-center gap-2.5 border-b border-line-soft py-2.5"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="mono text-[12.5px] text-ink">{{ p.name }}</span>
<StatusBadge
:kind="p.passwordStrength === 'Custom' ? 'snatch' : 'check'"
:label="p.passwordStrength === 'Custom' ? 'Custom' : '内置'"
:dot="false"
/>
</div>
<div class="mt-0.5 text-xs text-ink-3">
{{ policyDesc(p) }}
</div>
</div>
<NButton
v-if="p.passwordStrength === 'Custom'"
size="tiny"
quaternary
@click="openPolicyEdit(p)"
>
编辑
</NButton>
<span v-else class="text-[12.5px] text-ink-3">只读</span>
</div>
<div class="flex items-center gap-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium">用户需要提供主电子邮件地址</div>
<div class="mt-0.5 text-xs text-ink-3">
开启后添加用户必须填写邮箱,JIT 预配必须映射邮箱属性
</div>
</div>
<NSwitch
:value="identity.data.value?.primaryEmailRequired ?? false"
:loading="savingIdentity"
size="small"
@update:value="toggleEmailRequired"
/>
</div>
</div>
<FootNote>
Custom 强度的策略可修改,Simple / Standard Oracle
只读内置策略;关闭邮箱强制后找回密码与欢迎邮件不可用
</FootNote>
</div>
<!-- 通知收件人 -->
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">通知收件人</div>
<div class="mt-0.5 text-xs text-ink-3">
域通知 test mode:开启后全部通知只发给指定收件人
</div>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="recipients.data.value?.testModeEnabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
test mode {{ recipients.data.value?.testModeEnabled ? '已开启' : '未开启' }}
</span>
</div>
<div v-if="recipients.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else class="flex flex-col gap-3 px-4.5 py-3.5">
<NDynamicTags v-model:value="emails" />
<div class="flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="saveRecipients">
保存
</NButton>
<span class="text-xs text-ink-3">传空列表将关闭 test mode恢复域默认发送</span>
</div>
</div>
<FootNote>收件人为本租户级配置;适合把告警邮件集中到运维邮箱</FootNote>
</div>
</div>
<PolicyEditModal
v-model:show="showPolicyEdit"
:cfg-id="cfgId"
:domain-id="domainId"
:policy="editingPolicy"
@updated="policies.run()"
/>
</div>
</template>