feat: add bgp observability and admin ui improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user