feat: add bgp observability and admin ui improvements

This commit is contained in:
linkong
2026-03-27 14:27:07 +08:00
parent bf2c4a172d
commit b0058edf17
51 changed files with 2473 additions and 245 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Table, Tag, Space, message, Button, Form, Input, Select,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
Table, Tag, Space, message, Button, Form, Input, Select, Progress, Checkbox,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
} from 'antd'
import {
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
@@ -11,6 +11,8 @@ import {
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useWebSocket } from '../../hooks/useWebSocket'
interface BuiltInDataSource {
id: number
@@ -22,6 +24,10 @@ interface BuiltInDataSource {
is_active: boolean
collector_class: string
last_run: string | null
last_run_at?: string | null
last_status?: string | null
last_records_processed?: number | null
data_count?: number
is_running: boolean
task_id: number | null
progress: number | null
@@ -38,6 +44,22 @@ interface TaskTrackerState {
status?: string | null
records_processed?: number | null
total_records?: number | null
error_message?: string | null
}
interface WebSocketTaskMessage {
type: string
channel?: string
payload?: {
datasource_id?: number
task_id?: number | null
progress?: number | null
phase?: string | null
status?: string | null
records_processed?: number | null
total_records?: number | null
error_message?: string | null
}
}
interface CustomDataSource {
@@ -78,6 +100,8 @@ function DataSources() {
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
const [recordCount, setRecordCount] = useState<number>(0)
const [testing, setTesting] = useState(false)
const [triggerAllLoading, setTriggerAllLoading] = useState(false)
const [forceTriggerAll, setForceTriggerAll] = useState(false)
const [testResult, setTestResult] = useState<any>(null)
const builtinTableRegionRef = useRef<HTMLDivElement | null>(null)
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
@@ -85,7 +109,7 @@ function DataSources() {
const [customTableHeight, setCustomTableHeight] = useState(360)
const [form] = Form.useForm()
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [builtinRes, customRes] = await Promise.all([
@@ -99,13 +123,72 @@ function DataSources() {
} finally {
setLoading(false)
}
}
}, [])
const [taskProgress, setTaskProgress] = useState<Record<number, TaskTrackerState>>({})
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
const runningBuiltInCount = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return trackedTask?.is_running || source.is_running
}).length
const runningBuiltInSources = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return trackedTask?.is_running || source.is_running
})
const aggregateProgress = runningBuiltInSources.length > 0
? Math.round(
runningBuiltInSources.reduce((sum, source) => {
const trackedTask = taskProgress[source.id]
return sum + (trackedTask?.progress ?? source.progress ?? 0)
}, 0) / runningBuiltInSources.length
)
: 0
const handleTaskSocketMessage = useCallback((message: WebSocketTaskMessage) => {
if (message.type !== 'data_frame' || message.channel !== 'datasource_tasks' || !message.payload?.datasource_id) {
return
}
const payload = message.payload
const sourceId = payload.datasource_id
const nextState: TaskTrackerState = {
task_id: payload.task_id ?? null,
progress: payload.progress ?? 0,
is_running: payload.status === 'running',
phase: payload.phase ?? null,
status: payload.status ?? null,
records_processed: payload.records_processed ?? null,
total_records: payload.total_records ?? null,
error_message: payload.error_message ?? null,
}
setTaskProgress((prev) => {
const next = {
...prev,
[sourceId]: nextState,
}
if (!nextState.is_running && nextState.status !== 'running') {
delete next[sourceId]
}
return next
})
if (payload.status && payload.status !== 'running') {
void fetchData()
}
}, [fetchData])
const { connected: taskSocketConnected } = useWebSocket({
autoConnect: true,
autoSubscribe: ['datasource_tasks'],
onMessage: handleTaskSocketMessage,
})
useEffect(() => {
fetchData()
}, [])
}, [fetchData])
useEffect(() => {
const updateHeights = () => {
@@ -130,6 +213,8 @@ function DataSources() {
}, [activeTab, builtInSources.length, customSources.length])
useEffect(() => {
if (taskSocketConnected) return
const trackedSources = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return Boolean((trackedTask?.task_id ?? source.task_id) && (trackedTask?.is_running ?? source.is_running))
@@ -186,22 +271,28 @@ function DataSources() {
}, 2000)
return () => clearInterval(interval)
}, [builtInSources, taskProgress])
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
const handleTrigger = async (id: number) => {
try {
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
message.success('任务已触发')
setTaskProgress(prev => ({
...prev,
[id]: {
task_id: res.data.task_id ?? null,
progress: 0,
is_running: true,
phase: 'queued',
status: 'running',
},
}))
if (res.data.task_id) {
setTaskProgress(prev => ({
...prev,
[id]: {
task_id: res.data.task_id,
progress: 0,
is_running: true,
phase: 'queued',
status: 'running',
},
}))
} else {
window.setTimeout(() => {
fetchData()
}, 800)
}
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
@@ -209,6 +300,52 @@ function DataSources() {
}
}
const handleTriggerAll = async () => {
try {
setTriggerAllLoading(true)
const res = await axios.post('/api/v1/datasources/trigger-all', null, {
params: { force: forceTriggerAll },
})
const triggered = res.data.triggered || []
const skipped = res.data.skipped || []
const failed = res.data.failed || []
const skippedInWindow = skipped.filter((item: { reason?: string }) => item.reason === 'within_frequency_window')
const skippedOther = skipped.filter((item: { reason?: string }) => item.reason !== 'within_frequency_window')
if (triggered.length > 0) {
setTaskProgress((prev) => {
const next = { ...prev }
for (const item of triggered) {
if (!item.task_id) continue
next[item.id] = {
task_id: item.task_id,
progress: 0,
is_running: true,
phase: 'queued',
status: 'running',
}
}
return next
})
}
const summaryParts = [
`已触发 ${triggered.length}`,
skippedInWindow.length > 0 ? `周期内跳过 ${skippedInWindow.length}` : null,
skippedOther.length > 0 ? `其他跳过 ${skippedOther.length}` : null,
failed.length > 0 ? `失败 ${failed.length}` : null,
].filter(Boolean)
message.success(summaryParts.join(''))
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || '全触发失败')
} finally {
setTriggerAllLoading(false)
}
}
const handleToggle = async (id: number, current: boolean) => {
const endpoint = current ? 'disable' : 'enable'
try {
@@ -405,8 +542,15 @@ function DataSources() {
title: '最近采集',
dataIndex: 'last_run',
key: 'last_run',
width: 140,
render: (lastRun: string | null) => lastRun || '-',
width: 180,
render: (_: string | null, record: BuiltInDataSource) => {
const label = formatDateTimeZhCN(record.last_run_at || record.last_run)
if (!label || label === '-') return '-'
if ((record.data_count || 0) === 0 && record.last_status === 'success') {
return `${label} (0条)`
}
return label
},
},
{
title: '状态',
@@ -431,7 +575,6 @@ function DataSources() {
const phase = taskState?.phase || record.phase || 'queued'
return (
<Space size={6} wrap>
<Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
<Tag color="processing">
{phaseLabelMap[phase] || phase}
{pct > 0 ? ` ${Math.round(pct)}%` : ''}
@@ -439,7 +582,26 @@ function DataSources() {
</Space>
)
}
return <Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
const lastStatusColor =
record.last_status === 'success'
? 'success'
: record.last_status === 'failed'
? 'error'
: 'default'
return (
<Space size={6} wrap>
{record.last_status ? (
<Tag color={lastStatusColor}>
{record.last_status === 'success'
? '采集成功'
: record.last_status === 'failed'
? '采集失败'
: record.last_status}
</Tag>
) : null}
</Space>
)
},
},
{
@@ -453,6 +615,7 @@ function DataSources() {
type="link"
size="small"
icon={<SyncOutlined />}
disabled={!record.is_active}
onClick={() => handleTrigger(record.id)}
>
@@ -461,6 +624,8 @@ function DataSources() {
type="link"
size="small"
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
danger={record.is_active}
style={record.is_active ? undefined : { color: '#52c41a' }}
onClick={() => handleToggle(record.id, record.is_active)}
>
{record.is_active ? '禁用' : '启用'}
@@ -536,7 +701,47 @@ function DataSources() {
key: 'builtin',
label: '内置数据源',
children: (
<div className="page-shell__body">
<div className="page-shell__body data-source-builtin-tab">
<div className="data-source-bulk-toolbar">
<div className="data-source-bulk-toolbar__meta">
<div className="data-source-bulk-toolbar__title"></div>
<div className="data-source-bulk-toolbar__progress">
<div className="data-source-bulk-toolbar__progress-copy">
<span></span>
<strong>{aggregateProgress}%</strong>
</div>
<Progress
percent={aggregateProgress}
size="small"
status={runningBuiltInCount > 0 ? 'active' : 'normal'}
showInfo={false}
strokeColor="#1677ff"
/>
</div>
<div className="data-source-bulk-toolbar__stats">
<Tag color="blue"> {builtInSources.length}</Tag>
<Tag color="green"> {activeBuiltInCount}</Tag>
<Tag color="processing"> {runningBuiltInCount}</Tag>
</div>
</div>
<Space size={12} align="center">
<Checkbox
checked={forceTriggerAll}
onChange={(event) => setForceTriggerAll(event.target.checked)}
>
</Checkbox>
<Button
type="primary"
size="middle"
icon={<SyncOutlined />}
loading={triggerAllLoading}
onClick={handleTriggerAll}
>
</Button>
</Space>
</div>
<div ref={builtinTableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={builtinColumns}
@@ -854,80 +1059,87 @@ function DataSources() {
}
>
{viewingSource && (
<Form layout="vertical">
<Form.Item label="名称">
<Input value={viewingSource.name} disabled />
</Form.Item>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card size="small" bordered={false} style={{ background: '#fafafa' }}>
<Row gutter={[12, 12]}>
<Col span={24}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.name} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.module} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.priority} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.frequency} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={`${recordCount}`} disabled />
</Col>
<Col span={24}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.collector_class} disabled />
</Col>
</Row>
</Card>
<Form.Item label="数据量">
<Input value={`${recordCount}`} disabled />
</Form.Item>
<Form layout="vertical">
<Form.Item label="采集源 API 链接">
<Space.Compact style={{ width: '100%' }}>
<Input value={viewingSource.endpoint || '-'} readOnly />
<Tooltip title={viewingSource.endpoint ? '复制采集源 API 链接' : '当前没有可复制的采集源 API 链接'}>
<Button
disabled={!viewingSource.endpoint}
icon={<CopyOutlined />}
onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
<Form.Item label="采集器">
<Input value={viewingSource.collector_class} disabled />
</Form.Item>
<Form.Item label="模块">
<Input value={viewingSource.module} disabled />
</Form.Item>
<Form.Item label="优先级">
<Input value={viewingSource.priority} disabled />
</Form.Item>
<Form.Item label="频率">
<Input value={viewingSource.frequency} disabled />
</Form.Item>
<Form.Item label="采集源 API 链接">
<Space.Compact style={{ width: '100%' }}>
<Input value={viewingSource.endpoint || '-'} readOnly />
<Tooltip title={viewingSource.endpoint ? '复制采集源 API 链接' : '当前没有可复制的采集源 API 链接'}>
<Button
disabled={!viewingSource.endpoint}
icon={<CopyOutlined />}
onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
<Collapse
items={[
{
key: 'auth',
label: '认证配置',
children: (
<Form.Item label="认证方式">
<Input value={viewingSource.auth_type || 'none'} disabled />
</Form.Item>
),
},
{
key: 'headers',
label: '请求头',
children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
{JSON.stringify(viewingSource.headers, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
{
key: 'config',
label: '高级配置',
children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
{JSON.stringify(viewingSource.config, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
]}
/>
</Form>
<Collapse
items={[
{
key: 'auth',
label: '认证配置',
children: (
<Form.Item label="认证方式" style={{ marginBottom: 0 }}>
<Input value={viewingSource.auth_type || 'none'} disabled />
</Form.Item>
),
},
{
key: 'headers',
label: '请求头',
children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
{JSON.stringify(viewingSource.headers, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
{
key: 'config',
label: '高级配置',
children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
{JSON.stringify(viewingSource.config, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
]}
/>
</Form>
</Space>
)}
</Drawer>
</AppLayout>