初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
<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>
将删除租户内的 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"
:policy="editingPolicy"
@updated="policies.run()"
/>
</div>
</template>