发布 0.1.0:通知渠道、告警规则与多项体验修复
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s

This commit is contained in:
Wang Defa
2026-07-10 17:31:19 +08:00
parent 9d0c116ce3
commit 5464d2dfea
68 changed files with 1604 additions and 359 deletions
+41 -47
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { NButton, NDataTable, NModal, NSelect, useMessage, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { NButton, NDataTable, NModal, type DataTableColumns } from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { getAuditEventDetail, listAuditEvents } from '@/api/tenant'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
@@ -11,17 +11,21 @@ import StatusBadge from '@/components/StatusBadge.vue'
import { eventLabel } from '@/composables/useEventLabel'
import { fmtTime } from '@/composables/useFormat'
import type { AuditEvent } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number }>()
const message = useMessage()
const message = useToast()
const hours = ref(24)
const hourOptions = [
{ label: '近 1 小时', value: 1 },
{ label: '近 6 小时', value: 6 },
{ label: '近 24 小时', value: 24 },
{ label: '近 72 小时', value: 72 },
]
const loading = ref(false)
const loadingMore = ref(false)
const error = ref('')
const rows = ref<AuditEvent[]>([])
// 服务端分窗回溯游标:空且 exhausted 表示已到 365 天保留期尽头
const cursor = ref('')
const exhausted = ref(false)
const pageCount = computed(() => Math.max(1, Math.ceil(rows.value.length / pagination.pageSize)))
const hasMore = computed(() => cursor.value !== '' && !exhausted.value)
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
const pagination = reactive({
@@ -29,8 +33,13 @@ const pagination = reactive({
pageSize: 20,
showSizePicker: true,
pageSizes: [20, 50, 100],
prefix: () =>
`已加载 ${rows.value.length} 条 · ` +
(loadingMore.value ? '正在加载更早…' : exhausted.value ? '已到 365 天保留期尽头' : '翻到末页自动加载更早'),
onUpdatePage: (p: number) => {
pagination.page = p
// 懒加载触发点:翻到已加载数据的最后一页即向后端续取一批
if (p >= pageCount.value) void fetchBatch()
},
onUpdatePageSize: (ps: number) => {
pagination.pageSize = ps
@@ -38,55 +47,48 @@ const pagination = reactive({
},
})
const loading = ref(false)
const loadingMore = ref(false)
const error = ref('')
const rows = ref<AuditEvent[]>([])
const truncated = ref(false)
const nextPage = ref('')
// 本次查询的绝对时间窗:续查游标绑定查询参数,必须原样带回
const win = ref<{ start: string; end: string } | null>(null)
/** 重置信息流:回到最新一批 */
async function load() {
loading.value = true
error.value = ''
rows.value = []
cursor.value = ''
exhausted.value = false
pagination.page = 1
try {
const r = await listAuditEvents(props.cfgId, { hours: hours.value })
rows.value = r.items
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
win.value = { start: r.start, end: r.end }
pagination.page = 1
} catch (e) {
error.value = e instanceof Error ? e.message : '查询失败'
await fetchBatch(true)
} finally {
loading.value = false
}
}
/** 截断续查:同一绝对窗带游标断点续翻,追加去重后按时间重排 */
async function loadMore() {
if (!win.value || !nextPage.value || loadingMore.value) return
/** 向更早方向续取一批(~100 条):追加去重后按时间重排;
* 末页不满且还有更早数据时自动补一批,避免首屏残页 */
async function fetchBatch(first = false) {
if (loadingMore.value || (!first && !hasMore.value)) return
loadingMore.value = true
try {
const r = await listAuditEvents(props.cfgId, { ...win.value, page: nextPage.value })
const r = await listAuditEvents(props.cfgId, { cursor: cursor.value || undefined })
const seen = new Set(rows.value.map((e) => e.eventId))
rows.value = [...rows.value, ...r.items.filter((e) => !seen.has(e.eventId))].sort((a, b) =>
(b.eventTime ?? '').localeCompare(a.eventTime ?? ''),
)
truncated.value = r.truncated
nextPage.value = r.nextPage ?? ''
cursor.value = r.cursor ?? ''
exhausted.value = r.exhausted
} catch (e) {
// 追加失败不清空已有列表;游标过期时提示重新查询
message.error(e instanceof Error ? e.message : '继续加载失败,请刷新重新查询')
if (first) {
error.value = e instanceof Error ? e.message : '查询失败'
} else {
// 追加失败不清空已有列表;游标过期时提示刷新重来
message.error(e instanceof Error ? e.message : '继续加载失败,请刷新重新查询')
}
} finally {
loadingMore.value = false
}
if (hasMore.value && rows.value.length < pagination.pageSize) void fetchBatch()
}
onMounted(() => void load())
// 切换时间窗自动重查并回到第一页
watch(hours, () => void load())
// ---- 行点击详情:原始 JSON 懒取(命中服务端缓存秒开,过期走小窗重查) ----
const detail = ref<AuditEvent | null>(null)
@@ -202,16 +204,8 @@ const columns: DataTableColumns<AuditEvent> = [
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3">
<div class="text-sm font-semibold">审计日志</div>
<template v-if="truncated">
<StatusBadge kind="warn" label="结果已截断" />
<NButton v-if="nextPage" size="tiny" :loading="loadingMore" @click="loadMore">
继续加载更早
</NButton>
</template>
<StatusBadge v-if="exhausted" kind="run" label="已加载全部保留期数据" :dot="false" />
<div class="flex-1" />
<div class="w-32 max-md:w-full">
<NSelect v-model:value="hours" size="small" :options="hourOptions" />
</div>
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
</div>
@@ -235,7 +229,7 @@ const columns: DataTableColumns<AuditEvent> = [
OCI Audit 免费且默认开启事件保留 365 无需在云端做任何配置 ·
实时查询不入库事件通常延迟数分钟可见 ·
已默认过滤遥测噪声SummarizeMetricsData与内网地址发起的服务互调 ·
单次最多翻 10 截断时点继续加载更早断点续查 · 点击行查看完整详情
从最新事件起每批约 100 翻到末页自动向更早加载空档期自动扩窗回溯 · 点击行查看完整详情
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
+7 -6
View File
@@ -1,16 +1,17 @@
<script setup lang="ts">
import { NCheckbox, NInput, NSelect, NSwitch, useMessage } from 'naive-ui'
import { NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ show: boolean; cfgId: number }>()
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
const primaryEmailRequired = ref(false)
@@ -32,7 +33,7 @@ const userAttrOptions = [
]
// 默认值与控制台一致:名称 ID 格式「无」、NameID 映射用户名、
// JIT 开启建用户不更新、分配 Administrators
// JIT 开启建用户不更新、分配租户管理员
const blank = {
name: '',
metadata: '',
@@ -103,7 +104,7 @@ function buildBody(): CreateIdpBody {
async function submit() {
submitting.value = true
try {
const created = await createIdp(props.cfgId, buildBody())
const created = await createIdp(props.cfgId, buildBody(), props.domainId)
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
emit('update:show', false)
emit('created')
@@ -199,7 +200,7 @@ async function submit() {
<NCheckbox v-model:checked="form.jitCreate">首次联邦登录时自动创建用户</NCheckbox>
<NCheckbox v-model:checked="form.jitUpdate">每次登录时按断言更新既有用户</NCheckbox>
<NCheckbox v-model:checked="form.jitAssignAdminGroup">
JIT 用户加入 Administrators
JIT 用户加入租户管理员
</NCheckbox>
<NCheckbox
v-model:checked="form.jitMapEmail"
+12 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, NSwitch, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NPopconfirm, NSwitch, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import {
@@ -16,13 +16,14 @@ import StatusBadge from '@/components/StatusBadge.vue'
import IdpFormModal from '@/components/tenant/IdpFormModal.vue'
import { useAsync } from '@/composables/useAsync'
import type { IdentityProvider, SignOnRule } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number }>()
const props = defineProps<{ cfgId: number; domainId?: string }>()
const message = useMessage()
const message = useToast()
const dialog = useDialog()
const idps = useAsync(() => listIdps(props.cfgId))
const rules = useAsync(() => listSignOnRules(props.cfgId))
const idps = useAsync(() => listIdps(props.cfgId, props.domainId))
const rules = useAsync(() => listSignOnRules(props.cfgId, props.domainId))
const showCreate = ref(false)
async function act(fn: () => Promise<unknown>, ok: string) {
@@ -74,7 +75,7 @@ const idpColumns: DataTableColumns<IdentityProvider> = [
size: 'small',
value: row.enabled,
onUpdateValue: (v: boolean) =>
act(() => activateIdp(props.cfgId, row.id, v), v ? '已激活并显示到登录页' : '已停用'),
act(() => activateIdp(props.cfgId, row.id, v, props.domainId), v ? '已激活并显示到登录页' : '已停用'),
}),
h(
NButton,
@@ -123,7 +124,7 @@ const ruleColumns: DataTableColumns<SignOnRule> = [
NPopconfirm,
{
onPositiveClick: () =>
act(() => deleteSignOnExemption(props.cfgId, row.id), '已删除免 MFA 规则并恢复优先级'),
act(() => deleteSignOnExemption(props.cfgId, row.id, props.domainId), '已删除免 MFA 规则并恢复优先级'),
},
{
trigger: () =>
@@ -136,7 +137,7 @@ const ruleColumns: DataTableColumns<SignOnRule> = [
async function doDownloadMetadata() {
try {
await downloadSamlMetadata(props.cfgId)
await downloadSamlMetadata(props.cfgId, props.domainId)
message.success('SAML 元数据已下载')
} catch (e) {
message.error(e instanceof Error ? e.message : '下载失败')
@@ -154,7 +155,7 @@ function confirmDeleteIdp(row: IdentityProvider) {
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () =>
act(() => deleteIdp(props.cfgId, row.id), `已删除 IdP「${row.name}」及其关联规则`),
act(() => deleteIdp(props.cfgId, row.id, props.domainId), `已删除 IdP「${row.name}」及其关联规则`),
})
}
@@ -165,7 +166,7 @@ function addExemption() {
return
}
void act(
() => createSignOnExemption(props.cfgId, enabled.id),
() => createSignOnExemption(props.cfgId, enabled.id, props.domainId),
`已为 ${enabled.name} 创建免 MFA 规则并置顶`,
)
}
@@ -211,6 +212,6 @@ function addExemption() {
</FootNote>
</div>
<IdpFormModal v-model:show="showCreate" :cfg-id="cfgId" @created="idps.run()" />
<IdpFormModal v-model:show="showCreate" :cfg-id="cfgId" :domain-id="domainId" @created="idps.run()" />
</div>
</template>
+10 -8
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch, useMessage } from 'naive-ui'
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
@@ -22,14 +22,15 @@ 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 }>()
const props = defineProps<{ cfgId: number; domainId?: string }>()
const message = useMessage()
const message = useToast()
const policies = useAsync(() => listPasswordPolicies(props.cfgId))
const recipients = useAsync(() => getNotificationRecipients(props.cfgId))
const identity = useAsync(() => getIdentitySetting(props.cfgId))
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)
@@ -114,7 +115,7 @@ watch(
async function saveRecipients() {
saving.value = true
try {
await updateNotificationRecipients(props.cfgId, emails.value)
await updateNotificationRecipients(props.cfgId, emails.value, props.domainId)
message.success(emails.value.length ? '通知收件人已保存' : '已关闭 test mode,恢复默认发送')
void recipients.run()
} catch (e) {
@@ -127,7 +128,7 @@ async function saveRecipients() {
async function toggleEmailRequired(v: boolean) {
savingIdentity.value = true
try {
await updateIdentitySetting(props.cfgId, v)
await updateIdentitySetting(props.cfgId, v, props.domainId)
message.success(v ? '已开启:添加用户 / JIT 预配必须提供邮箱' : '已关闭:邮箱变为可选')
void identity.run()
} catch (e) {
@@ -402,6 +403,7 @@ function policyDesc(p: PasswordPolicy): string {
<PolicyEditModal
v-model:show="showPolicyEdit"
:cfg-id="cfgId"
:domain-id="domainId"
:policy="editingPolicy"
@updated="policies.run()"
/>
+5 -4
View File
@@ -1,19 +1,20 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { NSwitch } from 'naive-ui'
import { reactive, ref, watch } from 'vue'
import { updatePasswordPolicy } from '@/api/tenant'
import FormModal from '@/components/FormModal.vue'
import type { PasswordPolicy } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ show: boolean; cfgId: number; policy: PasswordPolicy | null }>()
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string; policy: PasswordPolicy | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
// 关闭开关时写回的控制台默认值
const DEFAULT_EXPIRES_DAYS = 120
const DEFAULT_HISTORY_COUNT = 4
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const form = reactive({ neverExpires: true, allowReuse: true })
@@ -45,7 +46,7 @@ async function submit() {
}
submitting.value = true
try {
await updatePasswordPolicy(props.cfgId, props.policy.id, body)
await updatePasswordPolicy(props.cfgId, props.policy.id, body, props.domainId)
message.success('密码策略已更新')
emit('update:show', false)
emit('updated')
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { NCheckbox, NSelect, useMessage } from 'naive-ui'
import { NCheckbox, NSelect } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listRegions, subscribeRegion } from '@/api/configs'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -16,7 +17,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ 'update:show': [boolean]; subscribed: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const regionKey = ref<string | null>(null)
const acknowledged = ref(false)
+21 -16
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NCheckbox, NInput, useMessage } from 'naive-ui'
import { NCheckbox, NInput } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import {
@@ -12,12 +12,13 @@ import {
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import type { IamUser, IamUserDetail } from '@/types/api'
import { useToast } from '@/composables/useToast'
// user 非空即编辑模式:打开时拉取域档案预填充,提交只发送与原值不同的字段
const props = defineProps<{ show: boolean; cfgId: number; user?: IamUser | null }>()
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string; user?: IamUser | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
// 域开启「用户需要提供主电子邮件地址」时邮箱必填
const emailRequired = ref(false)
@@ -57,7 +58,7 @@ watch(
async function loadIdentitySetting() {
try {
emailRequired.value = (await getIdentitySetting(props.cfgId)).primaryEmailRequired
emailRequired.value = (await getIdentitySetting(props.cfgId, props.domainId)).primaryEmailRequired
} catch {
emailRequired.value = false
}
@@ -67,7 +68,7 @@ async function loadIdentitySetting() {
async function loadDetail(userId: string) {
detailLoading.value = true
try {
const d = await getUserDetail(props.cfgId, userId)
const d = await getUserDetail(props.cfgId, userId, props.domainId)
if (props.user?.id !== userId) return
detail.value = d
if (!d.inDomain) return
@@ -139,20 +140,24 @@ async function submitEdit(u: IamUser) {
emit('update:show', false)
return
}
await updateUser(props.cfgId, u.id, body)
await updateUser(props.cfgId, u.id, body, props.domainId)
message.success(`用户「${u.name}」已更新`)
}
async function submitCreate() {
const created = await createUser(props.cfgId, {
name: form.name.trim(),
givenName: form.givenName.trim(),
familyName: form.familyName.trim(),
email: form.email.trim() || undefined,
description: form.description.trim() || undefined,
grantDomainAdmin: form.grantDomainAdmin,
addToAdminGroup: form.addToAdminGroup,
})
const created = await createUser(
props.cfgId,
{
name: form.name.trim(),
givenName: form.givenName.trim(),
familyName: form.familyName.trim(),
email: form.email.trim() || undefined,
description: form.description.trim() || undefined,
grantDomainAdmin: form.grantDomainAdmin,
addToAdminGroup: form.addToAdminGroup,
},
props.domainId,
)
message.success(`用户「${created.name}」已创建,可再执行「重置密码」下发初始密码`)
}
@@ -225,7 +230,7 @@ async function submit() {
{{ editing ? '身份域管理员' : '添加到身份域管理员' }}
</NCheckbox>
<NCheckbox v-model:checked="form.addToAdminGroup" :disabled="classicUser || detailLoading">
{{ editing ? '管理员组Administrators成员' : '添加到管理员组Administrators' }}
{{ editing ? '租户管理员组成员' : '添加到租户管理员组' }}
</NCheckbox>
<div v-if="adminHint" class="text-xs text-ink-3">{{ adminHint }}</div>
</div>
+9 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NPopconfirm, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import { clearUserMfa, deleteUser, deleteUserApiKeys, listUsers, resetUserPassword } from '@/api/tenant'
@@ -9,12 +9,13 @@ import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { IamUser } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number }>()
const props = defineProps<{ cfgId: number; domainId?: string }>()
const message = useMessage()
const message = useToast()
const dialog = useDialog()
const users = useAsync(() => listUsers(props.cfgId))
const users = useAsync(() => listUsers(props.cfgId, props.domainId))
const showForm = ref(false)
const editingUser = ref<IamUser | null>(null)
@@ -48,13 +49,13 @@ function confirmDelete(row: IamUser) {
]),
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () => act(() => deleteUser(props.cfgId, row.id), `已删除用户 ${row.name}`),
onPositiveClick: () => act(() => deleteUser(props.cfgId, row.id, props.domainId), `已删除用户 ${row.name}`),
})
}
async function resetPwd(row: IamUser) {
try {
const { password } = await resetUserPassword(props.cfgId, row.id)
const { password } = await resetUserPassword(props.cfgId, row.id, props.domainId)
dialog.success({
title: '一次性密码',
content: () =>
@@ -119,7 +120,7 @@ const columns: DataTableColumns<IamUser> = [
NPopconfirm,
{
onPositiveClick: () =>
act(() => clearUserMfa(props.cfgId, row.id), `已清除 ${row.name} 的全部 MFA`),
act(() => clearUserMfa(props.cfgId, row.id, props.domainId), `已清除 ${row.name} 的全部 MFA`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
@@ -169,6 +170,7 @@ const columns: DataTableColumns<IamUser> = [
<UserFormModal
v-model:show="showForm"
:cfg-id="cfgId"
:domain-id="domainId"
:user="editingUser"
@created="users.run()"
/>