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,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.21.3",
"version": "0.21.4-dev",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",

View File

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

View File

@@ -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> },
]

View File

@@ -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;

View File

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

View 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

View File

@@ -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>
)}

View File

@@ -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"

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>

View File

@@ -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: '状态',

View File

@@ -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),
},
]

View 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}`
}