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,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