278 lines
8.4 KiB
Vue
278 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import { NDataTable, NModal, NPopover, type DataTableColumns } from 'naive-ui'
|
|
import { computed, h, reactive, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
import { listConfigs } from '@/api/configs'
|
|
import { listLogEvents } from '@/api/logevents'
|
|
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
|
|
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
|
import JsonBlock from '@/components/JsonBlock.vue'
|
|
import StatusBadge from '@/components/StatusBadge.vue'
|
|
import TenantPicker from '@/components/TenantPicker.vue'
|
|
import { eventLabel, eventTail } from '@/composables/useEventLabel'
|
|
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
|
import { fmtTime } from '@/composables/useFormat'
|
|
import { useAsync } from '@/composables/useAsync'
|
|
import { useRegionAlias } from '@/composables/useRegionAlias'
|
|
import { useScopeStore } from '@/stores/scope'
|
|
import type { LogEvent, OciConfigSummary } from '@/types/api'
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
const message = useToast()
|
|
const router = useRouter()
|
|
const scope = useScopeStore()
|
|
const { alias: regionAlias } = useRegionAlias()
|
|
|
|
const rows = ref<LogEvent[]>([])
|
|
const loading = ref(false)
|
|
/** 租户筛选;null 为全部 */
|
|
const cfgFilter = ref<number | null>(null)
|
|
|
|
/** 租户摘要:hover 信息(别名/租户名/主区域)的数据源 */
|
|
const configs = useAsync(listConfigs)
|
|
const cfgById = computed(() => {
|
|
const map = new Map<number, OciConfigSummary>()
|
|
for (const c of configs.data.value ?? []) map.set(c.id, c)
|
|
return map
|
|
})
|
|
|
|
const detail = ref<LogEvent | null>(null)
|
|
const showDetail = ref(false)
|
|
|
|
/** 受控远程分页:字面量对象会在重渲染时被重建导致状态丢失 */
|
|
const pagination = reactive({
|
|
page: 1,
|
|
pageSize: 20,
|
|
itemCount: 0,
|
|
showSizePicker: true,
|
|
pageSizes: [20, 50, 100],
|
|
onUpdatePage: (p: number) => {
|
|
pagination.page = p
|
|
void load()
|
|
},
|
|
onUpdatePageSize: (ps: number) => {
|
|
pagination.pageSize = ps
|
|
pagination.page = 1
|
|
void load()
|
|
},
|
|
})
|
|
|
|
async function load(silent = false) {
|
|
if (!silent) loading.value = true
|
|
try {
|
|
const data = await listLogEvents({
|
|
page: pagination.page,
|
|
pageSize: pagination.pageSize,
|
|
cfgId: cfgFilter.value ?? undefined,
|
|
})
|
|
rows.value = data.items
|
|
pagination.itemCount = data.total
|
|
} catch (e) {
|
|
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
|
|
} finally {
|
|
if (!silent) loading.value = false
|
|
}
|
|
}
|
|
|
|
function onFilter() {
|
|
pagination.page = 1
|
|
void load()
|
|
}
|
|
|
|
const aliasById = computed(() => {
|
|
const map = new Map<number, string>()
|
|
for (const o of scope.cfgOptions) map.set(Number(o.value), String(o.label))
|
|
return map
|
|
})
|
|
|
|
/** 租户单元格:点击跳租户详情(阻止冒泡到行详情);hover 只展示值不加标签 */
|
|
function renderTenant(row: LogEvent) {
|
|
const label = aliasById.value.get(row.ociConfigId) ?? `#${row.ociConfigId}`
|
|
const cfg = cfgById.value.get(row.ociConfigId)
|
|
const link = h(
|
|
'span',
|
|
{
|
|
class: 'cursor-pointer border-b border-dashed border-ink-3/50 text-[13px] hover:text-accent',
|
|
onClick: (e: MouseEvent) => {
|
|
e.stopPropagation()
|
|
void router.push({ name: 'tenant-detail', params: { id: row.ociConfigId } })
|
|
},
|
|
},
|
|
label,
|
|
)
|
|
if (!cfg) return link
|
|
return h(
|
|
NPopover,
|
|
{ trigger: 'hover', placement: 'right' },
|
|
{
|
|
trigger: () => link,
|
|
default: () =>
|
|
h('div', { class: 'flex flex-col gap-1 text-xs' }, [
|
|
h('div', { class: 'font-medium text-[13px]' }, cfg.alias),
|
|
h('div', { class: 'mono text-ink-2' }, cfg.tenancyName),
|
|
h('div', { class: 'text-ink-2' }, regionAlias(cfg.region)),
|
|
]),
|
|
},
|
|
)
|
|
}
|
|
|
|
/** 摘要头标题:事件中文名 → 类型尾段 → 解析状态兜底 */
|
|
const heroTitle = computed(() => {
|
|
const d = detail.value
|
|
if (!d) return ''
|
|
if (d.eventType) return eventLabel(d.eventType) || eventTail(d.eventType)
|
|
return d.processed ? '未识别事件' : '解析中…'
|
|
})
|
|
|
|
/** 摘要头 chips:接收时间 / 事件时间 / 来源 IP */
|
|
const heroChips = computed<HeroChip[]>(() => {
|
|
const d = detail.value
|
|
if (!d) return []
|
|
return [
|
|
{ label: '接收', value: fmtTime(d.receivedAt) },
|
|
{ label: '事件时间', value: d.eventTime ? fmtTime(d.eventTime) : '—' },
|
|
{ label: '来源 IP', value: d.sourceIp || '—', mono: true },
|
|
]
|
|
})
|
|
|
|
/** 字段网格:头部未覆盖的补充字段;payload 原文由 JsonBlock 高亮展示 */
|
|
const detailRows = computed<DetailRow[]>(() => {
|
|
const d = detail.value
|
|
if (!d) return []
|
|
return [
|
|
{ label: 'MessageId', value: d.messageId, mono: true, span: 2 as const, ellipsis: true },
|
|
{ label: '事件类型', value: d.eventType, mono: true, span: 2 as const, ellipsis: true },
|
|
]
|
|
})
|
|
|
|
function openDetail(row: LogEvent) {
|
|
detail.value = row
|
|
showDetail.value = true
|
|
}
|
|
|
|
const columns: DataTableColumns<LogEvent> = [
|
|
{
|
|
title: '接收时间',
|
|
key: 'receivedAt',
|
|
width: 150,
|
|
render: (row) =>
|
|
h('span', { class: 'text-[13px] whitespace-nowrap text-ink-2' }, fmtTime(row.receivedAt)),
|
|
},
|
|
{
|
|
title: '租户',
|
|
key: 'ociConfigId',
|
|
width: 130,
|
|
render: renderTenant,
|
|
},
|
|
{
|
|
// 固定宽:常见事件类型约 45 字符,弹性空间让给来源列
|
|
title: '事件类型',
|
|
key: 'eventType',
|
|
width: 400,
|
|
ellipsis: { tooltip: true },
|
|
render: (row) =>
|
|
row.eventType
|
|
? h('span', { class: 'mono text-[12.5px]' }, row.eventType)
|
|
: h('span', { class: 'text-[12.5px] text-ink-3' }, row.processed ? '未识别' : '解析中…'),
|
|
},
|
|
{
|
|
title: 'IP',
|
|
key: 'sourceIp',
|
|
width: 140,
|
|
render: (row) =>
|
|
row.sourceIp
|
|
? h('span', { class: 'mono text-[12.5px] text-ink-2' }, row.sourceIp)
|
|
: h('span', { class: 'text-[13px] text-ink-3' }, '—'),
|
|
},
|
|
{
|
|
// 弹性列:实例名可较长,宽屏下尽量完整展示
|
|
title: '来源',
|
|
key: 'source',
|
|
minWidth: 160,
|
|
ellipsis: { tooltip: true },
|
|
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, row.source || '—'),
|
|
},
|
|
{
|
|
title: '标记',
|
|
key: 'truncated',
|
|
width: 90,
|
|
render: (row) =>
|
|
row.truncated ? h(StatusBadge, { kind: 'term', label: '已截断', dot: false }) : null,
|
|
},
|
|
]
|
|
|
|
function rowProps(row: LogEvent) {
|
|
return { style: 'cursor: pointer', onClick: () => openDetail(row) }
|
|
}
|
|
|
|
void load()
|
|
useAutoRefresh(() => load(true))
|
|
</script>
|
|
|
|
<template>
|
|
<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">
|
|
<TenantPicker
|
|
v-model:value="cfgFilter"
|
|
size="small"
|
|
clearable
|
|
placeholder="全部租户"
|
|
class="w-52"
|
|
placement="bottom-end"
|
|
:configs="scope.configs.data ?? []"
|
|
:loading="scope.configs.loading"
|
|
@update:value="onFilter"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<NDataTable
|
|
remote
|
|
:columns="columns"
|
|
:data="rows"
|
|
:loading="loading"
|
|
:pagination="pagination"
|
|
:scroll-x="880"
|
|
:row-key="(r: LogEvent) => r.id"
|
|
:row-props="rowProps"
|
|
/>
|
|
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
|
|
<template #header>
|
|
<DetailHero
|
|
v-if="detail"
|
|
kind="回传日志 · Connector → Webhook"
|
|
:title="heroTitle"
|
|
:sub="`${aliasById.get(detail.ociConfigId) ?? `#${detail.ociConfigId}`} · ${detail.source || '来源未知'}`"
|
|
:chips="heroChips"
|
|
>
|
|
<StatusBadge
|
|
v-if="detail.eventType"
|
|
kind="prov"
|
|
:label="eventTail(detail.eventType)"
|
|
:dot="false"
|
|
/>
|
|
</DetailHero>
|
|
</template>
|
|
<div v-if="detail" class="flex flex-col gap-3.5">
|
|
<DetailFieldList :rows="detailRows" :cols="2" />
|
|
<JsonBlock
|
|
:value="detail.payload"
|
|
title="Payload 原文"
|
|
:meta="`ONS 投递${detail.truncated ? ' · 超限已截断' : ''}`"
|
|
max-height="42vh"
|
|
wide
|
|
/>
|
|
</div>
|
|
</NModal>
|
|
</div>
|
|
</template>
|