This commit is contained in:
zxr
2026-04-27 19:26:44 +08:00
parent d9a0470ecf
commit 25e8eafc69
8 changed files with 459 additions and 19 deletions

View File

@@ -10,6 +10,9 @@ VITE_APP_DESCRIPTION="default standard template"
# API 基础URL
VITE_API_BASE_URL=https://ops-api.apinb.com
# Logs 本地调试地址(仅 logs 模块使用)
VITE_LOGS_API_BASE_URL=http://127.0.0.1:12440
# 应用版本

View File

@@ -1,6 +1,7 @@
import { request } from '@/api/request'
const BASE = '/Logs/v1'
const logsHost = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/+$/, '')
const BASE = logsHost ? `${logsHost}/Logs/v1` : '/Logs/v1'
/** 解析 bsm-sdk 风格响应,取出业务 data */
export function unwrapLogsPayload(res: any): any {
@@ -14,10 +15,15 @@ export interface LogEvent {
created_at: string
source_kind: string
remote_addr: string
source_ip: string
raw_payload: string
normalized_summary: string
normalized_detail: string
device_name: string
resource_type: string
resource_id: string
resource_name: string
match_method: string
severity_code: string
trap_oid: string
alert_sent: boolean
@@ -27,6 +33,10 @@ export interface LogEntriesParams {
page?: number
page_size?: number
source_kind?: string
resource_type?: string
resource_id?: string
dispatch_status?: string
log_event_id?: number
}
export interface LogEntriesResult {
@@ -36,6 +46,24 @@ export interface LogEntriesResult {
items: LogEvent[]
}
export interface AlertOutbox {
id: number
created_at: string
updated_at: string
log_event_id: number
payload_json: string
status: string
retry_count: number
next_retry_at: string
last_error: string
}
export interface AlertOutboxParams {
page?: number
page_size?: number
status?: string
}
export interface SyslogRule {
id?: number
created_at?: string
@@ -92,6 +120,14 @@ export function fetchLogEntries(params?: LogEntriesParams) {
return request.get(`${BASE}/entries`, { params })
}
export function fetchAlertOutbox(params?: AlertOutboxParams) {
return request.get(`${BASE}/alert-outbox`, { params })
}
export function retryAlertOutbox(id: number) {
return request.post(`${BASE}/alert-outbox/${id}/retry`)
}
export function fetchSyslogRules() {
return request.get(`${BASE}/syslog-rules`)
}

1
src/types/env.d.ts vendored
View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_LOGS_API_BASE_URL?: string
// 在这里可以继续补充其他 VITE_ 前缀的环境变量
}

View File

@@ -83,16 +83,6 @@
{{ recordDetail.rule_id || '-' }}
</a-descriptions-item>
<!-- 标签信息 -->
<a-descriptions-item label="标签" :span="2">
<a-space wrap v-if="recordDetail.labels && Object.keys(recordDetail.labels).length > 0">
<a-tag v-for="(value, key) in recordDetail.labels" :key="key" color="blue">
{{ key }}: {{ value }}
</a-tag>
</a-space>
<span v-else>-</span>
</a-descriptions-item>
<!-- 创建与更新时间 -->
<a-descriptions-item label="创建时间">
{{ formatDate(recordDetail.created_at) }}

View File

@@ -18,12 +18,18 @@
@page-size-change="handlePageSizeChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="outline" @click="openOutboxDrawer">告警队列</a-button>
</template>
<template #source_kind="{ record }">
<a-tag>{{ sourceKindLabel(record.source_kind) }}</a-tag>
</template>
<template #raw_payload="{ record }">
<span class="ellipsis-cell" :title="record.raw_payload">{{ truncate(record.raw_payload, 80) }}</span>
</template>
<template #severity_code="{ record }">
<a-tag :color="severityColor(record.severity_code)">{{ severityLabel(record.severity_code) }}</a-tag>
</template>
<template #alert_sent="{ record }">
<a-tag :color="record.alert_sent ? 'green' : 'gray'">
{{ record.alert_sent ? '已转发' : '否' }}
@@ -48,8 +54,13 @@
</a-descriptions-item>
<a-descriptions-item label="采集时间">{{ detailRow.created_at }}</a-descriptions-item>
<a-descriptions-item label="来源地址">{{ detailRow.remote_addr || '-' }}</a-descriptions-item>
<a-descriptions-item label="来源 IP">{{ detailRow.source_ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="设备名">{{ detailRow.device_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="严重级别">{{ detailRow.severity_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="资源类型">{{ detailRow.resource_type || '-' }}</a-descriptions-item>
<a-descriptions-item label="资源 ID">{{ detailRow.resource_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="资源名称">{{ detailRow.resource_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="命中方式">{{ detailRow.match_method || '-' }}</a-descriptions-item>
<a-descriptions-item label="严重级别">{{ severityLabel(detailRow.severity_code) }}</a-descriptions-item>
<a-descriptions-item label="Trap OID">{{ detailRow.trap_oid || '-' }}</a-descriptions-item>
<a-descriptions-item label="已转发告警">
{{ detailRow.alert_sent ? '是' : '否' }}
@@ -64,28 +75,141 @@
</a-descriptions>
</template>
</a-drawer>
<a-drawer
v-model:visible="outboxVisible"
width="920px"
title="告警队列"
:footer="false"
unmount-on-close
>
<a-space direction="vertical" fill>
<a-space>
<a-select
v-model="outboxStatus"
:style="{ width: '180px' }"
placeholder="全部状态"
allow-clear
@change="handleOutboxSearch"
>
<a-option value="">全部状态</a-option>
<a-option value="pending">pending</a-option>
<a-option value="retrying">retrying</a-option>
<a-option value="sent">sent</a-option>
<a-option value="dead">dead</a-option>
</a-select>
<a-button :loading="outboxLoading" @click="fetchOutboxList">刷新</a-button>
</a-space>
<a-table
:data="outboxRows"
:loading="outboxLoading"
:pagination="false"
row-key="id"
size="small"
>
<template #columns>
<a-table-column title="ID" data-index="id" :width="70" />
<a-table-column title="日志ID" :width="100">
<template #cell="{ record }">
<a-button type="text" size="small" @click="jumpToLogDetail(record.log_event_id)">
{{ record.log_event_id }}
</a-button>
</template>
</a-table-column>
<a-table-column title="状态" :width="110">
<template #cell="{ record }">
<a-tag :color="outboxStatusColor(record.status)">
{{ outboxStatusLabel(record.status) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="重试次数" data-index="retry_count" :width="90" />
<a-table-column title="下次重试" data-index="next_retry_at" :width="180" />
<a-table-column title="错误" data-index="last_error" ellipsis tooltip />
<a-table-column title="操作" :width="150">
<template #cell="{ record }">
<a-button
type="text"
size="small"
:disabled="record.status === 'sent'"
@click="handleOutboxRetry(record.id)"
>
重试
</a-button>
<a-button type="text" size="small" @click="openPayloadModal(record.payload_json)">
Payload
</a-button>
</template>
</a-table-column>
</template>
</a-table>
<a-pagination
:current="outboxPagination.current"
:total="outboxPagination.total"
:page-size="outboxPagination.pageSize"
show-total
show-page-size
@change="handleOutboxPageChange"
@page-size-change="handleOutboxPageSizeChange"
/>
</a-space>
</a-drawer>
<a-modal
v-model:visible="payloadVisible"
title="Payload 详情"
width="760px"
:footer="false"
unmount-on-close
>
<pre class="pre-block">{{ payloadText }}</pre>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { Message, Modal } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
fetchAlertOutbox,
fetchLogEntries,
retryAlertOutbox,
unwrapLogsPayload,
type AlertOutbox,
type LogEvent,
} from '@/api/ops/logs'
import { fetchSeverityOptions } from '@/api/ops/alertPolicy'
const loading = ref(false)
const tableData = ref<LogEvent[]>([])
const formModel = ref({
source_kind: '' as string,
resource_type: '' as string,
resource_id: '' as string,
dispatch_status: '' as string,
})
const detailVisible = ref(false)
const detailRow = ref<LogEvent | null>(null)
const outboxVisible = ref(false)
const outboxStatus = ref('')
const outboxLoading = ref(false)
const outboxRows = ref<AlertOutbox[]>([])
const payloadVisible = ref(false)
const payloadText = ref('')
type SeverityOption = {
code: string
name?: string
color?: string
}
const severityOptions = ref<SeverityOption[]>([])
const outboxPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const pagination = reactive({
current: 1,
@@ -106,6 +230,41 @@ const formItems = computed<FormItem[]>(() => [
{ label: 'SNMP Trap', value: 'snmp_trap' },
],
},
{
field: 'resource_type',
label: '资源类型',
type: 'select',
placeholder: '全部',
span: 6,
options: [
{ label: '全部', value: '' },
{ label: '服务器', value: 'server' },
{ label: '采集设备', value: 'collector' },
{ label: '设备', value: 'device' },
],
},
{
field: 'resource_id',
label: '资源ID',
type: 'input',
placeholder: '输入资源ID',
span: 6,
},
{
field: 'dispatch_status',
label: '分发状态',
type: 'select',
placeholder: '全部',
span: 6,
options: [
{ label: '全部', value: '' },
{ label: '待发送', value: 'pending' },
{ label: '重试中', value: 'retrying' },
{ label: '已发送', value: 'sent' },
{ label: '已失败', value: 'dead' },
{ label: '不适用', value: 'not_applicable' },
],
},
])
const columns = computed<TableColumnData[]>(() => [
@@ -118,8 +277,13 @@ const columns = computed<TableColumnData[]>(() => [
},
{ title: '时间', dataIndex: 'created_at', width: 180, ellipsis: true, tooltip: true },
{ title: '来源地址', dataIndex: 'remote_addr', width: 130, ellipsis: true, tooltip: true },
{ title: '来源IP', dataIndex: 'source_ip', width: 120, ellipsis: true, tooltip: true },
{ title: '设备', dataIndex: 'device_name', width: 120, ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: '资源类型', dataIndex: 'resource_type', width: 100 },
{ title: '资源ID', dataIndex: 'resource_id', width: 140, ellipsis: true, tooltip: true },
{ title: '资源名称', dataIndex: 'resource_name', width: 140, ellipsis: true, tooltip: true },
{ title: '命中方式', dataIndex: 'match_method', width: 90 },
{ title: '级别', dataIndex: 'severity_code', slotName: 'severity_code', width: 110 },
{ title: 'OID', dataIndex: 'trap_oid', width: 140, ellipsis: true, tooltip: true },
{
title: '原始报文',
@@ -149,13 +313,71 @@ function sourceKindLabel(k: string) {
return k || '-'
}
function outboxStatusLabel(status: string) {
if (status === 'pending') return '待发送'
if (status === 'retrying') return '重试中'
if (status === 'sent') return '已发送'
if (status === 'dead') return '已失败'
return status || '-'
}
function outboxStatusColor(status: string) {
if (status === 'pending') return 'arcoblue'
if (status === 'retrying') return 'orange'
if (status === 'sent') return 'green'
if (status === 'dead') return 'red'
return 'gray'
}
function severityMeta(code: string) {
const key = (code || '').trim().toLowerCase()
if (!key) return null
return (
severityOptions.value.find((item) => String(item.code || '').trim().toLowerCase() === key) || null
)
}
function severityLabel(code: string) {
const meta = severityMeta(code)
if (meta?.name) return meta.name
const key = (code || '').toLowerCase()
if (!key) return '-'
if (['critical', 'fatal', 'emergency', 'emerg', 'alert'].includes(key)) return '严重'
if (['error', 'err'].includes(key)) return '错误'
if (['warning', 'warn'].includes(key)) return '警告'
if (['notice', 'info', 'informational'].includes(key)) return '信息'
if (['debug', 'trace'].includes(key)) return '调试'
return code
}
function severityColor(code: string) {
const meta = severityMeta(code)
return meta?.color || 'arcoblue'
}
async function fetchSeverityMetaList() {
try {
const res: any = await fetchSeverityOptions({ enabled: 'true' })
const payload = res?.details ?? res?.data ?? []
severityOptions.value = Array.isArray(payload) ? (payload as SeverityOption[]) : []
} catch (e) {
console.error('加载告警级别选项失败:', e)
severityOptions.value = []
}
}
function truncate(s: string, n: number) {
if (!s) return '-'
return s.length <= n ? s : `${s.slice(0, n)}`
}
function handleFormModelUpdate(v: Record<string, unknown>) {
formModel.value = v as { source_kind: string }
formModel.value = v as {
source_kind: string
resource_type: string
resource_id: string
dispatch_status: string
}
}
async function fetchList() {
@@ -165,6 +387,9 @@ async function fetchList() {
page: pagination.current,
page_size: pagination.pageSize,
source_kind: formModel.value.source_kind || undefined,
resource_type: formModel.value.resource_type || undefined,
resource_id: formModel.value.resource_id?.trim() || undefined,
dispatch_status: formModel.value.dispatch_status || undefined,
})
const payload = unwrapLogsPayload(res) ?? {}
tableData.value = payload.items ?? []
@@ -188,7 +413,7 @@ function handleSearch() {
}
function handleReset() {
formModel.value = { source_kind: '' }
formModel.value = { source_kind: '', resource_type: '', resource_id: '', dispatch_status: '' }
pagination.current = 1
fetchList()
}
@@ -213,6 +438,104 @@ function openDetail(row: LogEvent) {
detailVisible.value = true
}
function openOutboxDrawer() {
outboxVisible.value = true
fetchOutboxList()
}
function handleOutboxSearch() {
outboxPagination.current = 1
fetchOutboxList()
}
async function fetchOutboxList() {
outboxLoading.value = true
try {
const res: any = await fetchAlertOutbox({
page: outboxPagination.current,
page_size: outboxPagination.pageSize,
status: outboxStatus.value || undefined,
})
const payload = unwrapLogsPayload(res) ?? {}
outboxRows.value = (payload.items ?? []) as AlertOutbox[]
outboxPagination.total = payload.total ?? 0
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '队列加载失败')
}
} catch (e: any) {
Message.error(e?.message || '队列加载失败')
outboxRows.value = []
outboxPagination.total = 0
} finally {
outboxLoading.value = false
}
}
function handleOutboxPageChange(page: number) {
outboxPagination.current = page
fetchOutboxList()
}
function handleOutboxPageSizeChange(size: number) {
outboxPagination.pageSize = size
outboxPagination.current = 1
fetchOutboxList()
}
function handleOutboxRetry(id: number) {
Modal.confirm({
title: '确认重试',
content: `立即重试队列任务 #${id}`,
onOk: async () => {
try {
const res: any = await retryAlertOutbox(id)
if (typeof res.code === 'number' && res.code !== 0) {
Message.error(res.message || res.msg || '重试失败')
return
}
Message.success('已提交重试')
fetchOutboxList()
} catch (e: any) {
Message.error(e?.message || '重试失败')
}
},
})
}
function openPayloadModal(payload: string) {
let text = payload || ''
try {
text = JSON.stringify(JSON.parse(payload || '{}'), null, 2)
} catch {
// 保持原样,便于排查非标准 JSON。
}
payloadText.value = text
payloadVisible.value = true
}
async function jumpToLogDetail(logEventID: number) {
if (!logEventID) return
try {
const res: any = await fetchLogEntries({
page: 1,
page_size: 1,
log_event_id: logEventID,
})
const payload = unwrapLogsPayload(res) ?? {}
const row = (payload.items?.[0] || null) as LogEvent | null
if (!row) {
Message.warning(`未找到日志 #${logEventID}`)
return
}
outboxVisible.value = false
detailRow.value = row
detailVisible.value = true
} catch (e: any) {
Message.error(e?.message || '日志定位失败')
}
}
fetchSeverityMetaList()
fetchList()
</script>

View File

@@ -27,6 +27,9 @@
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">{{ record.enabled ? '启用' : '禁用' }}</a-tag>
</template>
<template #severity_code="{ record }">
<a-tag :color="severityColor(record.severity_code)">{{ severityLabel(record.severity_code) }}</a-tag>
</template>
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
@@ -296,11 +299,37 @@ const columns = computed<TableColumnData[]>(() => [
{ title: '设备名包含', dataIndex: 'device_name_contains', ellipsis: true, tooltip: true },
{ title: '关键字正则', dataIndex: 'keyword_regex', ellipsis: true, tooltip: true },
{ title: '告警名', dataIndex: 'alert_name', ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: '级别', dataIndex: 'severity_code', slotName: 'severity_code', width: 110 },
{ title: '策略ID', dataIndex: 'policy_id', width: 88 },
{ title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
])
function severityMeta(code: string) {
const key = (code || '').trim().toLowerCase()
if (!key) return null
return (
severityOptions.value.find((item) => String(item.code || '').trim().toLowerCase() === key) || null
)
}
function severityLabel(code: string) {
const meta = severityMeta(code)
if (meta?.name) return meta.name
const key = (code || '').toLowerCase()
if (!key) return '-'
if (['critical', 'fatal', 'emergency', 'emerg', 'alert'].includes(key)) return '严重'
if (['error', 'err'].includes(key)) return '错误'
if (['warning', 'warn'].includes(key)) return '警告'
if (['notice', 'info', 'informational'].includes(key)) return '信息'
if (['debug', 'trace'].includes(key)) return '调试'
return code
}
function severityColor(code: string) {
const meta = severityMeta(code)
return meta?.color || 'arcoblue'
}
function applyFilter(list: SyslogRule[]) {
const kw = formModel.value.keyword?.trim()
if (!kw) return list

View File

@@ -27,6 +27,9 @@
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">{{ record.enabled ? '启用' : '禁用' }}</a-tag>
</template>
<template #severity_code="{ record }">
<a-tag :color="severityColor(record.severity_code)">{{ severityLabel(record.severity_code) }}</a-tag>
</template>
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
@@ -197,12 +200,38 @@ const columns = computed<TableColumnData[]>(() => [
{ title: 'ID', dataIndex: 'id', width: 72 },
{ title: 'OID 前缀', dataIndex: 'oid_prefix', ellipsis: true, tooltip: true },
{ title: '标题', dataIndex: 'title', ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: '级别', dataIndex: 'severity_code', slotName: 'severity_code', width: 110 },
{ title: '启用', dataIndex: 'enabled', slotName: 'enabled', width: 88 },
{ title: '描述', dataIndex: 'description', ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
])
function severityMeta(code: string) {
const key = (code || '').trim().toLowerCase()
if (!key) return null
return (
severityOptions.value.find((item) => String(item.code || '').trim().toLowerCase() === key) || null
)
}
function severityLabel(code: string) {
const meta = severityMeta(code)
if (meta?.name) return meta.name
const key = (code || '').toLowerCase()
if (!key) return '-'
if (['critical', 'fatal', 'emergency', 'emerg', 'alert'].includes(key)) return '严重'
if (['error', 'err'].includes(key)) return '错误'
if (['warning', 'warn'].includes(key)) return '警告'
if (['notice', 'info', 'informational'].includes(key)) return '信息'
if (['debug', 'trace'].includes(key)) return '调试'
return code
}
function severityColor(code: string) {
const meta = severityMeta(code)
return meta?.color || 'arcoblue'
}
function applyFilter(list: TrapDictionaryEntry[]) {
const kw = formModel.value.keyword?.trim()
if (!kw) return list

View File

@@ -27,6 +27,9 @@
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">{{ record.enabled ? '启用' : '禁用' }}</a-tag>
</template>
<template #severity_code="{ record }">
<a-tag :color="severityColor(record.severity_code)">{{ severityLabel(record.severity_code) }}</a-tag>
</template>
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
@@ -289,11 +292,37 @@ const columns = computed<TableColumnData[]>(() => [
{ title: 'OID 前缀', dataIndex: 'oid_prefix', ellipsis: true, tooltip: true },
{ title: 'Varbind 正则', dataIndex: 'varbind_match_regex', ellipsis: true, tooltip: true },
{ title: '告警名', dataIndex: 'alert_name', ellipsis: true, tooltip: true },
{ title: '级别', dataIndex: 'severity_code', width: 90 },
{ title: '级别', dataIndex: 'severity_code', slotName: 'severity_code', width: 110 },
{ title: '策略ID', dataIndex: 'policy_id', width: 88 },
{ title: '操作', slotName: 'operations', width: 160, fixed: 'right' },
])
function severityMeta(code: string) {
const key = (code || '').trim().toLowerCase()
if (!key) return null
return (
severityOptions.value.find((item) => String(item.code || '').trim().toLowerCase() === key) || null
)
}
function severityLabel(code: string) {
const meta = severityMeta(code)
if (meta?.name) return meta.name
const key = (code || '').toLowerCase()
if (!key) return '-'
if (['critical', 'fatal', 'emergency', 'emerg', 'alert'].includes(key)) return '严重'
if (['error', 'err'].includes(key)) return '错误'
if (['warning', 'warn'].includes(key)) return '警告'
if (['notice', 'info', 'informational'].includes(key)) return '信息'
if (['debug', 'trace'].includes(key)) return '调试'
return code
}
function severityColor(code: string) {
const meta = severityMeta(code)
return meta?.color || 'arcoblue'
}
function applyFilter(list: TrapRule[]) {
const kw = formModel.value.keyword?.trim()
if (!kw) return list