271 lines
8.8 KiB
Vue
271 lines
8.8 KiB
Vue
<script setup lang="ts">
|
||
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'
|
||
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
||
import FootNote from '@/components/FootNote.vue'
|
||
import JsonBlock from '@/components/JsonBlock.vue'
|
||
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 = useToast()
|
||
|
||
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({
|
||
page: 1,
|
||
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
|
||
pagination.page = 1
|
||
},
|
||
})
|
||
|
||
/** 重置信息流:回到最新一批 */
|
||
async function load() {
|
||
loading.value = true
|
||
error.value = ''
|
||
rows.value = []
|
||
cursor.value = ''
|
||
exhausted.value = false
|
||
pagination.page = 1
|
||
try {
|
||
await fetchBatch(true)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 向更早方向续取一批(~100 条):追加去重后按时间重排;
|
||
* 末页不满且还有更早数据时自动补一批,避免首屏残页 */
|
||
async function fetchBatch(first = false) {
|
||
if (loadingMore.value || (!first && !hasMore.value)) return
|
||
loadingMore.value = true
|
||
try {
|
||
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 ?? ''),
|
||
)
|
||
cursor.value = r.cursor ?? ''
|
||
exhausted.value = r.exhausted
|
||
} catch (e) {
|
||
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())
|
||
|
||
// ---- 行点击详情:原始 JSON 懒取(命中服务端缓存秒开,过期走小窗重查) ----
|
||
const detail = ref<AuditEvent | null>(null)
|
||
const showDetail = ref(false)
|
||
const detailRaw = ref<unknown>(null)
|
||
const rawLoading = ref(false)
|
||
const rawGone = ref(false)
|
||
|
||
function rowProps(row: AuditEvent) {
|
||
return {
|
||
style: 'cursor: pointer',
|
||
onClick: () => {
|
||
detail.value = row
|
||
showDetail.value = true
|
||
void loadRaw(row)
|
||
},
|
||
}
|
||
}
|
||
|
||
async function loadRaw(row: AuditEvent) {
|
||
detailRaw.value = null
|
||
rawGone.value = false
|
||
if (!row.eventId || !row.eventTime) {
|
||
rawGone.value = true
|
||
return
|
||
}
|
||
rawLoading.value = true
|
||
try {
|
||
const { raw } = await getAuditEventDetail(props.cfgId, {
|
||
eventId: row.eventId,
|
||
eventTime: row.eventTime,
|
||
})
|
||
detailRaw.value = raw
|
||
} catch {
|
||
rawGone.value = true
|
||
} finally {
|
||
rawLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 摘要头 chips:操作者 / IP / 时间;完整长值仍在字段网格与原始 JSON 中 */
|
||
const heroChips = computed<HeroChip[]>(() => {
|
||
const d = detail.value
|
||
if (!d) return []
|
||
return [
|
||
{ label: '操作者', value: d.principalName || '—' },
|
||
{ label: 'IP', value: d.ipAddress || '—', mono: true },
|
||
{ label: '时间', value: fmtTime(d.eventTime) },
|
||
]
|
||
})
|
||
|
||
/** 字段网格:头部未覆盖的补充字段;空值以「—」占位 */
|
||
const detailRows = computed<DetailRow[]>(() => {
|
||
const d = detail.value
|
||
if (!d) return []
|
||
return [
|
||
{ label: '资源', value: d.resourceName, mono: true },
|
||
{ label: '区间', value: d.compartmentName },
|
||
{ label: '请求', value: `${d.requestAction} ${d.requestPath}`.trim(), mono: true },
|
||
{ label: '来源', value: d.source },
|
||
]
|
||
})
|
||
|
||
/** 主文本 + 次行小字的两行单元格;空值以「—」占位 */
|
||
function twoLine(main: string, sub: string) {
|
||
return h('div', { class: 'min-w-0' }, [
|
||
h('div', { class: 'truncate text-[13px]' }, main || '—'),
|
||
h('div', { class: 'mt-px truncate text-xs text-ink-3' }, sub || '—'),
|
||
])
|
||
}
|
||
|
||
const columns: DataTableColumns<AuditEvent> = [
|
||
{
|
||
title: '时间',
|
||
key: 'eventTime',
|
||
width: 145,
|
||
render: (row) =>
|
||
h('span', { class: 'text-[13px] text-ink-2 tabular-nums whitespace-nowrap' }, fmtTime(row.eventTime)),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'eventName',
|
||
minWidth: 200,
|
||
render: (row) => twoLine(row.eventName, row.source),
|
||
},
|
||
{
|
||
// 固定列宽让 truncate 生效:资源路径可长达数百字符,完整值点行查看
|
||
title: '资源',
|
||
key: 'resourceName',
|
||
width: 200,
|
||
render: (row) => twoLine(row.resourceName, row.compartmentName),
|
||
},
|
||
{
|
||
title: '操作者',
|
||
key: 'principalName',
|
||
width: 180,
|
||
render: (row) => twoLine(row.principalName, row.ipAddress),
|
||
},
|
||
{
|
||
title: '结果',
|
||
key: 'status',
|
||
width: 90,
|
||
render: (row) => {
|
||
if (!row.status) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
|
||
const failed = Number(row.status) >= 400
|
||
return h(StatusBadge, { kind: failed ? 'term' : 'run', label: row.status, dot: false })
|
||
},
|
||
},
|
||
]
|
||
</script>
|
||
|
||
<template>
|
||
<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>
|
||
<StatusBadge v-if="exhausted" kind="run" label="已加载全部保留期数据" :dot="false" />
|
||
<div class="flex-1" />
|
||
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
|
||
</div>
|
||
|
||
<div
|
||
v-if="error"
|
||
class="px-5 py-10 text-center text-[13px] text-ink-3"
|
||
>
|
||
{{ error }}
|
||
</div>
|
||
<NDataTable
|
||
v-else
|
||
:columns="columns"
|
||
:data="rows"
|
||
:loading="loading"
|
||
:pagination="pagination"
|
||
:scroll-x="820"
|
||
:row-props="rowProps"
|
||
/>
|
||
<FootNote>
|
||
OCI Audit 免费且默认开启,事件保留 365 天,无需在云端做任何配置 ·
|
||
实时查询不入库,事件通常延迟数分钟可见 ·
|
||
已默认过滤遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调 ·
|
||
从最新事件起每批约 100 条,翻到末页自动向更早加载,空档期自动扩窗回溯 · 点击行查看完整详情
|
||
</FootNote>
|
||
|
||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
|
||
<template #header>
|
||
<DetailHero
|
||
v-if="detail"
|
||
kind="审计事件 · OCI Audit"
|
||
:title="eventLabel(detail.eventName) || detail.eventName"
|
||
:sub="`${detail.eventName} · ${detail.source}`"
|
||
:chips="heroChips"
|
||
>
|
||
<StatusBadge
|
||
v-if="detail.status"
|
||
:kind="Number(detail.status) >= 400 ? 'term' : 'run'"
|
||
:label="`${Number(detail.status) >= 400 ? '失败' : '成功'} ${detail.status}`"
|
||
:dot="false"
|
||
/>
|
||
</DetailHero>
|
||
</template>
|
||
<div v-if="detail" class="flex flex-col gap-3.5">
|
||
<DetailFieldList :rows="detailRows" :cols="2" />
|
||
<JsonBlock
|
||
v-if="detailRaw"
|
||
:value="detailRaw"
|
||
title="原始 JSON"
|
||
meta="来自 Audit SDK"
|
||
max-height="36vh"
|
||
wide
|
||
/>
|
||
<div v-else-if="rawLoading" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
|
||
正在取回原始事件…
|
||
</div>
|
||
<div v-else-if="rawGone" class="rounded-[10px] border border-line-soft px-3.5 py-3 text-xs text-ink-3">
|
||
原始事件已不可取回(超出缓存与重查窗口),以上为列表精简字段
|
||
</div>
|
||
</div>
|
||
</NModal>
|
||
</div>
|
||
</template>
|