Files
oci-portal-dash/src/components/tenant/AuditLogTab.vue
T
wangdefa 2ea5b73de2
CI / test (push) Successful in 25s
Release / release (push) Successful in 26s
移除告警规则界面、恢复 Chat 端点并优化列表
2026-07-13 10:08:08 +08:00

271 lines
8.8 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, 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>