diff --git a/VERSION b/VERSION index 18fb7fec..9e432c86 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.6 +0.22.7 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a2aed6dd..50e86f8a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,33 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.22.7 + +Released: 2026-04-01 + +### Highlights + +- Fixed datasource bulk-collection progress so the realtime progress bar now tracks a dedicated batch lifecycle instead of collapsing back to zero when completed tasks drop out of the live running queue. +- Stabilized frontend WebSocket behavior by unifying dashboard subscriptions on the shared hook, reducing reconnect churn, and making local `/ws` fallback handling more resilient. +- Cleaned up several frontend console and type-check noise sources so the admin pages now run with a clean `tsc --noEmit` result. + +### Improved + +- Improved bulk collection progress tracking in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) by introducing a separate `bulkProgressBatch` state that persists task outcomes across the full `trigger-all` batch instead of averaging only the currently running tasks. +- Improved datasource progress summaries in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) and [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by replacing the previous ad hoc tag row with unified stat pills for total builtin sources, enabled sources, running tasks, successful batch completions, and failed batch items. +- Improved datasource notifications in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) by switching from static `message.*` calls to `message.useMessage()`, which lets the page consume Ant Design context correctly under dynamic themes. +- Improved WebSocket address selection in [useWebSocket.ts](/home/ray/dev/linkong/planet/frontend/src/hooks/useWebSocket.ts) so the client prefers same-origin `/ws`, then falls back to direct backend access on local development hosts when the Vite proxy path is unavailable. +- Improved dashboard realtime updates in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) by removing the page-local WebSocket implementation and reusing the shared `useWebSocket` hook for the `dashboard` channel. +- Improved dashboard loading behavior in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) by updating the initial spinner to Ant Design’s supported fullscreen pattern, eliminating the invalid `Spin tip` warning. + +### Fixed + +- Fixed the bulk datasource progress regression where each finished task disappeared from the running queue and caused the overall progress bar to reset or jump backward mid-batch. +- Fixed stale batch summaries in [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) by clearing completed `bulkProgressBatch` state once every tracked datasource reaches a terminal non-running state. +- Fixed a WebSocket lifecycle bug in [useWebSocket.ts](/home/ray/dev/linkong/planet/frontend/src/hooks/useWebSocket.ts) where changing callbacks or unmounting during `CONNECTING` could leave behind orphaned sockets or produce repeated browser-side “closed before the connection is established” noise. +- Fixed repeated dashboard/admin websocket logic drift by consolidating channel subscription behavior into the shared hook instead of maintaining a second handwritten `new WebSocket(...)` path in the dashboard page. +- Fixed remaining frontend TypeScript hygiene issues in [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) and [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) by removing unused anomaly summary state and an unused `Space` import. + ## 0.22.6 Released: 2026-04-01 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9671e74f..961ce692 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "planet-frontend", - "version": "0.22.6", + "version": "0.22.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "planet-frontend", - "version": "0.22.6", + "version": "0.22.7", "dependencies": { "@ant-design/icons": "^5.2.6", "antd": "^5.12.5", diff --git a/frontend/package.json b/frontend/package.json index 4e4a1963..1d199e38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.22.6", + "version": "0.22.7", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 47d92e27..ec96810e 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,7 +1,38 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { useAuthStore } from '../stores/auth' -const WS_URL = (import.meta as any).env?.VITE_WS_URL || 'ws://localhost:8000/ws' +const DEFAULT_WS_URL = (() => { + if (typeof window === 'undefined') { + return 'ws://localhost:8000/ws' + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}/ws` +})() + +const WS_URL = (import.meta as any).env?.VITE_WS_URL || DEFAULT_WS_URL + +function buildWebSocketCandidates(): string[] { + if ((import.meta as any).env?.VITE_WS_URL) { + return [WS_URL] + } + + if (typeof window === 'undefined') { + return ['ws://localhost:8000/ws'] + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const sameOrigin = `${protocol}//${window.location.host}/ws` + const candidates = [sameOrigin] + + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + const directBackend = `${protocol}//${window.location.hostname}:8000/ws` + if (!candidates.includes(directBackend)) { + candidates.push(directBackend) + } + } + + return candidates +} interface WebSocketMessage { type: string @@ -41,71 +72,130 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet } = options const { token } = useAuthStore() - console.log('[WebSocket] Token present:', !!token, token ? token.substring(0, 20) + '...' : 'none') - console.log('[WebSocket] autoConnect:', autoConnect) const wsRef = useRef(null) const [connected, setConnected] = useState(false) const [lastMessage, setLastMessage] = useState(null) const reconnectTimeoutRef = useRef | null>(null) const heartbeatTimerRef = useRef | null>(null) + const activeWsUrlRef = useRef(null) + const intentionalCloseRef = useRef(false) + const pendingCloseSocketRef = useRef(null) + const autoSubscribeRef = useRef(autoSubscribe) + const onMessageRef = useRef(onMessage) + const onConnectRef = useRef(onConnect) + const onDisconnectRef = useRef(onDisconnect) + const onErrorRef = useRef(onError) + + useEffect(() => { + autoSubscribeRef.current = autoSubscribe + onMessageRef.current = onMessage + onConnectRef.current = onConnect + onDisconnectRef.current = onDisconnect + onErrorRef.current = onError + }, [autoSubscribe, onMessage, onConnect, onDisconnect, onError]) const connect = useCallback(() => { if (!token) { - console.log('[WebSocket] No token, skipping connect') return } - const wsUrl = `${WS_URL}?token=${token}` - console.log('[WebSocket] Connecting to:', wsUrl) - const ws = new WebSocket(wsUrl) + intentionalCloseRef.current = false + const candidates = buildWebSocketCandidates() + let candidateIndex = 0 + let opened = false - ws.onopen = () => { - console.log('[WebSocket] Connected!') - setConnected(true) - if (autoSubscribe.length > 0) { - ws.send(JSON.stringify({ type: 'subscribe', data: { channels: autoSubscribe } })) + const tryConnect = () => { + const baseUrl = candidates[candidateIndex] + const wsUrl = `${baseUrl}?token=${token}` + activeWsUrlRef.current = baseUrl + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + if (intentionalCloseRef.current || pendingCloseSocketRef.current === ws) { + pendingCloseSocketRef.current = null + ws.close() + return + } + opened = true + setConnected(true) + if (autoSubscribeRef.current.length > 0) { + ws.send(JSON.stringify({ type: 'subscribe', data: { channels: autoSubscribeRef.current } })) + } + onConnectRef.current?.() } - onConnect?.() + + ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data) + setLastMessage(message) + onMessageRef.current?.(message) + } catch { + console.error('Failed to parse WebSocket message') + } + } + + ws.onclose = () => { + if (wsRef.current === ws) { + wsRef.current = null + } + if (pendingCloseSocketRef.current === ws) { + pendingCloseSocketRef.current = null + } + setConnected(false) + if (heartbeatTimerRef.current) { + clearInterval(heartbeatTimerRef.current) + heartbeatTimerRef.current = null + } + + if (!opened && candidateIndex < candidates.length - 1) { + candidateIndex += 1 + tryConnect() + return + } + + if (intentionalCloseRef.current) { + return + } + + onDisconnectRef.current?.() + + if (autoConnect && token) { + reconnectTimeoutRef.current = setTimeout(() => { + connect() + }, 3000) + } + } + + ws.onerror = (error) => { + setConnected(false) + if (intentionalCloseRef.current || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) { + return + } + if (candidateIndex >= candidates.length - 1) { + console.warn('[WebSocket] Connection error', { url: baseUrl, error }) + onErrorRef.current?.(new Error('WebSocket error')) + } + } + + wsRef.current = ws } - ws.onmessage = (event) => { - console.log('[WebSocket] Received:', event.data) - try { - const message: WebSocketMessage = JSON.parse(event.data) - setLastMessage(message) - onMessage?.(message) - } catch { - console.error('Failed to parse WebSocket message') - } - } - - ws.onclose = (event) => { - console.log('[WebSocket] Disconnected:', event.code, event.reason) + try { + tryConnect() + } catch (error) { setConnected(false) - if (heartbeatTimerRef.current) { - clearInterval(heartbeatTimerRef.current) - heartbeatTimerRef.current = null - } - onDisconnect?.() - + console.warn('[WebSocket] Failed to initialize connection', { url: activeWsUrlRef.current, error }) if (autoConnect && token) { reconnectTimeoutRef.current = setTimeout(() => { - console.log('[WebSocket] Reconnecting...') connect() }, 3000) } } - - ws.onerror = (error) => { - console.error('[WebSocket] Error:', error) - onError?.(new Error('WebSocket error')) - } - - wsRef.current = ws - }, [token, autoConnect, autoSubscribe, onConnect, onDisconnect, onError]) + }, [token, autoConnect]) const disconnect = useCallback(() => { + intentionalCloseRef.current = true if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null @@ -114,8 +204,14 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet clearInterval(heartbeatTimerRef.current) heartbeatTimerRef.current = null } - wsRef.current?.close() - wsRef.current = null + const socket = wsRef.current + if (socket) { + if (socket.readyState === WebSocket.CONNECTING) { + pendingCloseSocketRef.current = socket + } else if (socket.readyState === WebSocket.OPEN) { + socket.close() + } + } setConnected(false) }, []) @@ -131,7 +227,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet }, []) useEffect(() => { - console.log('[WebSocket] useEffect triggered, autoConnect:', autoConnect, 'token:', !!token) if (autoConnect && token) { connect() } diff --git a/frontend/src/index.css b/frontend/src/index.css index 58f9a3fd..fff1a20e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -281,6 +281,47 @@ body { gap: 8px; } +.data-source-bulk-toolbar__stat-pill { + display: inline-flex; + align-items: baseline; + gap: 8px; + padding: 6px 10px; + border: 1px solid #e5e7eb; + border-radius: 999px; + background: #ffffff; + color: #475467; + line-height: 1; +} + +.data-source-bulk-toolbar__stat-pill strong { + color: #111827; + font-size: 13px; + font-weight: 700; +} + +.data-source-bulk-toolbar__stat-label { + font-size: 12px; + color: #667085; +} + +.data-source-bulk-toolbar__stat-pill--success { + border-color: #b7eb8f; + background: #f6ffed; +} + +.data-source-bulk-toolbar__stat-pill--success strong { + color: #237804; +} + +.data-source-bulk-toolbar__stat-pill--danger { + border-color: #ffccc7; + background: #fff2f0; +} + +.data-source-bulk-toolbar__stat-pill--danger strong { + color: #cf1322; +} + .data-source-bulk-toolbar__progress { display: flex; flex-direction: column; diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 02458b06..d904dd34 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -110,7 +110,6 @@ function BGP() { const [events, setEvents] = useState([]) const [collectors, setCollectors] = useState([]) const [incidentSummary, setIncidentSummary] = useState(null) - const [anomalySummary, setAnomalySummary] = useState(null) const [eventSummary, setEventSummary] = useState(null) const [collectorSummary, setCollectorSummary] = useState(null) @@ -118,11 +117,10 @@ function BGP() { const load = async () => { setLoading(true) try { - const [incidentsRes, incidentSummaryRes, anomaliesRes, anomalySummaryRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([ + const [incidentsRes, incidentSummaryRes, anomaliesRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([ axios.get('/api/v1/bgp/incidents', { params: { page_size: 50 } }), axios.get('/api/v1/bgp/incidents/summary'), axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }), - axios.get('/api/v1/bgp/anomalies/summary'), axios.get('/api/v1/bgp/events', { params: { page_size: 20 } }), axios.get('/api/v1/bgp/events/summary'), axios.get('/api/v1/bgp/collectors'), @@ -131,7 +129,6 @@ function BGP() { setIncidents(incidentsRes.data.data || []) setIncidentSummary(incidentSummaryRes.data) setAnomalies(anomaliesRes.data.data || []) - setAnomalySummary(anomalySummaryRes.data) setEvents(eventsRes.data.data || []) setEventSummary(eventSummaryRes.data) setCollectors(collectorsRes.data.data || []) diff --git a/frontend/src/pages/Dashboard/Dashboard.tsx b/frontend/src/pages/Dashboard/Dashboard.tsx index 1cf68dd8..9b9433a7 100644 --- a/frontend/src/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/pages/Dashboard/Dashboard.tsx @@ -14,6 +14,7 @@ import { Link } from 'react-router-dom' import axios from 'axios' import { useAuthStore } from '../../stores/auth' import AppLayout from '../../components/AppLayout/AppLayout' +import { useWebSocket } from '../../hooks/useWebSocket' import { formatDateTimeZhCN } from '../../utils/datetime' const { Title, Text } = Typography @@ -150,54 +151,21 @@ function Dashboard() { fetchStats() }, [token, clearAuth]) - useEffect(() => { - if (!token) return - - let ws: WebSocket | null = null - let reconnectTimer: ReturnType | null = null - - const connectWs = () => { - try { - ws = new WebSocket(`ws://localhost:8000/ws?token=${token}`) - - ws.onopen = () => { - setWsConnected(true) - ws?.send(JSON.stringify({ type: 'subscribe', data: { channels: ['dashboard'] } })) - } - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data) - if (msg.type === 'data_frame' && msg.channel === 'dashboard') { - const nextStats = msg.payload?.stats as Stats - cachedDashboardStats = nextStats - setStats(nextStats) - } - } catch (e) { - console.error('Parse WS message error:', e) - } - } - - ws.onclose = () => { - setWsConnected(false) - reconnectTimer = setTimeout(connectWs, 3000) - } - - ws.onerror = () => { - setWsConnected(false) - } - } catch (e) { - console.error('WS connect error:', e) + const { connected: dashboardSocketConnected } = useWebSocket({ + autoConnect: true, + autoSubscribe: ['dashboard'], + onMessage: (message) => { + if (message.type === 'data_frame' && message.channel === 'dashboard' && message.payload?.stats) { + const nextStats = message.payload.stats as Stats + cachedDashboardStats = nextStats + setStats(nextStats) } - } + }, + }) - connectWs() - - return () => { - ws?.close() - if (reconnectTimer) clearTimeout(reconnectTimer) - } - }, [token]) + useEffect(() => { + setWsConnected(dashboardSocketConnected) + }, [dashboardSocketConnected]) const handleRetry = () => { window.location.reload() @@ -376,8 +344,8 @@ function Dashboard() { if (loading && !stats) { return ( -
- +
+
) } diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index c22a426e..70b65042 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { - Table, Tag, Space, message, Button, Form, Input, Select, Progress, Checkbox, + Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card } from 'antd' import { @@ -47,6 +47,37 @@ interface TaskTrackerState { error_message?: string | null } +interface BulkProgressItem { + task_id: number | null + progress: number + status: string | null + phase: string | null + is_running: boolean +} + +interface BulkProgressBatch { + sourceIds: number[] + items: Record +} + +function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgressBatch | null { + if (!batch || batch.sourceIds.length === 0) { + return null + } + + const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running) + if (hasRunningItem) { + return batch + } + + const allFinished = batch.sourceIds.every((sourceId) => { + const status = batch.items[sourceId]?.status + return Boolean(status && status !== 'running') + }) + + return allFinished ? null : batch +} + interface WebSocketTaskMessage { type: string channel?: string @@ -90,6 +121,7 @@ interface ViewDataSource { } function DataSources() { + const [messageApi, contextHolder] = message.useMessage() const [activeTab, setActiveTab] = useState('builtin') const [builtInSources, setBuiltInSources] = useState([]) const [customSources, setCustomSources] = useState([]) @@ -126,6 +158,7 @@ function DataSources() { }, []) const [taskProgress, setTaskProgress] = useState>({}) + const [bulkProgressBatch, setBulkProgressBatch] = useState(null) const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length const runningBuiltInCount = builtInSources.filter((source) => { const trackedTask = taskProgress[source.id] @@ -135,13 +168,42 @@ function DataSources() { const trackedTask = taskProgress[source.id] return trackedTask?.is_running || source.is_running }) - const aggregateProgress = runningBuiltInSources.length > 0 + const aggregateProgress = bulkProgressBatch && bulkProgressBatch.sourceIds.length > 0 ? Math.round( - runningBuiltInSources.reduce((sum, source) => { - const trackedTask = taskProgress[source.id] - return sum + (trackedTask?.progress ?? source.progress ?? 0) - }, 0) / runningBuiltInSources.length + bulkProgressBatch.sourceIds.reduce((sum, sourceId) => { + const item = bulkProgressBatch.items[sourceId] + if (!item) return sum + if (item.status && item.status !== 'running') { + return sum + 100 + } + return sum + (item.progress || 0) + }, 0) / bulkProgressBatch.sourceIds.length ) + : 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 bulkBatchRunningCount = bulkProgressBatch + ? bulkProgressBatch.sourceIds.filter((sourceId) => bulkProgressBatch.items[sourceId]?.is_running).length + : 0 + + const bulkBatchSuccessCount = bulkProgressBatch + ? bulkProgressBatch.sourceIds.filter((sourceId) => { + const status = bulkProgressBatch.items[sourceId]?.status + return status === 'success' + }).length + : 0 + + const bulkBatchFailedCount = bulkProgressBatch + ? bulkProgressBatch.sourceIds.filter((sourceId) => { + const status = bulkProgressBatch.items[sourceId]?.status + return Boolean(status && status !== 'running' && status !== 'success') + }).length : 0 const handleTaskSocketMessage = useCallback((message: WebSocketTaskMessage) => { @@ -151,6 +213,9 @@ function DataSources() { const payload = message.payload const sourceId = payload.datasource_id + if (typeof sourceId !== 'number') { + return + } const nextState: TaskTrackerState = { task_id: payload.task_id ?? null, progress: payload.progress ?? 0, @@ -175,6 +240,28 @@ function DataSources() { return next }) + setBulkProgressBatch((prev) => { + if (!prev || !prev.sourceIds.includes(sourceId)) { + return prev + } + + const nextItems = { + ...prev.items, + [sourceId]: { + task_id: payload.task_id ?? prev.items[sourceId]?.task_id ?? null, + progress: payload.status && payload.status !== 'running' ? 100 : (payload.progress ?? prev.items[sourceId]?.progress ?? 0), + is_running: payload.status === 'running', + phase: payload.phase ?? prev.items[sourceId]?.phase ?? null, + status: payload.status ?? prev.items[sourceId]?.status ?? null, + }, + } + + return finalizeBulkProgressBatch({ + ...prev, + items: nextItems, + }) + }) + if (payload.status && payload.status !== 'running') { void fetchData() } @@ -265,6 +352,28 @@ function DataSources() { return next }) + setBulkProgressBatch((prev) => { + if (!prev) return prev + + const nextItems = { ...prev.items } + for (const [sourceId, state] of Object.entries(updates)) { + const numericSourceId = Number(sourceId) + if (!prev.sourceIds.includes(numericSourceId)) continue + nextItems[numericSourceId] = { + task_id: state.task_id, + progress: state.status && state.status !== 'running' ? 100 : state.progress, + is_running: state.is_running, + phase: state.phase ?? null, + status: state.status ?? null, + } + } + + return finalizeBulkProgressBatch({ + ...prev, + items: nextItems, + }) + }) + if (Object.values(updates).some((state) => !state.is_running)) { fetchData() } @@ -276,7 +385,7 @@ function DataSources() { const handleTrigger = async (id: number) => { try { const res = await axios.post(`/api/v1/datasources/${id}/trigger`) - message.success('任务已触发') + messageApi.success('任务已触发') if (res.data.task_id) { setTaskProgress(prev => ({ ...prev, @@ -296,7 +405,7 @@ function DataSources() { fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '触发失败') + messageApi.error(err.response?.data?.detail || '触发失败') } } @@ -313,6 +422,22 @@ function DataSources() { const skippedOther = skipped.filter((item: { reason?: string }) => item.reason !== 'within_frequency_window') if (triggered.length > 0) { + setBulkProgressBatch({ + sourceIds: triggered.map((item: { id: number }) => item.id), + items: Object.fromEntries( + triggered.map((item: { id: number; task_id?: number | null }) => [ + item.id, + { + task_id: item.task_id ?? null, + progress: 0, + is_running: true, + phase: 'queued', + status: 'running', + } satisfies BulkProgressItem, + ]) + ), + }) + setTaskProgress((prev) => { const next = { ...prev } for (const item of triggered) { @@ -336,11 +461,11 @@ function DataSources() { failed.length > 0 ? `失败 ${failed.length} 个` : null, ].filter(Boolean) - message.success(summaryParts.join(',')) + messageApi.success(summaryParts.join(',')) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '全触发失败') + messageApi.error(err.response?.data?.detail || '全触发失败') } finally { setTriggerAllLoading(false) } @@ -350,11 +475,11 @@ function DataSources() { const endpoint = current ? 'disable' : 'enable' try { await axios.post(`/api/v1/datasources/${id}/${endpoint}`) - message.success(`${current ? '已禁用' : '已启用'}`) + messageApi.success(`${current ? '已禁用' : '已启用'}`) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '操作失败') + messageApi.error(err.response?.data?.detail || '操作失败') } } @@ -362,12 +487,12 @@ function DataSources() { if (!viewingSource) return try { const res = await axios.delete(`/api/v1/datasources/${viewingSource.id}/data`) - message.success(res.data.message || '数据已删除') + messageApi.success(res.data.message || '数据已删除') setViewDrawerVisible(false) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '删除数据失败') + messageApi.error(err.response?.data?.detail || '删除数据失败') } } @@ -395,7 +520,7 @@ function DataSources() { setRecordCount(statsRes.data.total_records || 0) setViewDrawerVisible(true) } catch (error) { - message.error('获取数据源信息失败') + messageApi.error('获取数据源信息失败') } } @@ -403,11 +528,11 @@ function DataSources() { if (!viewingSource) return try { await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`) - message.success('已触发更新') + messageApi.success('已触发更新') setViewDrawerVisible(false) } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '更新失败') + messageApi.error(err.response?.data?.detail || '更新失败') } } @@ -419,13 +544,13 @@ function DataSources() { const res = await axios.post('/api/v1/datasources/configs/test', values) setTestResult(res.data) if (res.data.success) { - message.success('连接测试成功') + messageApi.success('连接测试成功') } else { - message.error('连接测试失败') + messageApi.error('连接测试失败') } } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string; message?: string } } } - message.error(err.response?.data?.message || err.response?.data?.detail || '测试失败') + messageApi.error(err.response?.data?.message || err.response?.data?.detail || '测试失败') } finally { setTesting(false) } @@ -436,10 +561,10 @@ function DataSources() { const values = await form.validateFields() if (editingConfig) { await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values) - message.success('配置已更新') + messageApi.success('配置已更新') } else { await axios.post('/api/v1/datasources/configs', values) - message.success('配置已创建') + messageApi.success('配置已创建') } setDrawerVisible(false) form.resetFields() @@ -448,29 +573,29 @@ function DataSources() { fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string; message?: string } } } - message.error(err.response?.data?.message || err.response?.data?.detail || '保存失败') + messageApi.error(err.response?.data?.message || err.response?.data?.detail || '保存失败') } } const handleDelete = async (id: number) => { try { await axios.delete(`/api/v1/datasources/configs/${id}`) - message.success('配置已删除') + messageApi.success('配置已删除') fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '删除失败') + messageApi.error(err.response?.data?.detail || '删除失败') } } const handleToggleCustom = async (id: number, current: boolean) => { try { await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current }) - message.success(`${current ? '已禁用' : '已启用'}`) + messageApi.success(`${current ? '已禁用' : '已启用'}`) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '操作失败') + messageApi.error(err.response?.data?.detail || '操作失败') } } @@ -509,9 +634,9 @@ function DataSources() { document.execCommand('copy') document.body.removeChild(textArea) } - message.success(successText) + messageApi.success(successText) } catch { - message.error('复制失败,请手动复制') + messageApi.error('复制失败,请手动复制') } } @@ -719,9 +844,30 @@ function DataSources() { />
- 内置 {builtInSources.length} - 已启用 {activeBuiltInCount} - 执行中 {runningBuiltInCount} +
+ 内置 + {builtInSources.length} +
+
+ 已启用 + {activeBuiltInCount} +
+
+ 执行中 + {bulkProgressBatch ? bulkBatchRunningCount : runningBuiltInCount} +
+ {bulkProgressBatch ? ( +
+ 成功 + {bulkBatchSuccessCount}/{bulkProgressBatch.sourceIds.length} +
+ ) : null} + {bulkProgressBatch ? ( +
+ 失败 + {bulkBatchFailedCount} +
+ ) : null}
@@ -796,6 +942,7 @@ function DataSources() { return ( + {contextHolder}

数据源管理

diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index 558a4b78..e7d7fc7f 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -7,7 +7,6 @@ import { InputNumber, message, Select, - Space, Switch, Table, Tabs, diff --git a/uv.lock b/uv.lock index c4762569..259fe961 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.22.4" +version = "0.22.7" source = { virtual = "." } dependencies = [ { name = "aiofiles" },