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:
@@ -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
|
||||
}
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('已恢复')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user