From 453b133d60ccee095d25c8ae430a2c01bb408ccd Mon Sep 17 00:00:00 2001 From: claude_dev Date: Fri, 25 Sep 2026 23:04:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=BF=90=E7=BB=B4=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E9=A1=B5=20/ops-alerts(=E5=9B=9B=E7=B1=BBtab+?= =?UTF-8?q?=E7=BA=A2=E9=BB=84/=E7=8A=B6=E6=80=81=E7=AD=9B=E9=80=89+evidenc?= =?UTF-8?q?e=E5=B1=95=E5=BC=80+ack+=E5=AF=BC=E8=88=AA=E7=BA=A2=E7=82=B9?= =?UTF-8?q?=E8=A7=92=E6=A0=87,spec=20=C2=A720.15.4)=20[nas]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- frontend/src/api/alerts.ts | 47 +++++++ frontend/src/router/index.ts | 1 + frontend/src/views/Layout.vue | 31 +++++ frontend/src/views/ops/Alerts.vue | 146 +++++++++++++++++++++ frontend/src/views/ops/alertsLogic.spec.ts | 37 ++++++ frontend/src/views/ops/alertsLogic.ts | 28 ++++ 6 files changed, 290 insertions(+) create mode 100644 frontend/src/api/alerts.ts create mode 100644 frontend/src/views/ops/Alerts.vue create mode 100644 frontend/src/views/ops/alertsLogic.spec.ts create mode 100644 frontend/src/views/ops/alertsLogic.ts diff --git a/frontend/src/api/alerts.ts b/frontend/src/api/alerts.ts new file mode 100644 index 00000000..da38489b --- /dev/null +++ b/frontend/src/api/alerts.ts @@ -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 + 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 }> { + const { data } = await apiClient.get<{ open_red: number; by_source: Record }>('/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 +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8b7d5244..b8c346e1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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') }, ], }, ] diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 0426d966..90d0c22f 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.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 { + try { + alertBadge.value = (await getAlertsSummary()).open_red + } catch { + // 未登录/接口不可达时静默, 60s 后重试 + } +} + function navigate(path: string): void { router.push(path) } @@ -221,6 +238,10 @@ function onLogout(): void { 归因·研判 闸门阈值 + + 运维告警 + {{ alertBadge }} + 账户 @@ -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; diff --git a/frontend/src/views/ops/Alerts.vue b/frontend/src/views/ops/Alerts.vue new file mode 100644 index 00000000..650c8135 --- /dev/null +++ b/frontend/src/views/ops/Alerts.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/frontend/src/views/ops/alertsLogic.spec.ts b/frontend/src/views/ops/alertsLogic.spec.ts new file mode 100644 index 00000000..d53a29f5 --- /dev/null +++ b/frontend/src/views/ops/alertsLogic.spec.ts @@ -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 => ({ + 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('已恢复') + }) +}) diff --git a/frontend/src/views/ops/alertsLogic.ts b/frontend/src/views/ops/alertsLogic.ts new file mode 100644 index 00000000..e7cfd991 --- /dev/null +++ b/frontend/src/views/ops/alertsLogic.ts @@ -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 +}