feat: ship persistent ai playground and alerts foundation
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal file
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal 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
|
||||
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal file
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal 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
|
||||
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal file
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal 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
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user