Files
oci-portal-dash/src/components/logs/LogEventPanel.vue
T

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>