feat(web): 运维告警页 /ops-alerts(四类tab+红黄/状态筛选+evidence展开+ack+导航红点角标,spec §20.15.4) [nas]

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-25 23:04:21 +08:00
parent 0d1deb5fe1
commit 453b133d60
6 changed files with 290 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
import { apiClient } from './client'
export type AlertSource = 'data' | 'strategy' | 'factor' | 'infra'
export type AlertSeverity = 'red' | 'yellow'
export type AlertStatus = 'open' | 'acked' | 'resolved'
export interface AlertItem {
alert_id: string
check_key: string
source: AlertSource
host: 'vps' | 'nas'
severity: AlertSeverity
title: string
detail: string
evidence: Record<string, unknown>
first_seen: string
last_seen: string
count: number
status: AlertStatus
acked_at?: string | null
acked_by?: string | null
resolved_at?: string | null
}
export interface AlertsQuery {
source?: string
severity?: string
status?: string
host?: string
limit?: number
offset?: number
}
export async function getAlerts(params: AlertsQuery = {}): Promise<{ items: AlertItem[]; total: number }> {
const { data } = await apiClient.get<{ items: AlertItem[]; total: number }>('/alerts', { params })
return data
}
export async function getAlertsSummary(): Promise<{ open_red: number; by_source: Record<string, number> }> {
const { data } = await apiClient.get<{ open_red: number; by_source: Record<string, number> }>('/alerts/summary')
return data
}
export async function ackAlert(alertId: string): Promise<{ alert_id: string; status: string }> {
const { data } = await apiClient.post<{ alert_id: string; status: string }>(`/alerts/${alertId}/ack`)
return data
}
+1
View File
@@ -46,6 +46,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'pipeline/incubation', name: 'pl-incubation', component: () => import('@/views/pipeline/IncubationLadder.vue') },
{ path: 'pipeline/attribution', name: 'pl-attribution', component: () => import('@/views/pipeline/AttributionResearch.vue') },
{ path: 'pipeline/gate-config', name: 'pl-gate-config', component: () => import('@/views/pipeline/GateConfig.vue') },
{ path: 'ops-alerts', name: 'ops-alerts', component: () => import('@/views/ops/Alerts.vue') },
],
},
]
+31
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getAlertsSummary } from '@/api/alerts'
import CompareTray from '@/views/factor/CompareTray.vue'
const router = useRouter()
@@ -52,6 +53,7 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/pipeline\/incubation$/, group: '流水线', title: '孵化梯' },
{ match: /^\/pipeline\/attribution$/, group: '流水线', title: '归因·研判' },
{ match: /^\/pipeline\/gate-config$/, group: '流水线', title: '闸门阈值' },
{ match: /^\/ops-alerts$/, group: '运维', title: '运维告警' },
]
const pageMeta = computed(() => {
const p = route.path
@@ -96,6 +98,7 @@ const CMD_ITEMS: CmdItem[] = [
{ label: '孵化梯', group: '流水线', path: '/pipeline/incubation' },
{ label: '归因·研判', group: '流水线', path: '/pipeline/attribution' },
{ label: '闸门阈值', group: '流水线', path: '/pipeline/gate-config' },
{ label: '运维告警', group: '运维', path: '/ops-alerts' },
]
const cmdOpen = ref(false)
const cmdQuery = ref('')
@@ -146,12 +149,26 @@ onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
window.addEventListener('keydown', onKeydown)
pollAlerts()
alertTimer = window.setInterval(pollAlerts, 60_000)
})
onUnmounted(() => {
clearInterval(timer)
window.clearInterval(alertTimer)
window.removeEventListener('keydown', onKeydown)
})
/* —— 运维告警红点角标(spec §20.15.4: open 红项数) —— */
const alertBadge = ref(0)
let alertTimer = 0
async function pollAlerts(): Promise<void> {
try {
alertBadge.value = (await getAlertsSummary()).open_red
} catch {
// 未登录/接口不可达时静默, 60s 后重试
}
}
function navigate(path: string): void {
router.push(path)
}
@@ -221,6 +238,10 @@ function onLogout(): void {
<el-menu-item index="/pipeline/attribution">归因·研判</el-menu-item>
<el-menu-item index="/pipeline/gate-config">闸门阈值</el-menu-item>
</el-sub-menu>
<el-menu-item index="/ops-alerts">
<span class="nav-label">运维告警</span>
<span v-if="alertBadge > 0" class="alert-badge">{{ alertBadge }}</span>
</el-menu-item>
<el-menu-item index="/account"><span class="nav-label">账户</span></el-menu-item>
</el-menu>
@@ -393,6 +414,16 @@ function onLogout(): void {
gap: 7px;
}
.alert-badge {
margin-left: 8px;
padding: 0 6px;
border-radius: 9px;
font-size: 11px;
line-height: 16px;
background: var(--lamp-crit, #ff4d5e);
color: #fff;
}
.header {
height: var(--header-h);
display: flex;
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ackAlert, getAlerts, type AlertItem } from '@/api/alerts'
import { filterAlerts, nightsLabel, sourceLabel, statusLabel } from './alertsLogic'
const SOURCE_TABS = [
{ key: '', label: '全部' },
{ key: 'data', label: '数据' },
{ key: 'strategy', label: '策略' },
{ key: 'factor', label: '因子' },
{ key: 'infra', label: '运维' },
]
const SEVERITIES = [
{ key: '', label: '全部级别' },
{ key: 'red', label: '红' },
{ key: 'yellow', label: '黄' },
]
const STATUSES = [
{ key: '', label: '全部状态' },
{ key: 'open', label: '未处理' },
{ key: 'acked', label: '已确认' },
{ key: 'resolved', label: '已恢复' },
]
const source = ref('')
const severity = ref('')
const status = ref('open')
const items = ref<AlertItem[]>([])
const total = ref(0)
const loading = ref(false)
let timer: number | undefined
const filtered = computed(() =>
filterAlerts(items.value, {
source: source.value,
severity: severity.value,
status: status.value,
}))
async function load() {
loading.value = true
try {
const data = await getAlerts({ limit: 1000 })
items.value = data.items
total.value = data.total
} catch {
// 拉取失败保持旧数据; 60s 后自动重试
} finally {
loading.value = false
}
}
async function onAck(row: AlertItem) {
try {
await ackAlert(row.alert_id)
ElMessage.success('已确认')
await load()
} catch {
ElMessage.error('ack 失败(已恢复的告警无需 ack)')
}
}
onMounted(() => {
load()
timer = window.setInterval(load, 60_000)
})
onBeforeUnmount(() => window.clearInterval(timer))
</script>
<template>
<div class="alerts-page">
<div class="toolbar">
<el-radio-group v-model="source" size="small">
<el-radio-button v-for="t in SOURCE_TABS" :key="t.key" :value="t.key">
{{ t.label }}
</el-radio-button>
</el-radio-group>
<el-select v-model="severity" size="small" style="width: 130px">
<el-option v-for="s in SEVERITIES" :key="s.key" :label="s.label" :value="s.key" />
</el-select>
<el-select v-model="status" size="small" style="width: 130px">
<el-option v-for="s in STATUSES" :key="s.key" :label="s.label" :value="s.key" />
</el-select>
<span class="dim">共 {{ total }} 条,60s 自动刷新</span>
</div>
<el-table :data="filtered" size="small" v-loading="loading" row-key="alert_id">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-body">
<p class="detail">{{ row.detail }}</p>
<p class="dim">check_key: {{ row.check_key }} · first_seen {{ row.first_seen }}</p>
<pre class="evidence">{{ JSON.stringify(row.evidence, null, 2) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="级别" width="70">
<template #default="{ row }">
<span class="chip" :class="row.severity === 'red' ? 'st-crit' : 'st-warn'">
{{ row.severity === 'red' ? '红' : '黄' }}
</span>
</template>
</el-table-column>
<el-table-column label="标题" min-width="260">
<template #default="{ row }">
{{ row.title }} <span v-if="nightsLabel(row.count)" class="nights">{{ nightsLabel(row.count) }}</span>
</template>
</el-table-column>
<el-table-column label="类别" width="70">
<template #default="{ row }">{{ sourceLabel(row.source) }}</template>
</el-table-column>
<el-table-column label="主机" width="70">
<template #default="{ row }">{{ row.host }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<span class="chip" :class="row.status === 'open' ? 'st-warn' : 'st-ok'">{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="最近" width="170">
<template #default="{ row }">{{ row.last_seen }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button v-if="row.status === 'open'" size="small" @click="onAck(row)">确认</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.alerts-page { padding: 12px; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
.dim { color: var(--tx-dim, #888); font-size: 12px; }
.nights { color: var(--lamp-crit, #ff4d5e); font-size: 12px; margin-left: 6px; }
.expand-body { padding: 4px 12px; }
.evidence {
background: var(--bg-soft, #1b1f27); padding: 8px; border-radius: 6px;
font-size: 12px; max-height: 320px; overflow: auto; white-space: pre-wrap;
}
.st-crit { color: var(--lamp-crit, #ff4d5e); }
.st-warn { color: var(--lamp-warn, #f7b955); }
.st-ok { color: var(--lamp-ok, #2ee68a); }
</style>
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import type { AlertItem } from '@/api/alerts'
import { filterAlerts, nightsLabel, statusLabel } from './alertsLogic'
const item = (over: Partial<AlertItem>): AlertItem => ({
alert_id: 'x', check_key: 'x', source: 'data', host: 'vps', severity: 'red',
title: 't', detail: '', evidence: {}, first_seen: '', last_seen: '', count: 1,
status: 'open', ...over,
})
describe('filterAlerts', () => {
const items = [
item({ alert_id: '1', source: 'data', severity: 'red', status: 'open' }),
item({ alert_id: '2', source: 'infra', severity: 'yellow', status: 'acked' }),
item({ alert_id: '3', source: 'data', severity: 'yellow', status: 'resolved' }),
]
it('filters by source', () => {
expect(filterAlerts(items, { source: 'data' }).map((i) => i.alert_id)).toEqual(['1', '3'])
})
it('combines filters', () => {
expect(filterAlerts(items, { source: 'data', severity: 'yellow' }).map((i) => i.alert_id)).toEqual(['3'])
})
it('empty filter returns all', () => {
expect(filterAlerts(items, {}).length).toBe(3)
})
})
describe('labels', () => {
it('nights only from 2', () => {
expect(nightsLabel(1)).toBe('')
expect(nightsLabel(3)).toBe('第 3 晚')
})
it('status labels', () => {
expect(statusLabel('open')).toBe('未处理')
expect(statusLabel('resolved')).toBe('已恢复')
})
})
+28
View File
@@ -0,0 +1,28 @@
import type { AlertItem } from '@/api/alerts'
export interface AlertFilter {
source?: string
severity?: string
status?: string
}
export function filterAlerts(items: AlertItem[], filter: AlertFilter): AlertItem[] {
return items.filter(
(a) =>
(!filter.source || a.source === filter.source) &&
(!filter.severity || a.severity === filter.severity) &&
(!filter.status || a.status === filter.status),
)
}
export function nightsLabel(count: number): string {
return count > 1 ? `第 ${count} 晚` : ''
}
export function statusLabel(s: string): string {
return { open: '未处理', acked: '已确认', resolved: '已恢复' }[s] ?? s
}
export function sourceLabel(s: string): string {
return { data: '数据', strategy: '策略', factor: '因子', infra: '运维' }[s] ?? s
}