初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
<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 { 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'
|
||||
|
||||
const props = defineProps<{ cfgId: number }>()
|
||||
const message = useMessage()
|
||||
|
||||
const hours = ref(24)
|
||||
const hourOptions = [
|
||||
{ label: '近 1 小时', value: 1 },
|
||||
{ label: '近 6 小时', value: 6 },
|
||||
{ label: '近 24 小时', value: 24 },
|
||||
{ label: '近 72 小时', value: 72 },
|
||||
]
|
||||
|
||||
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
},
|
||||
onUpdatePageSize: (ps: number) => {
|
||||
pagination.pageSize = ps
|
||||
pagination.page = 1
|
||||
},
|
||||
})
|
||||
|
||||
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 = ''
|
||||
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 : '查询失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 截断续查:同一绝对窗带游标断点续翻,追加去重后按时间重排 */
|
||||
async function loadMore() {
|
||||
if (!win.value || !nextPage.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const r = await listAuditEvents(props.cfgId, { ...win.value, page: nextPage.value })
|
||||
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 ?? ''
|
||||
} catch (e) {
|
||||
// 追加失败不清空已有列表;游标过期时提示重新查询
|
||||
message.error(e instanceof Error ? e.message : '继续加载失败,请刷新重新查询')
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load())
|
||||
// 切换时间窗自动重查并回到第一页
|
||||
watch(hours, () => 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>
|
||||
<template v-if="truncated">
|
||||
<StatusBadge kind="warn" label="结果已截断" />
|
||||
<NButton v-if="nextPage" size="tiny" :loading="loadingMore" @click="loadMore">
|
||||
继续加载更早
|
||||
</NButton>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
:max-height="480"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<FootNote>
|
||||
OCI Audit 免费且默认开启,事件保留 365 天,无需在云端做任何配置 ·
|
||||
实时查询不入库,事件通常延迟数分钟可见 ·
|
||||
已默认过滤遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调 ·
|
||||
单次最多翻 10 页,截断时点「继续加载更早」断点续查 · 点击行查看完整详情
|
||||
</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>
|
||||
Reference in New Issue
Block a user