feat: ship persistent ai playground and alerts foundation

This commit is contained in:
linkong
2026-04-10 15:57:34 +08:00
parent 60f5ff9bab
commit 89a71e6f29
31 changed files with 4754 additions and 669 deletions

View File

@@ -1,9 +1,14 @@
import { Suspense, lazy } from 'react'
import { Spin } from 'antd'
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/auth'
import Login from './pages/Login/Login'
const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts'))
const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts'))
const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts'))
const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'))
const Users = lazy(() => import('./pages/Users/Users'))
const DataSources = lazy(() => import('./pages/DataSources/DataSources'))
@@ -37,6 +42,10 @@ function App() {
<Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />
<Route path="/alerts" element={<Navigate to="/alerts/system" replace />} />
<Route path="/alerts/system" element={<SystemAlerts />} />
<Route path="/alerts/bgp" element={<BGPAlerts />} />
<Route path="/alerts/situational" element={<SituationalAlerts />} />
<Route path="/bgp" element={<BGP />} />
<Route path="/playground" element={<Playground />} />
<Route path="/settings" element={<Settings />} />

View File

@@ -1,6 +1,7 @@
import { ReactNode, useState } from 'react'
import { ReactNode, useMemo, useState } from 'react'
import { Layout, Menu, Typography, Button, Space } from 'antd'
import {
AlertOutlined,
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
@@ -10,8 +11,13 @@ import {
RobotOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
GlobalOutlined,
AppstoreOutlined,
ToolOutlined,
InboxOutlined,
} from '@ant-design/icons'
import { useLocation, useNavigate } from 'react-router-dom'
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
import { useAuthStore } from '../../stores/auth'
import packageJson from '../../../package.json'
@@ -27,19 +33,64 @@ function AppLayout({ children }: AppLayoutProps) {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false)
const [openKeys, setOpenKeys] = useState<string[]>(['collection'])
const showBanner = true
const appVersion = `v${packageJson.version}`
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/datasources', icon: <DatabaseOutlined />, label: '数据源' },
{ key: '/data', icon: <BarChartOutlined />, label: '采集数据' },
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP观测' },
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
const menuItems: ItemType<MenuItemType>[] = [
{
key: 'overview',
icon: <DashboardOutlined />,
label: '总览',
children: [
{ key: '/admin', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/earth', icon: <GlobalOutlined />, label: 'Earth' },
],
},
{
key: 'collection',
icon: <InboxOutlined />,
label: '采集与数据',
children: [
{ key: '/datasources', icon: <DatabaseOutlined />, label: '数据源' },
{ key: '/data', icon: <BarChartOutlined />, label: '采集数据' },
],
},
{
key: 'observability',
icon: <AppstoreOutlined />,
label: '专题观测',
children: [
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP观测' },
],
},
{
key: 'alerts',
icon: <AlertOutlined />,
label: '告警与研判',
children: [
{ key: '/alerts/system', icon: <AlertOutlined />, label: '系统告警' },
{ key: '/alerts/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP 告警' },
{ key: '/alerts/situational', icon: <GlobalOutlined />, label: '态势告警' },
],
},
{
key: 'ops',
icon: <ToolOutlined />,
label: '运维与配置',
children: [
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
],
},
]
const selectedKey = useMemo(() => {
if (location.pathname === '/') return '/earth'
return location.pathname
}, [location.pathname])
return (
<Layout className="dashboard-layout">
<Sider
@@ -47,7 +98,12 @@ function AppLayout({ children }: AppLayoutProps) {
collapsedWidth={72}
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
onCollapse={(nextCollapsed) => {
setCollapsed(nextCollapsed)
if (nextCollapsed) {
setOpenKeys([])
}
}}
className="dashboard-sider"
>
<div className="dashboard-sider-inner">
@@ -69,10 +125,14 @@ function AppLayout({ children }: AppLayoutProps) {
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
selectedKeys={[selectedKey]}
openKeys={collapsed ? [] : openKeys}
items={menuItems}
onOpenChange={(keys) => {
setOpenKeys(keys as string[])
}}
onClick={({ key }) => {
if (key !== location.pathname) {
if (key !== selectedKey) {
navigate(key)
}
}}

View File

@@ -190,6 +190,11 @@ body {
min-height: 0;
}
.playground-page {
height: 100%;
min-height: 0;
}
.playground-page__grid {
flex: 1 1 auto;
min-height: 0;
@@ -198,11 +203,15 @@ body {
}
.playground-page__body {
flex: 1 1 auto;
min-height: 0;
display: flex;
overflow: hidden;
}
.playground-shell {
flex: 1 1 auto;
height: 100%;
min-height: 0;
display: flex;
gap: 12px;
@@ -303,6 +312,7 @@ body {
.playground-card--workspace {
flex: 1 1 auto;
height: 100%;
}
.playground-card--workspace .ant-card-body,
@@ -310,6 +320,405 @@ body {
overflow: hidden;
}
.playground-chat {
flex: 1 1 auto;
min-height: 0;
}
.playground-chat .ant-card-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px 18px 18px;
min-height: 0;
overflow: hidden;
}
.playground-chat__messages {
flex: 1 1 auto;
min-height: 0;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
overscroll-behavior: contain;
}
.playground-chat__messages-shell {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.playground-chat__scroll-bottom.ant-btn {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 12px;
width: 38px;
height: 38px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(255, 255, 255, 0.94);
color: #0f172a;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(8px);
}
.playground-chat__scroll-bottom.ant-btn:hover,
.playground-chat__scroll-bottom.ant-btn:focus {
background: #ffffff !important;
color: #0f766e !important;
border-color: rgba(15, 118, 110, 0.2) !important;
}
.playground-chat__composer {
flex: 0 0 auto;
border-top: 1px solid rgba(148, 163, 184, 0.18);
padding-top: 14px;
}
.playground-chat__input-wrap {
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98));
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
padding: 12px;
}
.playground-chat__input.ant-input {
border: 0;
box-shadow: none;
padding: 6px 2px 2px;
resize: none;
background: transparent;
margin-top: 10px;
}
.playground-chat__input.ant-input:focus,
.playground-chat__input.ant-input-focused {
box-shadow: none;
}
.playground-chat__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(148, 163, 184, 0.16);
}
.playground-chat__hints {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.playground-chat__send-button.ant-btn {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #0f172a;
color: #f8fafc;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16);
}
.playground-chat__send-button.ant-btn:hover,
.playground-chat__send-button.ant-btn:focus {
background: #111827 !important;
color: #ffffff !important;
border-color: transparent !important;
}
.playground-chat__send-button--stop.ant-btn {
background: #7f1d1d;
}
.playground-chat__send-button--stop.ant-btn:hover,
.playground-chat__send-button--stop.ant-btn:focus {
background: #991b1b !important;
}
.playground-message {
display: flex;
align-items: flex-end;
gap: 10px;
}
.playground-message--system,
.playground-message--assistant {
justify-content: flex-start;
}
.playground-message--user {
flex-direction: row-reverse;
}
.playground-message__avatar {
flex: 0 0 auto;
}
.playground-message__body {
display: flex;
flex-direction: column;
min-width: 0;
max-width: min(100%, 760px);
}
.playground-message__body--editing {
width: min(100%, 760px);
flex: 0 1 min(100%, 760px);
}
.playground-message__avatar-inner {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14);
}
.playground-message__avatar-inner--assistant,
.playground-message__avatar-inner--system {
background: linear-gradient(135deg, #0f766e, #14b8a6) !important;
color: #f8fafc !important;
}
.playground-message__avatar-inner--user {
background: linear-gradient(135deg, #0f172a, #334155) !important;
color: #f8fafc !important;
}
.playground-message__bubble {
width: 100%;
border-radius: 18px;
padding: 14px 16px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(148, 163, 184, 0.18);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98));
}
.playground-message--assistant .playground-message__bubble {
background:
linear-gradient(180deg, rgba(232, 244, 255, 0.95), rgba(240, 249, 255, 0.98));
border-color: rgba(56, 189, 248, 0.26);
}
.playground-message--system .playground-message__bubble {
background:
linear-gradient(180deg, rgba(255, 250, 235, 0.98), rgba(254, 249, 195, 0.72));
border-color: rgba(245, 158, 11, 0.24);
}
.playground-message--user .playground-message__bubble {
background:
linear-gradient(180deg, rgba(15, 118, 110, 0.94), rgba(13, 148, 136, 0.96));
border-color: rgba(15, 118, 110, 0.28);
color: #f8fafc;
}
.playground-message__bubble--loading {
display: inline-flex;
align-items: center;
gap: 10px;
}
.playground-message__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.playground-message__head .ant-tag {
margin-inline-end: 0;
}
.playground-message__phase {
margin-bottom: 8px;
}
.playground-message__phase .ant-typography {
font-size: 12px;
color: #64748b;
}
.playground-message__thinking {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px dashed rgba(148, 163, 184, 0.35);
background: rgba(255, 255, 255, 0.45);
}
.playground-message__thinking .ant-typography {
display: block;
margin-bottom: 6px;
font-size: 12px;
}
.playground-message--user .playground-message__head .ant-typography,
.playground-message--user .playground-message__content {
color: #f8fafc;
}
.playground-message__content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 1.65;
}
.playground-message__editor {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.playground-message__editor-input,
.playground-message__editor-input.ant-input,
.playground-message__editor-input.ant-input-affix-wrapper,
.playground-message__editor .ant-input-textarea,
.playground-message__editor .ant-input-textarea-show-count {
display: block;
width: 100%;
min-width: 0;
}
.playground-message__editor-input.ant-input,
.playground-message__editor .ant-input-textarea textarea.ant-input {
border-radius: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.94);
color: #0f172a;
width: 100% !important;
min-width: 0;
}
.playground-message__editor-actions {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.playground-message__editor-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
justify-content: flex-start;
flex: 1 1 auto;
}
.playground-message__editor-meta .ant-tag {
margin-inline-end: 0;
}
.playground-message__editor-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
margin-left: auto;
}
.playground-message__editor-buttons .ant-btn {
border-radius: 999px;
padding-inline: 12px;
font-size: 12px;
line-height: 1;
min-width: 56px;
}
.playground-message__editor-buttons .ant-btn-default {
border-color: rgba(148, 163, 184, 0.3);
color: #475569;
background: rgba(255, 255, 255, 0.82);
}
.playground-message__editor-buttons .ant-btn-default:hover,
.playground-message__editor-buttons .ant-btn-default:focus {
color: #0f172a !important;
border-color: rgba(100, 116, 139, 0.34) !important;
background: rgba(255, 255, 255, 0.92) !important;
}
.playground-message__editor-buttons .ant-btn-primary {
border-color: transparent;
background: linear-gradient(135deg, #0f172a, #334155);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
}
.playground-message__editor-buttons .ant-btn-primary:hover,
.playground-message__editor-buttons .ant-btn-primary:focus {
background: linear-gradient(135deg, #020617, #1e293b) !important;
border-color: transparent !important;
}
.playground-message__markdown {
font-size: 13px;
line-height: 1.7;
}
.playground-message__markdown .markdown-renderer {
color: inherit;
}
.playground-message__markdown .markdown-renderer > :last-child {
margin-bottom: 0;
}
.playground-message__markdown .markdown-renderer code {
background: rgba(15, 23, 42, 0.08);
}
.playground-message--assistant .playground-message__markdown .markdown-renderer blockquote {
background: rgba(255, 255, 255, 0.42);
}
.playground-message__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.playground-message__detail-action {
margin-top: 8px;
}
.playground-message__detail-action .ant-btn {
padding-inline: 0;
}
.playground-message__actions {
display: flex;
align-items: center;
gap: 2px;
margin-top: 6px;
padding-inline: 6px;
}
.playground-message__actions .ant-btn {
color: #64748b;
}
.playground-message__actions .ant-btn:hover {
color: #0f172a !important;
background: rgba(148, 163, 184, 0.12) !important;
}
.playground-tabs,
.playground-tabs .ant-tabs-content-holder,
.playground-tabs .ant-tabs-content,
@@ -397,6 +806,30 @@ body {
gap: 8px;
}
.playground-preset-strip--chat {
gap: 8px;
margin-bottom: 14px;
}
.playground-preset-strip__actions .ant-tag-checkable {
margin-inline-end: 0;
margin-bottom: 4px;
border-radius: 999px;
padding: 4px 12px;
}
.playground-preset-strip__actions .ant-tag-checkable-checked {
background: linear-gradient(135deg, #0f766e, #14b8a6);
color: #f8fafc;
border-color: transparent;
}
.playground-preset-strip__actions .ant-tag-checkable:not(.ant-tag-checkable-checked) {
background: rgba(15, 118, 110, 0.08);
border-color: rgba(15, 118, 110, 0.16);
color: #0f766e;
}
.playground-form__actions {
display: flex;
gap: 12px;
@@ -534,6 +967,11 @@ body {
gap: 12px;
}
.playground-service-modal__body {
display: grid;
gap: 12px;
}
.playground-note.ant-alert {
padding: 10px 12px;
}
@@ -621,6 +1059,21 @@ body {
gap: 12px;
}
.playground-chat__messages::-webkit-scrollbar {
width: 8px;
}
.playground-chat__messages::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.playground-chat__messages::-webkit-scrollbar-track {
background: transparent;
}
.playground-result__blocks-head {
display: flex;
align-items: center;
@@ -638,6 +1091,52 @@ body {
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-result__blocks-scroll--raw {
max-height: 240px;
}
.playground-result-modal .ant-modal-content {
overflow: hidden;
}
.playground-result-modal__body {
display: flex;
flex-direction: column;
gap: 14px;
height: min(76vh, 760px);
min-height: 0;
}
.playground-result-modal__head {
flex: 0 0 auto;
display: grid;
gap: 12px;
}
.playground-result-modal__content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding-right: 6px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-result-modal__content::-webkit-scrollbar {
width: 8px;
}
.playground-result-modal__content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.playground-result-modal__content::-webkit-scrollbar-track {
background: transparent;
}
@media (max-width: 1200px) {
.playground-page__header {
align-items: flex-start;
@@ -652,9 +1151,7 @@ body {
}
.playground-shell__sidebar {
flex: 0 0 auto;
min-width: 0;
max-width: none;
display: none;
}
.playground-result__meta,
@@ -662,6 +1159,23 @@ body {
align-items: stretch;
flex-direction: column;
}
.playground-chat__actions {
align-items: center;
flex-direction: row;
justify-content: space-between;
gap: 10px;
}
.playground-chat__hints {
flex: 1 1 auto;
min-width: 0;
}
.playground-chat__send-button.ant-btn {
margin-left: auto;
flex: 0 0 auto;
}
}
@@ -1072,7 +1586,71 @@ body {
line-height: 1.5;
}
.alerts-page__body {
overflow: hidden;
}
.alerts-page__tabs,
.alerts-page__tabs .ant-tabs-content-holder,
.alerts-page__tabs .ant-tabs-content,
.alerts-page__tabs .ant-tabs-tabpane {
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
.alerts-page__tabs {
display: flex;
flex-direction: column;
}
.alerts-page__tabs .ant-tabs-nav {
flex: 0 0 auto;
margin-bottom: 8px;
}
.alerts-page__tabs .ant-tabs-content-holder {
flex: 1 1 auto;
overflow: hidden;
}
.alerts-page__tabs .ant-tabs-tabpane {
display: flex;
flex-direction: column;
overflow: hidden;
}
.alerts-tab-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.alerts-tab-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.alerts-tab-panel__subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 960px) {
.alerts-tab-panel__head {
flex-direction: column;
align-items: stretch;
}
.bgp-page__brief-head {
flex-direction: column;
}
@@ -1100,6 +1678,22 @@ body {
margin-top: 14px;
}
.bgp-page__brief-facts {
display: grid;
gap: 8px;
}
.bgp-page__brief-fact-list {
display: grid;
gap: 8px;
}
.bgp-page__brief-fact-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.bgp-page__brief-meta .ant-descriptions-view {
background: #f7f8fa;
border-radius: 12px;
@@ -1107,6 +1701,8 @@ body {
}
.bgp-page__brief-modal-body {
display: grid;
gap: 14px;
max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 6px;
@@ -1114,6 +1710,89 @@ body {
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.bgp-page__brief-evidence {
display: grid;
gap: 12px;
}
.bgp-page__brief-evidence-card {
border-radius: 12px;
}
.alerts-brief-drawer {
display: flex;
flex-direction: column;
gap: 12px;
}
.alerts-brief-drawer__loading {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
.alerts-brief-fact {
display: flex;
align-items: flex-start;
gap: 8px;
}
.alerts-brief-content {
white-space: pre-wrap;
margin-bottom: 0 !important;
}
.system-alerts-page__body,
.bgp-alerts-page__body,
.situational-alerts-page__body {
overflow: hidden;
}
.system-alerts-page__stack,
.bgp-alerts-page__stack,
.situational-alerts-page__stack {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.system-alerts-page__table-card,
.bgp-alerts-page__table-card {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.system-alerts-page__table-card .ant-card-body,
.bgp-alerts-page__table-card .ant-card-body {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.system-alerts-page__table-region,
.bgp-alerts-page__table-region {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.bgp-alerts-page__tabs,
.bgp-alerts-page__tabs .ant-tabs-content-holder,
.bgp-alerts-page__tabs .ant-tabs-content,
.bgp-alerts-page__tabs .ant-tabs-tabpane {
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
.bgp-page__brief-modal {
max-width: min(920px, calc(100vw - 32px));
}

View File

@@ -1,221 +1,66 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd'
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useMemo } from 'react'
interface Alert {
id: number
severity: 'critical' | 'warning' | 'info'
status: 'active' | 'acknowledged' | 'resolved'
datasource_name: string
message: string
created_at: string
acknowledged_at?: string
resolved_at?: string
}
import { AlertOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons'
import { Tabs, Typography } from 'antd'
import { useSearchParams } from 'react-router-dom'
import AppLayout from '../../components/AppLayout/AppLayout'
import { BGPAlertsPanel } from './BGPAlerts'
import { SituationalAlertsPanel } from './SituationalAlerts'
import { SystemAlertsPanel } from './SystemAlerts'
const { Title, Text } = Typography
const ALERT_TABS = [
{
key: 'system',
label: '系统告警',
icon: <AlertOutlined />,
children: <SystemAlertsPanel />,
},
{
key: 'bgp',
label: 'BGP 告警',
icon: <DeploymentUnitOutlined />,
children: <BGPAlertsPanel />,
},
{
key: 'situational',
label: '态势告警',
icon: <RadarChartOutlined />,
children: <SituationalAlertsPanel />,
},
]
function Alerts() {
const { token } = useAuthStore()
const [alerts, setAlerts] = useState<Alert[]>([])
const [loading, setLoading] = useState(false)
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null)
const [detailVisible, setDetailVisible] = useState(false)
const fetchAlerts = async () => {
setLoading(true)
try {
const res = await fetch('/api/v1/alerts', {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
setAlerts(data.data || [])
} catch (error) {
console.error('Failed to fetch alerts:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAlerts()
}, [token])
const handleAcknowledge = async (alertId: number) => {
try {
await fetch(`/api/v1/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
fetchAlerts()
} catch (error) {
console.error('Failed to acknowledge alert:', error)
}
}
const handleResolve = async (alertId: number) => {
try {
await fetch(`/api/v1/alerts/${alertId}/resolve`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ resolution: '已处理' }),
})
fetchAlerts()
} catch (error) {
console.error('Failed to resolve alert:', error)
}
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{
title: '级别',
dataIndex: 'severity',
key: 'severity',
render: (s: string) => {
const colors: Record<string, string> = { critical: 'error', warning: 'warning', info: 'blue' }
const icons: Record<string, JSX.Element> = {
critical: <AlertOutlined />,
warning: <AlertOutlined />,
info: <InfoCircleOutlined />,
}
return (
<Tag color={colors[s]} icon={icons[s]}>
{s === 'critical' ? '严重' : s === 'warning' ? '警告' : '信息'}
</Tag>
)
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (s: string) => {
const colors: Record<string, string> = { active: 'red', acknowledged: 'orange', resolved: 'green' }
return (
<Tag color={colors[s]}>
{s === 'active' ? '待处理' : s === 'acknowledged' ? '已确认' : '已解决'}
</Tag>
)
},
},
{ title: '数据源', dataIndex: 'datasource_name', key: 'datasource_name' },
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true },
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
render: (t: string) => formatDateTimeZhCN(t),
},
{
title: '操作',
key: 'action',
render: (_: unknown, record: Alert) => (
<Space>
{record.status === 'active' && (
<Button type="link" size="small" onClick={() => handleAcknowledge(record.id)}>
</Button>
)}
{record.status !== 'resolved' && (
<Button type="link" size="small" onClick={() => handleResolve(record.id)}>
</Button>
)}
<Button type="link" size="small" onClick={() => { setSelectedAlert(record); setDetailVisible(true); }}>
</Button>
</Space>
),
},
]
const stats = alerts.reduce(
(acc, alert) => {
if (alert.status === 'active') {
acc[alert.severity]++
}
return acc
},
{ critical: 0, warning: 0, info: 0 } as Record<string, number>
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab') || 'system'
const activeTab = useMemo(
() => (ALERT_TABS.some((item) => item.key === requestedTab) ? requestedTab : 'system'),
[requestedTab],
)
return (
<AppLayout>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic
title="严重告警"
value={stats.critical}
valueStyle={{ color: '#ff4d4f' }}
prefix={<AlertOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="警告"
value={stats.warning}
valueStyle={{ color: '#faad14' }}
prefix={<AlertOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} />
</Card>
</Col>
</Row>
<Card
title="告警列表"
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}></Button>}
>
<div className="table-scroll-region">
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
<div className="page-shell alerts-page">
<div className="page-shell__header alerts-page__header">
<div>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary">
BGP AI Tab
</Text>
</div>
</div>
</Card>
<Modal
title="告警详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={null}
width={600}
>
{selectedAlert && (
<Descriptions column={1} bordered>
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
<Descriptions.Item label="级别">
<Tag color={selectedAlert.severity === 'critical' ? 'error' : selectedAlert.severity === 'warning' ? 'warning' : 'blue'}>
{selectedAlert.severity}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedAlert.status === 'active' ? 'red' : selectedAlert.status === 'acknowledged' ? 'orange' : 'green'}>
{selectedAlert.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
{selectedAlert.acknowledged_at && (
<Descriptions.Item label="确认时间">
{formatDateTimeZhCN(selectedAlert.acknowledged_at)}
</Descriptions.Item>
)}
{selectedAlert.resolved_at && (
<Descriptions.Item label="解决时间">
{formatDateTimeZhCN(selectedAlert.resolved_at)}
</Descriptions.Item>
)}
</Descriptions>
)}
</Modal>
<div className="page-shell__body alerts-page__body">
<Tabs
className="alerts-page__tabs"
activeKey={activeTab}
onChange={(key) => setSearchParams({ tab: key })}
items={ALERT_TABS}
/>
</div>
</div>
</AppLayout>
)
}

View File

@@ -0,0 +1,264 @@
import { useEffect, useMemo, useState } from 'react'
import { ReloadOutlined, RobotOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Modal,
Row,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
type TableColumnsType,
} from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout'
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Text } = Typography
const gateway = getSituationalAwarenessGateway()
function severityColor(severity: string) {
if (severity === 'critical') return 'red'
if (severity === 'high') return 'orange'
if (severity === 'medium') return 'gold'
return 'blue'
}
export function BGPAlertsPanel() {
const [messageApi, contextHolder] = message.useMessage()
const [loading, setLoading] = useState(false)
const [incidents, setIncidents] = useState<BGPIncident[]>([])
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
const [briefLoading, setBriefLoading] = useState(false)
const [briefModalOpen, setBriefModalOpen] = useState(false)
const [brief, setBrief] = useState<BGPBriefRecord | null>(null)
const loadData = async () => {
setLoading(true)
try {
const [incidentRows, anomalyRows] = await Promise.all([
gateway.getBGPIncidents(50),
gateway.getBGPAnomalies(80),
])
setIncidents(incidentRows)
setAnomalies(anomalyRows)
} catch (error) {
console.error('Failed to load BGP alerts:', error)
messageApi.error('BGP 告警加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadData()
}, [])
const summary = useMemo(
() => ({
activeIncidents: incidents.filter((item) => item.status === 'active').length,
criticalIncidents: incidents.filter((item) => item.severity === 'critical').length,
activeAnomalies: anomalies.filter((item) => item.status === 'active').length,
highRiskAnomalies: anomalies.filter((item) => ['critical', 'high'].includes(item.severity)).length,
}),
[anomalies, incidents],
)
const handleGenerateBrief = async () => {
setBriefModalOpen(true)
setBriefLoading(true)
try {
const record = await gateway.generateBGPBrief()
setBrief(record)
messageApi.success('BGP AI 简报已生成')
} catch (error) {
console.error('Failed to generate BGP brief:', error)
messageApi.error('BGP AI 简报生成失败')
} finally {
setBriefLoading(false)
}
}
const incidentColumns: TableColumnsType<BGPIncident> = [
{
title: '开始时间',
dataIndex: 'started_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{ title: '类型', dataIndex: 'incident_type', width: 180 },
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
},
{
title: '影响前缀',
dataIndex: 'affected_prefixes',
width: 220,
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
},
{ title: '摘要', dataIndex: 'summary', width: 320 },
]
const anomalyColumns: TableColumnsType<BGPAnomaly> = [
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{ title: '类型', dataIndex: 'anomaly_type', width: 180 },
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
},
{
title: '前缀',
dataIndex: 'prefix',
width: 200,
render: (value: string | null) => value || '-',
},
{ title: '摘要', dataIndex: 'summary', width: 320 },
]
return (
<AppLayout>
<div className="alerts-tab-panel bgp-alerts-page">
{contextHolder}
<Space className="bgp-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
<div className="alerts-tab-panel__head">
<div>
<Text strong>BGP </Text>
<div className="alerts-tab-panel__subtitle"> BGP incidents anomalies </div>
</div>
<Space>
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
BGP AI
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>
</Button>
</Space>
</div>
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
<Row gutter={[12, 12]}>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
</Col>
</Row>
<Card className="bgp-alerts-page__table-card">
<Tabs
className="bgp-alerts-page__tabs"
items={[
{
key: 'incidents',
label: 'BGP 事件',
children: (
<div className="table-scroll-region bgp-alerts-page__table-region">
<Table<BGPIncident>
columns={incidentColumns}
dataSource={incidents}
loading={loading}
pagination={false}
rowKey="id"
scroll={{ x: 1200, y: 480 }}
tableLayout="fixed"
/>
</div>
),
},
{
key: 'anomalies',
label: 'BGP 异常',
children: (
<div className="table-scroll-region bgp-alerts-page__table-region">
<Table<BGPAnomaly>
columns={anomalyColumns}
dataSource={anomalies}
loading={loading}
pagination={false}
rowKey="id"
scroll={{ x: 1100, y: 480 }}
tableLayout="fixed"
/>
</div>
),
},
]}
/>
</Card>
</Space>
<Modal
title="BGP AI 简报"
open={briefModalOpen}
onCancel={() => setBriefModalOpen(false)}
footer={null}
width={920}
className="bgp-page__brief-modal"
style={{ top: 24 }}
styles={{ body: { paddingTop: 12 } }}
>
{briefLoading ? (
<div className="bgp-page__brief-loading">
<Spin tip="正在生成 BGP AI 简报..." />
</div>
) : brief ? (
<div className="bgp-page__brief-modal-body">
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
</Descriptions>
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
</div>
) : (
<Text type="secondary"> BGP </Text>
)}
</Modal>
</div>
</AppLayout>
)
}
export default BGPAlertsPanel

View File

@@ -0,0 +1,202 @@
import { useEffect, useMemo, useState } from 'react'
import { DeploymentUnitOutlined, ReloadOutlined, RobotOutlined, WarningOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Drawer,
Row,
Space,
Spin,
Statistic,
Typography,
message,
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
import {
getSituationalAwarenessGateway,
type SituationalAlertBriefResponse,
} from '../../services/situational-awareness'
const { Text } = Typography
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
const gateway = getSituationalAwarenessGateway()
interface AlertStatsResponse {
critical: number
warning: number
info: number
}
export function SituationalAlertsPanel() {
const [messageApi, contextHolder] = message.useMessage()
const [systemStats, setSystemStats] = useState<AlertStatsResponse | null>(null)
const [bgpSummary, setBgpSummary] = useState<BGPSummarySnapshot | null>(null)
const [loading, setLoading] = useState(false)
const [briefOpen, setBriefOpen] = useState(false)
const [briefLoading, setBriefLoading] = useState(false)
const [briefError, setBriefError] = useState<string | null>(null)
const [briefResult, setBriefResult] = useState<SituationalAlertBriefResponse | null>(null)
const loadOverview = async () => {
setLoading(true)
try {
const [alertStatsResponse, bgpSummaryResponse] = await Promise.all([
axios.get<AlertStatsResponse>(`${API_BASE_URL}/alerts/stats`),
gateway.getBGPSummary(),
])
setSystemStats(alertStatsResponse.data)
setBgpSummary(bgpSummaryResponse)
} catch (error) {
console.error('Failed to load situational alerts overview:', error)
messageApi.error('态势告警概览加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadOverview()
}, [])
const summary = useMemo(
() => ({
activeSystemAlerts: (systemStats?.critical || 0) + (systemStats?.warning || 0) + (systemStats?.info || 0),
criticalSystemAlerts: systemStats?.critical || 0,
activeBGPIncidents: bgpSummary?.incidentSummary?.by_status?.active || 0,
criticalBGPIncidents: bgpSummary?.incidentSummary?.by_severity?.critical || 0,
}),
[bgpSummary, systemStats],
)
const handleGenerateBrief = async () => {
setBriefOpen(true)
setBriefLoading(true)
setBriefError(null)
try {
const response = await axios.post<SituationalAlertBriefResponse>(`${API_BASE_URL}/ai/situational-alerts/brief`, {})
setBriefResult(response.data)
} catch (error) {
console.error('Failed to generate situational alert brief:', error)
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setBriefError(detail || '生成态势告警 AI 简报失败')
messageApi.error('生成态势告警 AI 简报失败')
} finally {
setBriefLoading(false)
}
}
return (
<AppLayout>
<div className="alerts-tab-panel situational-alerts-page">
{contextHolder}
<Space className="situational-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
<div className="alerts-tab-panel__head">
<div>
<Text strong></Text>
<div className="alerts-tab-panel__subtitle"> BGP </div>
</div>
<Space>
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
AI
</Button>
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadOverview()}>
</Button>
</Space>
</div>
<Alert
type="info"
showIcon
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
/>
<Row gutter={[12, 12]}>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={24} lg={12}>
<Card title="系统告警侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="BGP 风险侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
</Space>
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
<div className="alerts-brief-drawer">
{briefLoading ? (
<div className="alerts-brief-drawer__loading">
<Spin tip="正在生成态势告警 AI 简报..." />
</div>
) : null}
{!briefLoading && briefError ? (
<Alert type="error" showIcon message="态势告警 AI 简报生成失败" description={briefError} />
) : null}
{!briefLoading && briefResult ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
<Descriptions.Item label="活跃系统告警">{String(briefResult.context.active_system_alerts ?? '-')}</Descriptions.Item>
<Descriptions.Item label="活跃 BGP 事件">{String(briefResult.context.active_bgp_incidents ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="事实输入">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{briefResult.facts.map((fact, index) => (
<div key={`${index}-${fact}`} className="alerts-brief-fact">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</Space>
</Card>
<Card size="small" title="AI 简报">
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
</Card>
</Space>
) : null}
</div>
</Drawer>
</div>
</AppLayout>
)
}
export default SituationalAlertsPanel

View File

@@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Col,
Descriptions,
Drawer,
Modal,
Row,
Space,
Spin,
Statistic,
Table,
Tag,
Typography,
message,
type TableColumnsType,
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Text } = Typography
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
function renderAlertSeverityTag(value: AlertRecord['severity']) {
const colorMap = { critical: 'error', warning: 'warning', info: 'blue' }
const labelMap = { critical: '严重', warning: '警告', info: '信息' }
const iconMap = {
critical: <AlertOutlined />,
warning: <AlertOutlined />,
info: <InfoCircleOutlined />,
}
return (
<Tag color={colorMap[value]} icon={iconMap[value]}>
{labelMap[value]}
</Tag>
)
}
function renderAlertStatusTag(value: AlertRecord['status']) {
const colorMap = { active: 'red', acknowledged: 'orange', resolved: 'green' }
const labelMap = { active: '待处理', acknowledged: '已确认', resolved: '已解决' }
return <Tag color={colorMap[value]}>{labelMap[value]}</Tag>
}
export function SystemAlertsPanel() {
const [messageApi, contextHolder] = message.useMessage()
const [alerts, setAlerts] = useState<AlertRecord[]>([])
const [loading, setLoading] = useState(false)
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null)
const [detailVisible, setDetailVisible] = useState(false)
const [briefOpen, setBriefOpen] = useState(false)
const [briefLoading, setBriefLoading] = useState(false)
const [briefError, setBriefError] = useState<string | null>(null)
const [briefResult, setBriefResult] = useState<AlertBriefResponse | null>(null)
const fetchAlerts = async () => {
setLoading(true)
try {
const response = await axios.get<{ data: AlertRecord[] }>(`${API_BASE_URL}/alerts`)
setAlerts(response.data.data || [])
} catch (error) {
console.error('Failed to fetch system alerts:', error)
messageApi.error('系统告警加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchAlerts()
}, [])
const stats = useMemo(
() =>
alerts.reduce(
(accumulator, item) => {
if (item.status === 'active') {
accumulator[item.severity] += 1
}
return accumulator
},
{ critical: 0, warning: 0, info: 0 } as Record<AlertRecord['severity'], number>,
),
[alerts],
)
const handleAcknowledge = async (alertId: number) => {
try {
await axios.post(`${API_BASE_URL}/alerts/${alertId}/acknowledge`)
messageApi.success('告警已确认')
await fetchAlerts()
} catch (error) {
console.error('Failed to acknowledge alert:', error)
messageApi.error('确认告警失败')
}
}
const handleResolve = async (alertId: number) => {
try {
await axios.post(`${API_BASE_URL}/alerts/${alertId}/resolve`, { resolution: '已处理' })
messageApi.success('告警已解决')
await fetchAlerts()
} catch (error) {
console.error('Failed to resolve alert:', error)
messageApi.error('解决告警失败')
}
}
const handleGenerateBrief = async () => {
setBriefOpen(true)
setBriefLoading(true)
setBriefError(null)
try {
const response = await axios.post<AlertBriefResponse>(`${API_BASE_URL}/ai/alerts/brief`, {})
setBriefResult(response.data)
} catch (error) {
console.error('Failed to generate system alert brief:', error)
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setBriefError(detail || '生成系统告警 AI 简报失败')
messageApi.error('生成系统告警 AI 简报失败')
} finally {
setBriefLoading(false)
}
}
const columns: TableColumnsType<AlertRecord> = [
{ title: 'ID', dataIndex: 'id', width: 72 },
{
title: '级别',
dataIndex: 'severity',
width: 108,
render: (value: AlertRecord['severity']) => renderAlertSeverityTag(value),
},
{
title: '状态',
dataIndex: 'status',
width: 108,
render: (value: AlertRecord['status']) => renderAlertStatusTag(value),
},
{
title: '数据源',
dataIndex: 'datasource_name',
width: 180,
render: (value: string | null) => value || '-',
},
{
title: '消息',
dataIndex: 'message',
ellipsis: true,
},
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (value: string) => formatDateTimeZhCN(value),
},
{
title: '操作',
key: 'action',
width: 180,
render: (_, record) => (
<Space size={4}>
{record.status === 'active' ? (
<Button type="link" size="small" onClick={() => void handleAcknowledge(record.id)}>
</Button>
) : null}
{record.status !== 'resolved' ? (
<Button type="link" size="small" onClick={() => void handleResolve(record.id)}>
</Button>
) : null}
<Button
type="link"
size="small"
onClick={() => {
setSelectedAlert(record)
setDetailVisible(true)
}}
>
</Button>
</Space>
),
},
]
return (
<AppLayout>
<div className="alerts-tab-panel system-alerts-page">
{contextHolder}
<Space className="system-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
<div className="alerts-tab-panel__head">
<div>
<Text strong></Text>
<div className="alerts-tab-panel__subtitle"></div>
</div>
<Space>
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
AI
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void fetchAlerts()}>
</Button>
</Space>
</div>
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
<Row gutter={[12, 12]}>
<Col xs={24} sm={8}>
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
</Col>
</Row>
<Card className="system-alerts-page__table-card" title="系统告警列表">
<div className="table-scroll-region system-alerts-page__table-region">
<Table<AlertRecord>
columns={columns}
dataSource={alerts}
loading={loading}
pagination={{ pageSize: 10 }}
rowKey="id"
scroll={{ x: 1100, y: 480 }}
tableLayout="fixed"
/>
</div>
</Card>
</Space>
<Modal title="告警详情" open={detailVisible} onCancel={() => setDetailVisible(false)} footer={null} width={640}>
{selectedAlert ? (
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
<Descriptions.Item label="级别">{renderAlertSeverityTag(selectedAlert.severity)}</Descriptions.Item>
<Descriptions.Item label="状态">{renderAlertStatusTag(selectedAlert.status)}</Descriptions.Item>
<Descriptions.Item label="数据源">{selectedAlert.datasource_name || '-'}</Descriptions.Item>
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
<Descriptions.Item label="确认时间">{formatDateTimeZhCN(selectedAlert.acknowledged_at || null)}</Descriptions.Item>
<Descriptions.Item label="解决时间">{formatDateTimeZhCN(selectedAlert.resolved_at || null)}</Descriptions.Item>
<Descriptions.Item label="处理说明">{selectedAlert.resolution_notes || '-'}</Descriptions.Item>
</Descriptions>
) : null}
</Modal>
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
<div className="alerts-brief-drawer">
{briefLoading ? (
<div className="alerts-brief-drawer__loading">
<Spin tip="正在汇总系统告警事实并生成简报..." />
</div>
) : null}
{!briefLoading && briefError ? (
<Alert type="error" showIcon message="系统告警 AI 简报生成失败" description={briefError} />
) : null}
{!briefLoading && briefResult ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card size="small">
<Descriptions size="small" column={1}>
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
<Descriptions.Item label="待处理告警">{String(briefResult.context.active_alerts ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="事实输入">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{briefResult.facts.map((fact, index) => (
<div key={`${index}-${fact}`} className="alerts-brief-fact">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</Space>
</Card>
<Card size="small" title="AI 简报">
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
</Card>
</Space>
) : null}
</div>
</Drawer>
</div>
</AppLayout>
)
}
export default SystemAlertsPanel

View File

@@ -66,6 +66,19 @@ function sortBriefRecords<T extends BGPBriefRecordSummary>(records: T[]) {
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
}
function renderBriefContextValue(value: unknown) {
if (value === null || value === undefined) return '-'
if (Array.isArray(value)) {
return value.length > 0 ? JSON.stringify(value) : '-'
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return '-'
return entries.map(([key, count]) => `${key}: ${String(count)}`).join('')
}
return String(value)
}
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
return [record.city, record.country].filter(Boolean).join(', ') || '-'
}
@@ -574,7 +587,23 @@ function BGP() {
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
{formatDateTimeZhCN(brief.generated_at)}
</Descriptions.Item>
<Descriptions.Item label="事实条目">{brief.facts.length}</Descriptions.Item>
<Descriptions.Item label="Incident 总数">{renderBriefContextValue(brief.context.incident_total)}</Descriptions.Item>
<Descriptions.Item label="活跃观测站">{renderBriefContextValue(brief.context.active_collectors)}</Descriptions.Item>
</Descriptions>
{brief.facts.length > 0 ? (
<div className="bgp-page__brief-facts">
<Text strong></Text>
<div className="bgp-page__brief-fact-list">
{brief.facts.slice(0, 3).map((fact, index) => (
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</div>
</div>
) : null}
</div>
) : (
<div className="bgp-page__brief-empty">
@@ -727,6 +756,34 @@ function BGP() {
>
{brief ? (
<div className="bgp-page__brief-modal-body">
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
<div className="bgp-page__brief-evidence">
{brief.facts.length > 0 ? (
<Card size="small" title="事实输入快照" className="bgp-page__brief-evidence-card">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{brief.facts.map((fact, index) => (
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
<Text strong>{index + 1}.</Text>
<Text>{fact}</Text>
</div>
))}
</Space>
</Card>
) : null}
{Object.keys(brief.context || {}).length > 0 ? (
<Card size="small" title="结构化上下文" className="bgp-page__brief-evidence-card">
<Descriptions size="small" column={1}>
{Object.entries(brief.context).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{renderBriefContextValue(value)}
</Descriptions.Item>
))}
</Descriptions>
</Card>
) : null}
</div>
) : null}
<MarkdownRenderer markdown={brief.content_markdown} />
</div>
) : (

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import type { SituationalAwarenessGateway } from './port'
import { HttpSituationalAwarenessGateway } from './http-gateway'
import { MockSituationalAwarenessGateway } from './mock-gateway'
export * from './types'
export type { SituationalAwarenessGateway } from './port'
@@ -8,10 +7,6 @@ export type { SituationalAwarenessGateway } from './port'
let singleton: SituationalAwarenessGateway | null = null
export function createSituationalAwarenessGateway(): SituationalAwarenessGateway {
const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http'
if (provider === 'mock') {
return new MockSituationalAwarenessGateway()
}
return new HttpSituationalAwarenessGateway()
}

View File

@@ -1,103 +0,0 @@
import type { SituationalAwarenessGateway } from './port'
import type {
BGPBriefRecord,
BGPBriefRecordSummary,
BGPOverviewOptions,
BGPOverviewSnapshot,
BGPSummarySnapshot,
} from './types'
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
incidents: [],
incidentSummary: {
total: 0,
by_type: {},
by_severity: {},
by_status: {},
},
anomalies: [],
events: [],
eventSummary: {
total: 0,
collector_count: 0,
prefix_count: 0,
by_type: {},
},
collectors: [],
collectorSummary: {
total: 0,
active_collectors: 0,
observed_prefixes: 0,
observed_origins: 0,
recent_24h_events: 0,
recent_7d_events: 0,
},
}
export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
private readonly brief: BGPBriefRecord = {
id: 'mock-bgp-brief-001',
title: 'BGP AI 简报',
provider: 'mock',
model: 'mock-brief',
request_id: 'mock-bgp-brief',
generated_at: '2026-04-09T12:00:00+08:00',
content_markdown: [
'# BGP 态势简报',
'',
'## 当前判断',
'',
'- 当前 BGP 态势以高严重度事件为主。',
'- 建议优先核查活跃 incidents 涉及的受影响前缀与重点观测站。',
'',
'## 值班建议',
'',
'1. 先确认高严重度 incident 是否持续活跃。',
'2. 对照重点 collector 的近 24h 波动,避免将控制平面噪声误判为真实业务中断。',
].join('\n'),
}
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
return EMPTY_SNAPSHOT
}
async getBGPSummary(): Promise<BGPSummarySnapshot> {
return {
incidentSummary: EMPTY_SNAPSHOT.incidentSummary,
eventSummary: EMPTY_SNAPSHOT.eventSummary,
collectorSummary: EMPTY_SNAPSHOT.collectorSummary,
}
}
async getBGPCollectors() {
return EMPTY_SNAPSHOT.collectors
}
async getBGPIncidents() {
return EMPTY_SNAPSHOT.incidents
}
async getBGPAnomalies() {
return EMPTY_SNAPSHOT.anomalies
}
async getBGPEvents() {
return EMPTY_SNAPSHOT.events
}
async generateBGPBrief(): Promise<BGPBriefRecord> {
return this.brief
}
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
return [this.brief]
}
async getBGPBrief(_briefId: string): Promise<BGPBriefRecord> {
return this.brief
}
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
return this.brief
}
}

View File

@@ -144,4 +144,47 @@ export interface BGPBriefRecordSummary {
export interface BGPBriefRecord extends BGPBriefRecordSummary {
content_markdown: string
facts: string[]
context: Record<string, unknown>
}
export interface AlertRecord {
id: number
severity: 'critical' | 'warning' | 'info'
status: 'active' | 'acknowledged' | 'resolved'
datasource_name: string | null
message: string
created_at: string
acknowledged_at?: string | null
resolved_at?: string | null
alert_metadata?: string | null
resolution_notes?: string | null
}
export interface AlertBriefResponse {
provider: string
model: string
content: string
content_blocks: AIContentBlock[]
text_blocks: string[]
thinking_blocks: string[]
raw_response: Record<string, unknown>
title: string
objective: string
facts: string[]
context: Record<string, unknown>
}
export interface SituationalAlertBriefResponse {
provider: string
model: string
content: string
content_blocks: AIContentBlock[]
text_blocks: string[]
thinking_blocks: string[]
raw_response: Record<string, unknown>
title: string
objective: string
facts: string[]
context: Record<string, unknown>
}