fix: stabilize datasource progress and websocket flow
This commit is contained in:
@@ -7,6 +7,33 @@ This project follows the repository versioning rule:
|
|||||||
- `feature` -> `+0.1.0`
|
- `feature` -> `+0.1.0`
|
||||||
- `bugfix` -> `+0.0.1`
|
- `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
|
## 0.22.6
|
||||||
|
|
||||||
Released: 2026-04-01
|
Released: 2026-04-01
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.22.6",
|
"version": "0.22.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.22.6",
|
"version": "0.22.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
"antd": "^5.12.5",
|
"antd": "^5.12.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.22.6",
|
"version": "0.22.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { useAuthStore } from '../stores/auth'
|
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 {
|
interface WebSocketMessage {
|
||||||
type: string
|
type: string
|
||||||
@@ -41,71 +72,130 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||||||
} = options
|
} = options
|
||||||
|
|
||||||
const { token } = useAuthStore()
|
const { token } = useAuthStore()
|
||||||
console.log('[WebSocket] Token present:', !!token, token ? token.substring(0, 20) + '...' : 'none')
|
|
||||||
console.log('[WebSocket] autoConnect:', autoConnect)
|
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const heartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const heartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const activeWsUrlRef = useRef<string | null>(null)
|
||||||
|
const intentionalCloseRef = useRef(false)
|
||||||
|
const pendingCloseSocketRef = useRef<WebSocket | null>(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(() => {
|
const connect = useCallback(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.log('[WebSocket] No token, skipping connect')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsUrl = `${WS_URL}?token=${token}`
|
intentionalCloseRef.current = false
|
||||||
console.log('[WebSocket] Connecting to:', wsUrl)
|
const candidates = buildWebSocketCandidates()
|
||||||
const ws = new WebSocket(wsUrl)
|
let candidateIndex = 0
|
||||||
|
let opened = false
|
||||||
|
|
||||||
ws.onopen = () => {
|
const tryConnect = () => {
|
||||||
console.log('[WebSocket] Connected!')
|
const baseUrl = candidates[candidateIndex]
|
||||||
setConnected(true)
|
const wsUrl = `${baseUrl}?token=${token}`
|
||||||
if (autoSubscribe.length > 0) {
|
activeWsUrlRef.current = baseUrl
|
||||||
ws.send(JSON.stringify({ type: 'subscribe', data: { channels: autoSubscribe } }))
|
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) => {
|
try {
|
||||||
console.log('[WebSocket] Received:', event.data)
|
tryConnect()
|
||||||
try {
|
} catch (error) {
|
||||||
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)
|
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
if (heartbeatTimerRef.current) {
|
console.warn('[WebSocket] Failed to initialize connection', { url: activeWsUrlRef.current, error })
|
||||||
clearInterval(heartbeatTimerRef.current)
|
|
||||||
heartbeatTimerRef.current = null
|
|
||||||
}
|
|
||||||
onDisconnect?.()
|
|
||||||
|
|
||||||
if (autoConnect && token) {
|
if (autoConnect && token) {
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
console.log('[WebSocket] Reconnecting...')
|
|
||||||
connect()
|
connect()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [token, autoConnect])
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error('[WebSocket] Error:', error)
|
|
||||||
onError?.(new Error('WebSocket error'))
|
|
||||||
}
|
|
||||||
|
|
||||||
wsRef.current = ws
|
|
||||||
}, [token, autoConnect, autoSubscribe, onConnect, onDisconnect, onError])
|
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
|
intentionalCloseRef.current = true
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
reconnectTimeoutRef.current = null
|
reconnectTimeoutRef.current = null
|
||||||
@@ -114,8 +204,14 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||||||
clearInterval(heartbeatTimerRef.current)
|
clearInterval(heartbeatTimerRef.current)
|
||||||
heartbeatTimerRef.current = null
|
heartbeatTimerRef.current = null
|
||||||
}
|
}
|
||||||
wsRef.current?.close()
|
const socket = wsRef.current
|
||||||
wsRef.current = null
|
if (socket) {
|
||||||
|
if (socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
pendingCloseSocketRef.current = socket
|
||||||
|
} else if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -131,7 +227,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[WebSocket] useEffect triggered, autoConnect:', autoConnect, 'token:', !!token)
|
|
||||||
if (autoConnect && token) {
|
if (autoConnect && token) {
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,47 @@ body {
|
|||||||
gap: 8px;
|
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 {
|
.data-source-bulk-toolbar__progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ function BGP() {
|
|||||||
const [events, setEvents] = useState<BGPEvent[]>([])
|
const [events, setEvents] = useState<BGPEvent[]>([])
|
||||||
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
|
const [collectors, setCollectors] = useState<BGPCollectorCoverage[]>([])
|
||||||
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
const [incidentSummary, setIncidentSummary] = useState<Summary | null>(null)
|
||||||
const [anomalySummary, setAnomalySummary] = useState<Summary | null>(null)
|
|
||||||
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
const [eventSummary, setEventSummary] = useState<EventSummary | null>(null)
|
||||||
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
const [collectorSummary, setCollectorSummary] = useState<CollectorSummary | null>(null)
|
||||||
|
|
||||||
@@ -118,11 +117,10 @@ function BGP() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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', { params: { page_size: 50 } }),
|
||||||
axios.get('/api/v1/bgp/incidents/summary'),
|
axios.get('/api/v1/bgp/incidents/summary'),
|
||||||
axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }),
|
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', { params: { page_size: 20 } }),
|
||||||
axios.get('/api/v1/bgp/events/summary'),
|
axios.get('/api/v1/bgp/events/summary'),
|
||||||
axios.get('/api/v1/bgp/collectors'),
|
axios.get('/api/v1/bgp/collectors'),
|
||||||
@@ -131,7 +129,6 @@ function BGP() {
|
|||||||
setIncidents(incidentsRes.data.data || [])
|
setIncidents(incidentsRes.data.data || [])
|
||||||
setIncidentSummary(incidentSummaryRes.data)
|
setIncidentSummary(incidentSummaryRes.data)
|
||||||
setAnomalies(anomaliesRes.data.data || [])
|
setAnomalies(anomaliesRes.data.data || [])
|
||||||
setAnomalySummary(anomalySummaryRes.data)
|
|
||||||
setEvents(eventsRes.data.data || [])
|
setEvents(eventsRes.data.data || [])
|
||||||
setEventSummary(eventSummaryRes.data)
|
setEventSummary(eventSummaryRes.data)
|
||||||
setCollectors(collectorsRes.data.data || [])
|
setCollectors(collectorsRes.data.data || [])
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@@ -150,54 +151,21 @@ function Dashboard() {
|
|||||||
fetchStats()
|
fetchStats()
|
||||||
}, [token, clearAuth])
|
}, [token, clearAuth])
|
||||||
|
|
||||||
useEffect(() => {
|
const { connected: dashboardSocketConnected } = useWebSocket({
|
||||||
if (!token) return
|
autoConnect: true,
|
||||||
|
autoSubscribe: ['dashboard'],
|
||||||
let ws: WebSocket | null = null
|
onMessage: (message) => {
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
if (message.type === 'data_frame' && message.channel === 'dashboard' && message.payload?.stats) {
|
||||||
|
const nextStats = message.payload.stats as Stats
|
||||||
const connectWs = () => {
|
cachedDashboardStats = nextStats
|
||||||
try {
|
setStats(nextStats)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
})
|
||||||
|
|
||||||
connectWs()
|
useEffect(() => {
|
||||||
|
setWsConnected(dashboardSocketConnected)
|
||||||
return () => {
|
}, [dashboardSocketConnected])
|
||||||
ws?.close()
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
||||||
}
|
|
||||||
}, [token])
|
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
@@ -376,8 +344,8 @@ function Dashboard() {
|
|||||||
|
|
||||||
if (loading && !stats) {
|
if (loading && !stats) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<div style={{ height: '100vh' }}>
|
||||||
<Spin size="large" tip="加载中..." />
|
<Spin size="large" tip="加载中..." fullscreen />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
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
|
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,37 @@ interface TaskTrackerState {
|
|||||||
error_message?: string | null
|
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<number, BulkProgressItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
interface WebSocketTaskMessage {
|
||||||
type: string
|
type: string
|
||||||
channel?: string
|
channel?: string
|
||||||
@@ -90,6 +121,7 @@ interface ViewDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DataSources() {
|
function DataSources() {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
const [activeTab, setActiveTab] = useState('builtin')
|
const [activeTab, setActiveTab] = useState('builtin')
|
||||||
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
|
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
|
||||||
const [customSources, setCustomSources] = useState<CustomDataSource[]>([])
|
const [customSources, setCustomSources] = useState<CustomDataSource[]>([])
|
||||||
@@ -126,6 +158,7 @@ function DataSources() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [taskProgress, setTaskProgress] = useState<Record<number, TaskTrackerState>>({})
|
const [taskProgress, setTaskProgress] = useState<Record<number, TaskTrackerState>>({})
|
||||||
|
const [bulkProgressBatch, setBulkProgressBatch] = useState<BulkProgressBatch | null>(null)
|
||||||
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
|
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
|
||||||
const runningBuiltInCount = builtInSources.filter((source) => {
|
const runningBuiltInCount = builtInSources.filter((source) => {
|
||||||
const trackedTask = taskProgress[source.id]
|
const trackedTask = taskProgress[source.id]
|
||||||
@@ -135,13 +168,42 @@ function DataSources() {
|
|||||||
const trackedTask = taskProgress[source.id]
|
const trackedTask = taskProgress[source.id]
|
||||||
return trackedTask?.is_running || source.is_running
|
return trackedTask?.is_running || source.is_running
|
||||||
})
|
})
|
||||||
const aggregateProgress = runningBuiltInSources.length > 0
|
const aggregateProgress = bulkProgressBatch && bulkProgressBatch.sourceIds.length > 0
|
||||||
? Math.round(
|
? Math.round(
|
||||||
runningBuiltInSources.reduce((sum, source) => {
|
bulkProgressBatch.sourceIds.reduce((sum, sourceId) => {
|
||||||
const trackedTask = taskProgress[source.id]
|
const item = bulkProgressBatch.items[sourceId]
|
||||||
return sum + (trackedTask?.progress ?? source.progress ?? 0)
|
if (!item) return sum
|
||||||
}, 0) / runningBuiltInSources.length
|
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
|
: 0
|
||||||
|
|
||||||
const handleTaskSocketMessage = useCallback((message: WebSocketTaskMessage) => {
|
const handleTaskSocketMessage = useCallback((message: WebSocketTaskMessage) => {
|
||||||
@@ -151,6 +213,9 @@ function DataSources() {
|
|||||||
|
|
||||||
const payload = message.payload
|
const payload = message.payload
|
||||||
const sourceId = payload.datasource_id
|
const sourceId = payload.datasource_id
|
||||||
|
if (typeof sourceId !== 'number') {
|
||||||
|
return
|
||||||
|
}
|
||||||
const nextState: TaskTrackerState = {
|
const nextState: TaskTrackerState = {
|
||||||
task_id: payload.task_id ?? null,
|
task_id: payload.task_id ?? null,
|
||||||
progress: payload.progress ?? 0,
|
progress: payload.progress ?? 0,
|
||||||
@@ -175,6 +240,28 @@ function DataSources() {
|
|||||||
return next
|
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') {
|
if (payload.status && payload.status !== 'running') {
|
||||||
void fetchData()
|
void fetchData()
|
||||||
}
|
}
|
||||||
@@ -265,6 +352,28 @@ function DataSources() {
|
|||||||
return next
|
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)) {
|
if (Object.values(updates).some((state) => !state.is_running)) {
|
||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
@@ -276,7 +385,7 @@ function DataSources() {
|
|||||||
const handleTrigger = async (id: number) => {
|
const handleTrigger = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
|
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
|
||||||
message.success('任务已触发')
|
messageApi.success('任务已触发')
|
||||||
if (res.data.task_id) {
|
if (res.data.task_id) {
|
||||||
setTaskProgress(prev => ({
|
setTaskProgress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -296,7 +405,7 @@ function DataSources() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
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')
|
const skippedOther = skipped.filter((item: { reason?: string }) => item.reason !== 'within_frequency_window')
|
||||||
|
|
||||||
if (triggered.length > 0) {
|
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) => {
|
setTaskProgress((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
for (const item of triggered) {
|
for (const item of triggered) {
|
||||||
@@ -336,11 +461,11 @@ function DataSources() {
|
|||||||
failed.length > 0 ? `失败 ${failed.length} 个` : null,
|
failed.length > 0 ? `失败 ${failed.length} 个` : null,
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
message.success(summaryParts.join(','))
|
messageApi.success(summaryParts.join(','))
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
const err = error as { response?: { data?: { detail?: string } } }
|
||||||
message.error(err.response?.data?.detail || '全触发失败')
|
messageApi.error(err.response?.data?.detail || '全触发失败')
|
||||||
} finally {
|
} finally {
|
||||||
setTriggerAllLoading(false)
|
setTriggerAllLoading(false)
|
||||||
}
|
}
|
||||||
@@ -350,11 +475,11 @@ function DataSources() {
|
|||||||
const endpoint = current ? 'disable' : 'enable'
|
const endpoint = current ? 'disable' : 'enable'
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/v1/datasources/${id}/${endpoint}`)
|
await axios.post(`/api/v1/datasources/${id}/${endpoint}`)
|
||||||
message.success(`${current ? '已禁用' : '已启用'}`)
|
messageApi.success(`${current ? '已禁用' : '已启用'}`)
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
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
|
if (!viewingSource) return
|
||||||
try {
|
try {
|
||||||
const res = await axios.delete(`/api/v1/datasources/${viewingSource.id}/data`)
|
const res = await axios.delete(`/api/v1/datasources/${viewingSource.id}/data`)
|
||||||
message.success(res.data.message || '数据已删除')
|
messageApi.success(res.data.message || '数据已删除')
|
||||||
setViewDrawerVisible(false)
|
setViewDrawerVisible(false)
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
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)
|
setRecordCount(statsRes.data.total_records || 0)
|
||||||
setViewDrawerVisible(true)
|
setViewDrawerVisible(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取数据源信息失败')
|
messageApi.error('获取数据源信息失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,11 +528,11 @@ function DataSources() {
|
|||||||
if (!viewingSource) return
|
if (!viewingSource) return
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`)
|
await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`)
|
||||||
message.success('已触发更新')
|
messageApi.success('已触发更新')
|
||||||
setViewDrawerVisible(false)
|
setViewDrawerVisible(false)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
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)
|
const res = await axios.post('/api/v1/datasources/configs/test', values)
|
||||||
setTestResult(res.data)
|
setTestResult(res.data)
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
message.success('连接测试成功')
|
messageApi.success('连接测试成功')
|
||||||
} else {
|
} else {
|
||||||
message.error('连接测试失败')
|
messageApi.error('连接测试失败')
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
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 {
|
} finally {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
@@ -436,10 +561,10 @@ function DataSources() {
|
|||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
if (editingConfig) {
|
if (editingConfig) {
|
||||||
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
|
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
|
||||||
message.success('配置已更新')
|
messageApi.success('配置已更新')
|
||||||
} else {
|
} else {
|
||||||
await axios.post('/api/v1/datasources/configs', values)
|
await axios.post('/api/v1/datasources/configs', values)
|
||||||
message.success('配置已创建')
|
messageApi.success('配置已创建')
|
||||||
}
|
}
|
||||||
setDrawerVisible(false)
|
setDrawerVisible(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
@@ -448,29 +573,29 @@ function DataSources() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
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) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/v1/datasources/configs/${id}`)
|
await axios.delete(`/api/v1/datasources/configs/${id}`)
|
||||||
message.success('配置已删除')
|
messageApi.success('配置已删除')
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
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) => {
|
const handleToggleCustom = async (id: number, current: boolean) => {
|
||||||
try {
|
try {
|
||||||
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
|
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
|
||||||
message.success(`${current ? '已禁用' : '已启用'}`)
|
messageApi.success(`${current ? '已禁用' : '已启用'}`)
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
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.execCommand('copy')
|
||||||
document.body.removeChild(textArea)
|
document.body.removeChild(textArea)
|
||||||
}
|
}
|
||||||
message.success(successText)
|
messageApi.success(successText)
|
||||||
} catch {
|
} catch {
|
||||||
message.error('复制失败,请手动复制')
|
messageApi.error('复制失败,请手动复制')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,9 +844,30 @@ function DataSources() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="data-source-bulk-toolbar__stats">
|
<div className="data-source-bulk-toolbar__stats">
|
||||||
<Tag color="blue">内置 {builtInSources.length}</Tag>
|
<div className="data-source-bulk-toolbar__stat-pill">
|
||||||
<Tag color="green">已启用 {activeBuiltInCount}</Tag>
|
<span className="data-source-bulk-toolbar__stat-label">内置</span>
|
||||||
<Tag color="processing">执行中 {runningBuiltInCount}</Tag>
|
<strong>{builtInSources.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="data-source-bulk-toolbar__stat-pill">
|
||||||
|
<span className="data-source-bulk-toolbar__stat-label">已启用</span>
|
||||||
|
<strong>{activeBuiltInCount}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="data-source-bulk-toolbar__stat-pill">
|
||||||
|
<span className="data-source-bulk-toolbar__stat-label">执行中</span>
|
||||||
|
<strong>{bulkProgressBatch ? bulkBatchRunningCount : runningBuiltInCount}</strong>
|
||||||
|
</div>
|
||||||
|
{bulkProgressBatch ? (
|
||||||
|
<div className="data-source-bulk-toolbar__stat-pill data-source-bulk-toolbar__stat-pill--success">
|
||||||
|
<span className="data-source-bulk-toolbar__stat-label">成功</span>
|
||||||
|
<strong>{bulkBatchSuccessCount}/{bulkProgressBatch.sourceIds.length}</strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{bulkProgressBatch ? (
|
||||||
|
<div className="data-source-bulk-toolbar__stat-pill data-source-bulk-toolbar__stat-pill--danger">
|
||||||
|
<span className="data-source-bulk-toolbar__stat-label">失败</span>
|
||||||
|
<strong>{bulkBatchFailedCount}</strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Space size={12} align="center">
|
<Space size={12} align="center">
|
||||||
@@ -796,6 +942,7 @@ function DataSources() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
|
{contextHolder}
|
||||||
<div className="page-shell">
|
<div className="page-shell">
|
||||||
<div className="page-shell__header">
|
<div className="page-shell__header">
|
||||||
<h2 style={{ margin: 0 }}>数据源管理</h2>
|
<h2 style={{ margin: 0 }}>数据源管理</h2>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
message,
|
message,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
|||||||
Reference in New Issue
Block a user