feat: add bgp observability and admin ui improvements
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.21.3",
|
||||
"version": "0.21.4-dev",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
|
||||
@@ -7,6 +7,7 @@ import DataSources from './pages/DataSources/DataSources'
|
||||
import DataList from './pages/DataList/DataList'
|
||||
import Earth from './pages/Earth/Earth'
|
||||
import Settings from './pages/Settings/Settings'
|
||||
import BGP from './pages/BGP/BGP'
|
||||
|
||||
function App() {
|
||||
const { token } = useAuthStore()
|
||||
@@ -24,6 +25,7 @@ function App() {
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/datasources" element={<DataSources />} />
|
||||
<Route path="/data" element={<DataList />} />
|
||||
<Route path="/bgp" element={<BGP />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
BarChartOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
} from '@ant-design/icons'
|
||||
@@ -31,6 +32,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data">采集数据</Link> },
|
||||
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: <Link to="/bgp">BGP观测</Link> },
|
||||
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: <Link to="/settings">系统配置</Link> },
|
||||
]
|
||||
|
||||
@@ -239,12 +239,71 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-source-builtin-tab {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-source-custom-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.data-source-bulk-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 247, 250, 0.96) 100%);
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.data-source-bulk-toolbar__meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-source-bulk-toolbar__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.data-source-bulk-toolbar__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-source-bulk-toolbar__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.data-source-bulk-toolbar__progress-copy {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #595959;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-source-bulk-toolbar__progress-copy strong {
|
||||
color: #1677ff;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.data-source-table-region {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptio
|
||||
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
interface Alert {
|
||||
id: number
|
||||
@@ -105,7 +106,7 @@ function Alerts() {
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => new Date(t).toLocaleString('zh-CN'),
|
||||
render: (t: string) => formatDateTimeZhCN(t),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -201,15 +202,15 @@ function Alerts() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{new Date(selectedAlert.created_at).toLocaleString('zh-CN')}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
|
||||
{selectedAlert.acknowledged_at && (
|
||||
<Descriptions.Item label="确认时间">
|
||||
{new Date(selectedAlert.acknowledged_at).toLocaleString('zh-CN')}
|
||||
{formatDateTimeZhCN(selectedAlert.acknowledged_at)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{selectedAlert.resolved_at && (
|
||||
<Descriptions.Item label="解决时间">
|
||||
{new Date(selectedAlert.resolved_at).toLocaleString('zh-CN')}
|
||||
{formatDateTimeZhCN(selectedAlert.resolved_at)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
159
frontend/src/pages/BGP/BGP.tsx
Normal file
159
frontend/src/pages/BGP/BGP.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Card, Col, Row, Space, Statistic, Table, Tag, Typography } from 'antd'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface BGPAnomaly {
|
||||
id: number
|
||||
source: string
|
||||
anomaly_type: string
|
||||
severity: string
|
||||
status: string
|
||||
prefix: string | null
|
||||
origin_asn: number | null
|
||||
new_origin_asn: number | null
|
||||
confidence: number
|
||||
summary: string
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total: number
|
||||
by_type: Record<string, number>
|
||||
by_severity: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
|
||||
function severityColor(severity: string) {
|
||||
if (severity === 'critical') return 'red'
|
||||
if (severity === 'high') return 'orange'
|
||||
if (severity === 'medium') return 'gold'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function BGP() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [anomaliesRes, summaryRes] = await Promise.all([
|
||||
axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }),
|
||||
axios.get('/api/v1/bgp/anomalies/summary'),
|
||||
])
|
||||
setAnomalies(anomaliesRes.data.data || [])
|
||||
setSummary(summaryRes.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>BGP观测</Title>
|
||||
<Text type="secondary">查看实时与回放阶段归一化出的路由异常。</Text>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="该视图展示的是控制平面异常,不代表真实业务流量路径。"
|
||||
/>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="异常总数" value={summary?.total || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="Critical" value={summary?.by_severity?.critical || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="Active" value={summary?.by_status?.active || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="异常列表">
|
||||
<Table<BGPAnomaly>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={anomalies}
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{
|
||||
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: 'prefix',
|
||||
width: 180,
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: 'ASN',
|
||||
key: 'asn',
|
||||
width: 160,
|
||||
render: (_, record) => {
|
||||
if (record.origin_asn && record.new_origin_asn) {
|
||||
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
|
||||
}
|
||||
if (record.origin_asn) {
|
||||
return `AS${record.origin_asn}`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
width: 120,
|
||||
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'summary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default BGP
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -187,7 +188,7 @@ function Dashboard() {
|
||||
|
||||
{stats?.last_updated && (
|
||||
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
|
||||
最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')}
|
||||
最后更新: {formatDateTimeZhCN(stats.last_updated)}
|
||||
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { useBreakpoint } = Grid
|
||||
@@ -18,6 +19,7 @@ const { useBreakpoint } = Grid
|
||||
interface CollectedData {
|
||||
id: number
|
||||
source: string
|
||||
source_name: string
|
||||
source_id: string
|
||||
data_type: string
|
||||
name: string
|
||||
@@ -42,7 +44,12 @@ interface CollectedData {
|
||||
interface Summary {
|
||||
total_records: number
|
||||
by_source: Record<string, Record<string, number>>
|
||||
source_totals: Array<{ source: string; count: number }>
|
||||
source_totals: Array<{ source: string; source_name: string; count: number }>
|
||||
}
|
||||
|
||||
interface SourceOption {
|
||||
source: string
|
||||
source_name: string
|
||||
}
|
||||
|
||||
const DETAIL_FIELD_LABELS: Record<string, string> = {
|
||||
@@ -111,12 +118,15 @@ function formatDetailValue(key: string, value: unknown) {
|
||||
}
|
||||
|
||||
if (key === 'collected_at' || key === 'reference_date') {
|
||||
const date = new Date(String(value))
|
||||
const date = parseBackendDate(String(value))
|
||||
if (!date) {
|
||||
return String(value)
|
||||
}
|
||||
return Number.isNaN(date.getTime())
|
||||
? String(value)
|
||||
: key === 'reference_date'
|
||||
? date.toLocaleDateString('zh-CN')
|
||||
: date.toLocaleString('zh-CN')
|
||||
? formatDateZhCN(String(value))
|
||||
: formatDateTimeZhCN(String(value))
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
@@ -130,6 +140,13 @@ function formatDetailValue(key: string, value: unknown) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getDetailFieldValue(detailData: CollectedData, key: string): unknown {
|
||||
if (key === 'source') {
|
||||
return detailData.source_name || detailData.source
|
||||
}
|
||||
return detailData[key as keyof CollectedData]
|
||||
}
|
||||
|
||||
function NameMarquee({ text }: { text: string }) {
|
||||
const containerRef = useRef<HTMLSpanElement | null>(null)
|
||||
const textRef = useRef<HTMLSpanElement | null>(null)
|
||||
@@ -249,7 +266,7 @@ function DataList() {
|
||||
const [sourceFilter, setSourceFilter] = useState<string[]>([])
|
||||
const [typeFilter, setTypeFilter] = useState<string[]>([])
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [sources, setSources] = useState<string[]>([])
|
||||
const [sources, setSources] = useState<SourceOption[]>([])
|
||||
const [types, setTypes] = useState<string[]>([])
|
||||
const [detailVisible, setDetailVisible] = useState(false)
|
||||
const [detailData, setDetailData] = useState<CollectedData | null>(null)
|
||||
@@ -420,10 +437,42 @@ function DataList() {
|
||||
huggingface_models: 'purple',
|
||||
huggingface_datasets: 'cyan',
|
||||
huggingface_spaces: 'magenta',
|
||||
peeringdb_ixp: 'gold',
|
||||
peeringdb_network: 'orange',
|
||||
peeringdb_facility: 'lime',
|
||||
telegeography_cables: 'green',
|
||||
telegeography_landing: 'green',
|
||||
telegeography_systems: 'emerald',
|
||||
arcgis_cables: 'blue',
|
||||
arcgis_landing_points: 'cyan',
|
||||
arcgis_cable_landing_relations: 'volcano',
|
||||
fao_landing_points: 'processing',
|
||||
epoch_ai_gpu: 'volcano',
|
||||
ris_live_bgp: 'red',
|
||||
bgpstream_bgp: 'purple',
|
||||
cloudflare_radar_device: 'magenta',
|
||||
cloudflare_radar_traffic: 'orange',
|
||||
cloudflare_radar_top_as: 'gold',
|
||||
}
|
||||
return colorMap[source] || 'blue'
|
||||
|
||||
if (colorMap[source]) {
|
||||
return colorMap[source]
|
||||
}
|
||||
|
||||
const fallbackPalette = [
|
||||
'blue',
|
||||
'geekblue',
|
||||
'cyan',
|
||||
'green',
|
||||
'lime',
|
||||
'gold',
|
||||
'orange',
|
||||
'volcano',
|
||||
'magenta',
|
||||
'purple',
|
||||
]
|
||||
const hash = Array.from(source).reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return fallbackPalette[hash % fallbackPalette.length]
|
||||
}
|
||||
|
||||
const getDataTypeTagColor = (dataType: string) => {
|
||||
@@ -486,7 +535,7 @@ function DataList() {
|
||||
for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
|
||||
items.push({
|
||||
key: item.source,
|
||||
label: item.source,
|
||||
label: item.source_name,
|
||||
value: item.count,
|
||||
icon: getSourceIcon(item.source),
|
||||
})
|
||||
@@ -564,7 +613,7 @@ function DataList() {
|
||||
return DETAIL_BASE_FIELDS.map((key) => ({
|
||||
key,
|
||||
label: formatFieldLabel(key),
|
||||
value: formatDetailValue(key, detailData[key as keyof CollectedData]),
|
||||
value: formatDetailValue(key, getDetailFieldValue(detailData, key)),
|
||||
})).filter((item) => item.value !== '-')
|
||||
}, [detailData])
|
||||
|
||||
@@ -605,11 +654,11 @@ function DataList() {
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
minWidth: 140,
|
||||
render: (value: string) => (
|
||||
value ? (
|
||||
render: (_: string, record: CollectedData) => (
|
||||
record.source ? (
|
||||
<div className="data-list-tag-cell">
|
||||
<Tag color={getSourceTagColor(value)} style={{ marginInlineEnd: 0 }}>
|
||||
{value}
|
||||
<Tag color={getSourceTagColor(record.source)} style={{ marginInlineEnd: 0 }}>
|
||||
{record.source_name || record.source}
|
||||
</Tag>
|
||||
</div>
|
||||
) : '-'
|
||||
@@ -635,14 +684,14 @@ function DataList() {
|
||||
dataIndex: 'collected_at',
|
||||
key: 'collected_at',
|
||||
width: 180,
|
||||
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
||||
render: (time: string) => formatDateTimeZhCN(time),
|
||||
},
|
||||
{
|
||||
title: '参考日期',
|
||||
dataIndex: 'reference_date',
|
||||
key: 'reference_date',
|
||||
width: 120,
|
||||
render: (time: string | null) => (time ? new Date(time).toLocaleDateString('zh-CN') : '-'),
|
||||
render: (time: string | null) => formatDateZhCN(time),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -756,7 +805,7 @@ function DataList() {
|
||||
setSourceFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={sources.map((source) => ({ label: source, value: source }))}
|
||||
options={sources.map((source) => ({ label: source.source_name, value: source.source }))}
|
||||
tagRender={(tagProps) => renderFilterTag(tagProps, getSourceTagColor)}
|
||||
style={{ width: '100%' }}
|
||||
className="data-list-filter-select"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -220,14 +221,14 @@ function Settings() {
|
||||
dataIndex: 'last_run_at',
|
||||
key: 'last_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '下次执行',
|
||||
dataIndex: 'next_run_at',
|
||||
key: 'next_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
|
||||
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
interface Task {
|
||||
id: number
|
||||
@@ -93,7 +94,7 @@ function Tasks() {
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
key: 'started_at',
|
||||
render: (t: string) => t ? new Date(t).toLocaleString('zh-CN') : '-',
|
||||
render: (t: string) => formatDateTimeZhCN(t),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
47
frontend/src/utils/datetime.ts
Normal file
47
frontend/src/utils/datetime.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export function parseBackendDate(value: string | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
|
||||
let normalized = value.trim()
|
||||
if (!normalized) return null
|
||||
|
||||
if (normalized.includes(' ') && !normalized.includes('T')) {
|
||||
normalized = normalized.replace(' ', 'T')
|
||||
}
|
||||
|
||||
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/.test(normalized)
|
||||
if (!hasTimezone) {
|
||||
normalized = `${normalized}Z`
|
||||
}
|
||||
|
||||
const date = new Date(normalized)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function padNumber(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function formatDateTimeZhCN(value: string | null | undefined): string {
|
||||
const date = parseBackendDate(value)
|
||||
if (!date) return '-'
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = padNumber(date.getMonth() + 1)
|
||||
const day = padNumber(date.getDate())
|
||||
const hours = padNumber(date.getHours())
|
||||
const minutes = padNumber(date.getMinutes())
|
||||
const seconds = padNumber(date.getSeconds())
|
||||
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
export function formatDateZhCN(value: string | null | undefined): string {
|
||||
const date = parseBackendDate(value)
|
||||
if (!date) return '-'
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = padNumber(date.getMonth() + 1)
|
||||
const day = padNumber(date.getDate())
|
||||
|
||||
return `${year}/${month}/${day}`
|
||||
}
|
||||
Reference in New Issue
Block a user