410 lines
15 KiB
Vue
410 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch, useMessage } 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'
|
||
|
||
const props = defineProps<{ cfgId: number }>()
|
||
|
||
const message = useMessage()
|
||
|
||
const policies = useAsync(() => listPasswordPolicies(props.cfgId))
|
||
const recipients = useAsync(() => getNotificationRecipients(props.cfgId))
|
||
const identity = useAsync(() => getIdentitySetting(props.cfgId))
|
||
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)
|
||
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)
|
||
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>
|
||
将删除租户内的 Connector、Policy、订阅与 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">
|
||
在租户内自动创建 Topic、CUSTOM_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"
|
||
:policy="editingPolicy"
|
||
@updated="policies.run()"
|
||
/>
|
||
</div>
|
||
</template>
|