1508 lines
51 KiB
TypeScript
1508 lines
51 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import {
|
||
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
|
||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||
} from 'antd'
|
||
import {
|
||
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
|
||
EditOutlined, DeleteOutlined, ApiOutlined,
|
||
CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined,
|
||
SyncOutlined, ClearOutlined, CopyOutlined
|
||
} from '@ant-design/icons'
|
||
import axios, { type AxiosResponse } from 'axios'
|
||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
|
||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||
|
||
interface BuiltInDataSource {
|
||
id: number
|
||
name: string
|
||
module: string
|
||
priority: string
|
||
frequency: string
|
||
endpoint?: string
|
||
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
|
||
phase?: string | null
|
||
records_processed: number | null
|
||
total_records: number | null
|
||
}
|
||
|
||
interface TaskTrackerState {
|
||
task_id: number | null
|
||
is_running: boolean
|
||
progress: number
|
||
phase: string | null
|
||
status?: string | null
|
||
records_processed?: number | null
|
||
total_records?: number | 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>
|
||
}
|
||
|
||
type TriggerDatasourceConflict = {
|
||
reason?: string
|
||
message?: string
|
||
progress?: number | null
|
||
phase?: string | null
|
||
records_processed?: number | null
|
||
total_records?: number | null
|
||
}
|
||
|
||
type TriggerDatasourceResult =
|
||
| { ok: true; response: AxiosResponse<any> }
|
||
| { ok: false; status: number; detail?: string | TriggerDatasourceConflict }
|
||
|
||
type DatasourceTaskStatus = {
|
||
is_running: boolean
|
||
task_id?: number | null
|
||
progress?: number | null
|
||
phase?: string | null
|
||
records_processed?: number | null
|
||
total_records?: number | null
|
||
status?: string | null
|
||
}
|
||
|
||
const phaseProgressRanges: Record<string, [number, number]> = {
|
||
queued: [0, 0],
|
||
fetching: [0, 30],
|
||
transforming: [30, 50],
|
||
saving: [50, 100],
|
||
completed: [100, 100],
|
||
success: [100, 100],
|
||
failed: [0, 0],
|
||
cancelled: [0, 0],
|
||
}
|
||
|
||
function mapPhaseProgressToOverall(
|
||
phase?: string | null,
|
||
progress?: number | null,
|
||
status?: string | null,
|
||
): number {
|
||
if (status && status !== 'running') {
|
||
return status === 'success' ? 100 : 0
|
||
}
|
||
|
||
const normalizedPhase = phase || 'queued'
|
||
const [start, end] = phaseProgressRanges[normalizedPhase] ?? [0, 100]
|
||
if (start === end) return start
|
||
|
||
const boundedProgress = Math.max(0, Math.min(100, progress ?? 0))
|
||
return start + ((end - start) * boundedProgress) / 100
|
||
}
|
||
|
||
function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgressBatch | null {
|
||
if (!batch || batch.sourceIds.length === 0) {
|
||
return null
|
||
}
|
||
return batch
|
||
}
|
||
|
||
function resolveTerminalBatchItem(
|
||
sourceId: number,
|
||
batch: BulkProgressBatch,
|
||
builtInSources: BuiltInDataSource[],
|
||
taskProgress: Record<number, TaskTrackerState>,
|
||
): BulkProgressItem | null {
|
||
const currentItem = batch.items[sourceId]
|
||
const source = builtInSources.find((item) => item.id === sourceId)
|
||
const trackedTask = taskProgress[sourceId]
|
||
|
||
const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false
|
||
if (isRunning) {
|
||
return null
|
||
}
|
||
|
||
const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null
|
||
if (!status || status === 'running') {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
task_id: trackedTask?.task_id ?? currentItem?.task_id ?? source?.task_id ?? null,
|
||
progress:
|
||
status === 'success'
|
||
? 100
|
||
: trackedTask?.progress ?? source?.progress ?? currentItem?.progress ?? 0,
|
||
is_running: false,
|
||
phase: trackedTask?.phase ?? source?.phase ?? currentItem?.phase ?? null,
|
||
status,
|
||
}
|
||
}
|
||
|
||
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 {
|
||
id: number
|
||
name: string
|
||
description: string | null
|
||
source_type: string
|
||
endpoint: string
|
||
auth_type: string
|
||
is_active: boolean
|
||
created_at: string
|
||
updated_at: string | null
|
||
}
|
||
|
||
interface ViewDataSource {
|
||
id: number
|
||
name: string
|
||
description: string | null
|
||
source_type: string
|
||
endpoint: string
|
||
auth_type: string
|
||
headers: Record<string, string>
|
||
config: Record<string, any>
|
||
collector_class: string
|
||
module: string
|
||
priority: string
|
||
frequency: string
|
||
}
|
||
|
||
function DataSources() {
|
||
const [messageApi, contextHolder] = message.useMessage()
|
||
const [modal, modalContextHolder] = Modal.useModal()
|
||
const [activeTab, setActiveTab] = useState('builtin')
|
||
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
|
||
const [customSources, setCustomSources] = useState<CustomDataSource[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
|
||
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
|
||
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)
|
||
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
|
||
const [customTableHeight, setCustomTableHeight] = useState(360)
|
||
const [form] = Form.useForm()
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const [builtinRes, customRes] = await Promise.all([
|
||
axios.get('/api/v1/datasources'),
|
||
axios.get('/api/v1/datasources/configs')
|
||
])
|
||
setBuiltInSources(builtinRes.data.data || [])
|
||
setCustomSources(customRes.data.data || [])
|
||
} catch (error) {
|
||
console.error('Failed to fetch data:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
const [taskProgress, setTaskProgress] = useState<Record<number, TaskTrackerState>>({})
|
||
const [bulkProgressBatch, setBulkProgressBatch] = useState<BulkProgressBatch | null>(null)
|
||
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 = bulkProgressBatch && bulkProgressBatch.sourceIds.length > 0
|
||
? Math.round(
|
||
bulkProgressBatch.sourceIds.reduce((sum, sourceId) => {
|
||
const item = bulkProgressBatch.items[sourceId]
|
||
if (!item) return sum
|
||
return sum + mapPhaseProgressToOverall(item.phase, item.progress, item.status)
|
||
}, 0) / bulkProgressBatch.sourceIds.length
|
||
)
|
||
: runningBuiltInSources.length > 0
|
||
? Math.round(
|
||
runningBuiltInSources.reduce((sum, source) => {
|
||
const trackedTask = taskProgress[source.id]
|
||
return sum + mapPhaseProgressToOverall(
|
||
trackedTask?.phase ?? source.phase ?? 'queued',
|
||
trackedTask?.progress ?? source.progress ?? 0,
|
||
trackedTask?.status ?? (trackedTask?.is_running || source.is_running ? 'running' : source.last_status ?? null),
|
||
)
|
||
}, 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) => {
|
||
if (message.type !== 'data_frame' || message.channel !== 'datasource_tasks' || !message.payload?.datasource_id) {
|
||
return
|
||
}
|
||
|
||
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,
|
||
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
|
||
})
|
||
|
||
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.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()
|
||
}
|
||
}, [fetchData])
|
||
|
||
const { connected: taskSocketConnected } = useWebSocket({
|
||
autoConnect: true,
|
||
autoSubscribe: ['datasource_tasks'],
|
||
onMessage: handleTaskSocketMessage,
|
||
})
|
||
|
||
useEffect(() => {
|
||
fetchData()
|
||
}, [fetchData])
|
||
|
||
useEffect(() => {
|
||
const updateHeights = () => {
|
||
const builtinRegionHeight = builtinTableRegionRef.current?.offsetHeight || 0
|
||
const customRegionHeight = customTableRegionRef.current?.offsetHeight || 0
|
||
|
||
setBuiltinTableHeight(Math.max(220, builtinRegionHeight - 56))
|
||
setCustomTableHeight(Math.max(220, customRegionHeight - 56))
|
||
}
|
||
|
||
updateHeights()
|
||
|
||
if (typeof ResizeObserver === 'undefined') {
|
||
return undefined
|
||
}
|
||
|
||
const observer = new ResizeObserver(updateHeights)
|
||
if (builtinTableRegionRef.current) observer.observe(builtinTableRegionRef.current)
|
||
if (customTableRegionRef.current) observer.observe(customTableRegionRef.current)
|
||
|
||
return () => observer.disconnect()
|
||
}, [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))
|
||
})
|
||
|
||
if (trackedSources.length === 0) return
|
||
|
||
const interval = setInterval(async () => {
|
||
const updates: Record<number, TaskTrackerState> = {}
|
||
|
||
await Promise.all(
|
||
trackedSources.map(async (source) => {
|
||
const trackedTaskId = taskProgress[source.id]?.task_id ?? source.task_id
|
||
if (!trackedTaskId) return
|
||
|
||
try {
|
||
const res = await axios.get(`/api/v1/datasources/${source.id}/task-status`, {
|
||
params: { task_id: trackedTaskId },
|
||
})
|
||
updates[source.id] = {
|
||
task_id: res.data.task_id ?? trackedTaskId,
|
||
progress: res.data.progress || 0,
|
||
is_running: !!res.data.is_running,
|
||
phase: res.data.phase || null,
|
||
status: res.data.status || null,
|
||
records_processed: res.data.records_processed,
|
||
total_records: res.data.total_records,
|
||
}
|
||
} catch {
|
||
updates[source.id] = {
|
||
task_id: trackedTaskId,
|
||
progress: 0,
|
||
is_running: false,
|
||
phase: 'failed',
|
||
status: 'failed',
|
||
}
|
||
}
|
||
})
|
||
)
|
||
|
||
setTaskProgress((prev) => {
|
||
const next = { ...prev, ...updates }
|
||
for (const [sourceId, state] of Object.entries(updates)) {
|
||
if (!state.is_running && state.status !== 'running') {
|
||
delete next[Number(sourceId)]
|
||
}
|
||
}
|
||
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.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()
|
||
}
|
||
}, 2000)
|
||
|
||
return () => clearInterval(interval)
|
||
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
||
|
||
useEffect(() => {
|
||
if (!bulkProgressBatch) return
|
||
|
||
let changed = false
|
||
const nextItems = { ...bulkProgressBatch.items }
|
||
|
||
for (const sourceId of bulkProgressBatch.sourceIds) {
|
||
const nextItem = resolveTerminalBatchItem(sourceId, bulkProgressBatch, builtInSources, taskProgress)
|
||
if (!nextItem) continue
|
||
|
||
const previousItem = bulkProgressBatch.items[sourceId]
|
||
if (
|
||
previousItem?.status === nextItem.status &&
|
||
previousItem?.is_running === nextItem.is_running &&
|
||
previousItem?.progress === nextItem.progress &&
|
||
previousItem?.phase === nextItem.phase
|
||
) {
|
||
continue
|
||
}
|
||
|
||
nextItems[sourceId] = nextItem
|
||
changed = true
|
||
}
|
||
|
||
if (!changed) return
|
||
|
||
setBulkProgressBatch((prev) => {
|
||
if (!prev) return prev
|
||
return finalizeBulkProgressBatch({
|
||
...prev,
|
||
items: nextItems,
|
||
})
|
||
})
|
||
}, [bulkProgressBatch, builtInSources, taskProgress])
|
||
|
||
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
||
const force = options?.force ?? false
|
||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
||
params: { force },
|
||
validateStatus: (status) => status < 500,
|
||
})
|
||
|
||
if (res.status >= 400) {
|
||
return {
|
||
ok: false,
|
||
status: res.status,
|
||
detail: res.data?.detail,
|
||
} satisfies TriggerDatasourceResult
|
||
}
|
||
|
||
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()
|
||
return {
|
||
ok: true,
|
||
response: res,
|
||
} satisfies TriggerDatasourceResult
|
||
}
|
||
|
||
const fetchDatasourceTaskStatus = async (id: number) => {
|
||
const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`)
|
||
return res.data
|
||
}
|
||
|
||
const confirmForceTrigger = (id: number, taskInfo?: {
|
||
progress?: number | null
|
||
phase?: string | null
|
||
records_processed?: number | null
|
||
total_records?: number | null
|
||
message?: string
|
||
}) => {
|
||
const progressText = typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知'
|
||
const phaseText = taskInfo?.phase || 'running'
|
||
const processedText = typeof taskInfo?.records_processed === 'number'
|
||
? `${taskInfo.records_processed}${typeof taskInfo?.total_records === 'number' && taskInfo.total_records > 0 ? ` / ${taskInfo.total_records}` : ''}`
|
||
: '未知'
|
||
|
||
modal.confirm({
|
||
title: '当前任务未完成',
|
||
content: (
|
||
<div>
|
||
<p>{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}</p>
|
||
<p>当前阶段: {phaseText}</p>
|
||
<p>当前进度: {progressText}</p>
|
||
<p>已处理记录: {processedText}</p>
|
||
<p>确认后会强制取消当前采集,并回滚未完成写入,然后重新开始采集。</p>
|
||
</div>
|
||
),
|
||
okText: '强制重新采集',
|
||
cancelText: '取消',
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
const result = await triggerDatasource(id, { force: true })
|
||
if (result.ok) {
|
||
messageApi.success('已强制重新触发,未完成采集将回滚')
|
||
return
|
||
}
|
||
|
||
const detail = result.detail
|
||
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败')
|
||
},
|
||
})
|
||
}
|
||
|
||
const triggerDatasourceWithPrecheck = async (
|
||
id: number,
|
||
options?: {
|
||
successMessage: string
|
||
onSuccess?: () => void
|
||
errorMessage?: string
|
||
},
|
||
) => {
|
||
const taskStatus = await fetchDatasourceTaskStatus(id)
|
||
if (taskStatus.is_running) {
|
||
confirmForceTrigger(id, {
|
||
message: '当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?',
|
||
progress: taskStatus.progress ?? null,
|
||
phase: taskStatus.phase ?? null,
|
||
records_processed: taskStatus.records_processed ?? null,
|
||
total_records: taskStatus.total_records ?? null,
|
||
})
|
||
return
|
||
}
|
||
|
||
const result = await triggerDatasource(id)
|
||
if (result.ok) {
|
||
messageApi.success(options?.successMessage || '任务已触发')
|
||
options?.onSuccess?.()
|
||
return
|
||
}
|
||
|
||
const detail = result.detail
|
||
if (result.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') {
|
||
confirmForceTrigger(id, detail)
|
||
return
|
||
}
|
||
messageApi.error(typeof detail === 'string' ? detail : detail?.message || options?.errorMessage || '触发失败')
|
||
}
|
||
|
||
const handleTrigger = async (id: number) => {
|
||
await triggerDatasourceWithPrecheck(id, {
|
||
successMessage: '任务已触发',
|
||
errorMessage: '触发失败',
|
||
})
|
||
}
|
||
|
||
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) {
|
||
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) {
|
||
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)
|
||
|
||
messageApi.success(summaryParts.join(','))
|
||
fetchData()
|
||
} catch (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string } } }
|
||
messageApi.error(err.response?.data?.detail || '全触发失败')
|
||
} finally {
|
||
setTriggerAllLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleToggle = async (id: number, current: boolean) => {
|
||
const endpoint = current ? 'disable' : 'enable'
|
||
try {
|
||
await axios.post(`/api/v1/datasources/${id}/${endpoint}`)
|
||
messageApi.success(`${current ? '已禁用' : '已启用'}`)
|
||
fetchData()
|
||
} catch (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string } } }
|
||
messageApi.error(err.response?.data?.detail || '操作失败')
|
||
}
|
||
}
|
||
|
||
const handleClearDataFromDrawer = async () => {
|
||
if (!viewingSource) return
|
||
try {
|
||
const res = await axios.delete(`/api/v1/datasources/${viewingSource.id}/data`)
|
||
messageApi.success(res.data.message || '数据已删除')
|
||
setViewDrawerVisible(false)
|
||
fetchData()
|
||
} catch (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string } } }
|
||
messageApi.error(err.response?.data?.detail || '删除数据失败')
|
||
}
|
||
}
|
||
|
||
const handleViewSource = async (source: BuiltInDataSource) => {
|
||
try {
|
||
const [res, statsRes] = await Promise.all([
|
||
axios.get(`/api/v1/datasources/${source.id}`),
|
||
axios.get(`/api/v1/datasources/${source.id}/stats`)
|
||
])
|
||
const data = res.data
|
||
setViewingSource({
|
||
id: data.id,
|
||
name: data.name,
|
||
description: null,
|
||
source_type: data.collector_class,
|
||
endpoint: data.endpoint || '',
|
||
auth_type: 'none',
|
||
headers: {},
|
||
config: {},
|
||
collector_class: data.collector_class,
|
||
module: data.module,
|
||
priority: data.priority,
|
||
frequency: data.frequency,
|
||
})
|
||
setRecordCount(statsRes.data.total_records || 0)
|
||
setViewDrawerVisible(true)
|
||
} catch (error) {
|
||
messageApi.error('获取数据源信息失败')
|
||
}
|
||
}
|
||
|
||
const handleUpdateSource = async () => {
|
||
if (!viewingSource) return
|
||
await triggerDatasourceWithPrecheck(viewingSource.id, {
|
||
successMessage: '已触发更新',
|
||
errorMessage: '更新失败',
|
||
onSuccess: () => {
|
||
setViewDrawerVisible(false)
|
||
},
|
||
})
|
||
}
|
||
|
||
const handleTest = async () => {
|
||
try {
|
||
const values = await form.validateFields()
|
||
setTesting(true)
|
||
setTestResult(null)
|
||
const res = await axios.post('/api/v1/datasources/configs/test', values)
|
||
setTestResult(res.data)
|
||
if (res.data.success) {
|
||
messageApi.success('连接测试成功')
|
||
} else {
|
||
messageApi.error('连接测试失败')
|
||
}
|
||
} catch (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||
messageApi.error(err.response?.data?.message || err.response?.data?.detail || '测试失败')
|
||
} finally {
|
||
setTesting(false)
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const values = await form.validateFields()
|
||
if (editingConfig) {
|
||
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
|
||
messageApi.success('配置已更新')
|
||
} else {
|
||
await axios.post('/api/v1/datasources/configs', values)
|
||
messageApi.success('配置已创建')
|
||
}
|
||
setDrawerVisible(false)
|
||
form.resetFields()
|
||
setEditingConfig(null)
|
||
setTestResult(null)
|
||
fetchData()
|
||
} catch (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||
messageApi.error(err.response?.data?.message || err.response?.data?.detail || '保存失败')
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await axios.delete(`/api/v1/datasources/configs/${id}`)
|
||
messageApi.success('配置已删除')
|
||
fetchData()
|
||
} catch (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string } } }
|
||
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 })
|
||
messageApi.success(`${current ? '已禁用' : '已启用'}`)
|
||
fetchData()
|
||
} catch (error: unknown) {
|
||
const err = error as { response?: { data?: { detail?: string } } }
|
||
messageApi.error(err.response?.data?.detail || '操作失败')
|
||
}
|
||
}
|
||
|
||
const openDrawer = (config?: CustomDataSource) => {
|
||
setEditingConfig(config || null)
|
||
if (config) {
|
||
form.setFieldsValue({
|
||
...config,
|
||
auth_config: {},
|
||
})
|
||
} else {
|
||
form.resetFields()
|
||
form.setFieldsValue({
|
||
source_type: 'http',
|
||
auth_type: 'none',
|
||
config: { timeout: 30, retry: 3 },
|
||
headers: {},
|
||
})
|
||
}
|
||
setDrawerVisible(true)
|
||
setTestResult(null)
|
||
}
|
||
|
||
const handleCopyLink = async (value: string, successText: string) => {
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value)
|
||
} else {
|
||
const textArea = document.createElement('textarea')
|
||
textArea.value = value
|
||
textArea.style.position = 'fixed'
|
||
textArea.style.opacity = '0'
|
||
document.body.appendChild(textArea)
|
||
textArea.focus()
|
||
textArea.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(textArea)
|
||
}
|
||
messageApi.success(successText)
|
||
} catch {
|
||
messageApi.error('复制失败,请手动复制')
|
||
}
|
||
}
|
||
|
||
const builtinColumns = [
|
||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const },
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
width: 180,
|
||
ellipsis: true,
|
||
render: (name: string, record: BuiltInDataSource) => (
|
||
<Button type="link" onClick={() => handleViewSource(record)}>
|
||
{name}
|
||
</Button>
|
||
),
|
||
},
|
||
{ title: '模块', dataIndex: 'module', key: 'module', width: 80 },
|
||
{
|
||
title: '优先级',
|
||
dataIndex: 'priority',
|
||
key: 'priority',
|
||
width: 80,
|
||
render: (p: string) => <Tag color={p === 'P0' ? 'red' : 'orange'}>{p}</Tag>,
|
||
},
|
||
{ title: '频率', dataIndex: 'frequency', key: 'frequency', width: 80 },
|
||
{
|
||
title: '最近采集',
|
||
dataIndex: 'last_run',
|
||
key: 'last_run',
|
||
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: '状态',
|
||
dataIndex: 'is_active',
|
||
key: 'is_active',
|
||
width: 180,
|
||
render: (_: unknown, record: BuiltInDataSource) => {
|
||
const taskState = taskProgress[record.id]
|
||
const isTaskRunning = taskState?.is_running || record.is_running
|
||
|
||
const phaseLabelMap: Record<string, string> = {
|
||
queued: '排队中',
|
||
fetching: '抓取中',
|
||
transforming: '处理中',
|
||
saving: '保存中',
|
||
completed: '已完成',
|
||
failed: '失败',
|
||
}
|
||
|
||
if (isTaskRunning) {
|
||
const pct = taskState?.progress ?? record.progress ?? 0
|
||
const phase = taskState?.phase || record.phase || 'queued'
|
||
return (
|
||
<Space size={6} wrap>
|
||
<Tag color="processing">
|
||
{phaseLabelMap[phase] || phase}
|
||
{pct > 0 ? ` ${Math.round(pct)}%` : ''}
|
||
</Tag>
|
||
</Space>
|
||
)
|
||
}
|
||
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>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 200,
|
||
fixed: 'right' as const,
|
||
render: (_: unknown, record: BuiltInDataSource) => (
|
||
<Space size="small">
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<SyncOutlined />}
|
||
disabled={!record.is_active}
|
||
onClick={() => handleTrigger(record.id)}
|
||
>
|
||
触发
|
||
</Button>
|
||
<Button
|
||
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 ? '禁用' : '启用'}
|
||
</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
const customColumns = [
|
||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const },
|
||
{ title: '名称', dataIndex: 'name', key: 'name', width: 150, ellipsis: true },
|
||
{ title: '类型', dataIndex: 'source_type', key: 'source_type', width: 100 },
|
||
{
|
||
title: 'API链接',
|
||
dataIndex: 'endpoint',
|
||
key: 'endpoint',
|
||
width: 280,
|
||
ellipsis: true,
|
||
render: (endpoint: string) => (
|
||
endpoint ? (
|
||
<Tooltip title={endpoint}>
|
||
<a href={endpoint} target="_blank" rel="noreferrer">
|
||
{endpoint}
|
||
</a>
|
||
</Tooltip>
|
||
) : '-'
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'is_active',
|
||
key: 'is_active',
|
||
width: 80,
|
||
render: (active: boolean) => (
|
||
<Tag color={active ? 'green' : 'red'}>{active ? '启用' : '禁用'}</Tag>
|
||
),
|
||
},
|
||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 150,
|
||
fixed: 'right' as const,
|
||
render: (_: unknown, record: CustomDataSource) => (
|
||
<Space size="small">
|
||
<Tooltip title="编辑">
|
||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)} />
|
||
</Tooltip>
|
||
<Tooltip title={record.is_active ? '禁用' : '启用'}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||
/>
|
||
</Tooltip>
|
||
<Popconfirm
|
||
title="确定删除此配置?"
|
||
onConfirm={() => handleDelete(record.id)}
|
||
>
|
||
<Tooltip title="删除">
|
||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||
</Tooltip>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
const tabItems = [
|
||
{
|
||
key: 'builtin',
|
||
label: '内置数据源',
|
||
children: (
|
||
<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">
|
||
<div className="data-source-bulk-toolbar__stat-pill">
|
||
<span className="data-source-bulk-toolbar__stat-label">内置</span>
|
||
<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>
|
||
<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}
|
||
dataSource={builtInSources}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={false}
|
||
scroll={{ x: 800, y: builtinTableHeight }}
|
||
tableLayout="fixed"
|
||
size="small"
|
||
/>
|
||
<ScrollbarOverlay containerRef={builtinTableRegionRef} targetSelector=".ant-table-body" />
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'custom',
|
||
label: (
|
||
<span>
|
||
<ApiOutlined /> 自定义数据源
|
||
</span>
|
||
),
|
||
children: (
|
||
<div className="page-shell__body data-source-custom-tab">
|
||
<div className="data-source-custom-toolbar">
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||
添加数据源
|
||
</Button>
|
||
</div>
|
||
{customSources.length === 0 ? (
|
||
<div className="data-source-empty-state">
|
||
<Empty description="暂无自定义数据源" />
|
||
</div>
|
||
) : (
|
||
<div ref={customTableRegionRef} className="table-scroll-region data-source-table-region">
|
||
<Table
|
||
columns={customColumns}
|
||
dataSource={customSources}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={false}
|
||
scroll={{ x: 900, y: customTableHeight }}
|
||
tableLayout="fixed"
|
||
size="small"
|
||
/>
|
||
<ScrollbarOverlay containerRef={customTableRegionRef} targetSelector=".ant-table-body" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
|
||
return (
|
||
<AppLayout>
|
||
{contextHolder}
|
||
{modalContextHolder}
|
||
<div className="page-shell">
|
||
<div className="page-shell__header">
|
||
<h2 style={{ margin: 0 }}>数据源管理</h2>
|
||
</div>
|
||
<div className="page-shell__body">
|
||
<div className="data-source-tabs-shell">
|
||
<Tabs className="data-source-tabs" activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Drawer
|
||
title={editingConfig ? '编辑数据源' : '添加数据源'}
|
||
width={600}
|
||
open={drawerVisible}
|
||
onClose={() => {
|
||
setDrawerVisible(false)
|
||
form.resetFields()
|
||
setEditingConfig(null)
|
||
setTestResult(null)
|
||
}}
|
||
footer={
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<Button
|
||
icon={<ExperimentOutlined />}
|
||
loading={testing}
|
||
onClick={handleTest}
|
||
>
|
||
测试连接
|
||
</Button>
|
||
<Space>
|
||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||
<Button type="primary" onClick={handleSave}>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="name"
|
||
label="名称"
|
||
rules={[{ required: true, message: '请输入名称' }]}
|
||
>
|
||
<Input placeholder="My API Data Source" />
|
||
</Form.Item>
|
||
|
||
<Form.Item name="description" label="描述">
|
||
<Input.TextArea rows={2} placeholder="数据源描述" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="source_type"
|
||
label="数据源类型"
|
||
rules={[{ required: true, message: '请选择类型' }]}
|
||
>
|
||
<Select>
|
||
<Select.Option value="http">HTTP API</Select.Option>
|
||
<Select.Option value="api">REST API</Select.Option>
|
||
<Select.Option value="database">数据库</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="endpoint"
|
||
label="接口地址"
|
||
rules={[{ required: true, message: '请输入接口地址' }]}
|
||
>
|
||
<Input placeholder="https://api.example.com/data" />
|
||
</Form.Item>
|
||
|
||
<Collapse
|
||
items={[
|
||
{
|
||
key: 'auth',
|
||
label: '认证配置',
|
||
children: (
|
||
<>
|
||
<Form.Item name="auth_type" label="认证方式">
|
||
<Select>
|
||
<Select.Option value="none">无</Select.Option>
|
||
<Select.Option value="bearer">Bearer Token</Select.Option>
|
||
<Select.Option value="api_key">API Key</Select.Option>
|
||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
<div>
|
||
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'bearer'}>
|
||
{({ getFieldValue }) => {
|
||
if (getFieldValue('auth_type') === 'bearer') {
|
||
return (
|
||
<Form.Item name={['auth_config', 'token']} label="Token">
|
||
<Input.Password placeholder="Bearer Token" />
|
||
</Form.Item>
|
||
)
|
||
}
|
||
return null
|
||
}}
|
||
</Form.Item>
|
||
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'api_key'}>
|
||
{({ getFieldValue }) => {
|
||
if (getFieldValue('auth_type') === 'api_key') {
|
||
return (
|
||
<>
|
||
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
|
||
<Input placeholder="X-API-Key" />
|
||
</Form.Item>
|
||
<Form.Item name={['auth_config', 'api_key']} label="API Key">
|
||
<Input.Password placeholder="API Key" />
|
||
</Form.Item>
|
||
</>
|
||
)
|
||
}
|
||
return null
|
||
}}
|
||
</Form.Item>
|
||
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'basic'}>
|
||
{({ getFieldValue }) => {
|
||
if (getFieldValue('auth_type') === 'basic') {
|
||
return (
|
||
<>
|
||
<Form.Item name={['auth_config', 'username']} label="用户名">
|
||
<Input placeholder="Username" />
|
||
</Form.Item>
|
||
<Form.Item name={['auth_config', 'password']} label="密码">
|
||
<Input.Password placeholder="Password" />
|
||
</Form.Item>
|
||
</>
|
||
)
|
||
}
|
||
return null
|
||
}}
|
||
</Form.Item>
|
||
</div>
|
||
</>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Collapse
|
||
items={[
|
||
{
|
||
key: 'headers',
|
||
label: '请求头',
|
||
children: (
|
||
<Form.List name="headers">
|
||
{(fields, { add, remove }) => (
|
||
<>
|
||
{fields.map(({ key, name, ...restField }) => (
|
||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||
<Form.Item {...restField} name={[name, 'key']} rules={[{ required: true, message: 'Header键' }]}>
|
||
<Input placeholder="Content-Type" />
|
||
</Form.Item>
|
||
<Form.Item {...restField} name={[name, 'value']} rules={[{ required: true, message: 'Header值' }]}>
|
||
<Input placeholder="application/json" />
|
||
</Form.Item>
|
||
<Button type="link" danger onClick={() => remove(name)}>删除</Button>
|
||
</Space>
|
||
))}
|
||
<Button type="dashed" onClick={() => add()} block>
|
||
添加请求头
|
||
</Button>
|
||
</>
|
||
)}
|
||
</Form.List>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Collapse
|
||
items={[
|
||
{
|
||
key: 'config',
|
||
label: '高级配置',
|
||
children: (
|
||
<>
|
||
<Form.Item name={['config', 'timeout']} label="超时时间(秒)">
|
||
<InputNumber min={1} max={300} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name={['config', 'retry']} label="重试次数">
|
||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{testResult && (
|
||
<div style={{
|
||
marginTop: 16,
|
||
padding: 16,
|
||
background: testResult.success ? '#f6ffed' : '#fff2f0',
|
||
border: `1px solid ${testResult.success ? '#b7eb8f' : '#ffa39e'}`,
|
||
borderRadius: 4,
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||
{testResult.success ? (
|
||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 18 }} />
|
||
) : (
|
||
<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: 18 }} />
|
||
)}
|
||
<span style={{ fontWeight: 'bold' }}>
|
||
{testResult.success ? '连接成功' : '连接失败'}
|
||
</span>
|
||
</div>
|
||
{testResult.status_code && <div>状态码: {testResult.status_code}</div>}
|
||
{testResult.response_time_ms && <div>响应时间: {testResult.response_time_ms.toFixed(0)}ms</div>}
|
||
{testResult.error && <div style={{ color: '#ff4d4f' }}>错误: {testResult.error}</div>}
|
||
{testResult.data_preview && (
|
||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||
预览: {testResult.data_preview}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Form>
|
||
</Drawer>
|
||
|
||
<Drawer
|
||
title="查看数据源"
|
||
width={600}
|
||
open={viewDrawerVisible}
|
||
onClose={() => {
|
||
setViewDrawerVisible(false)
|
||
setViewingSource(null)
|
||
}}
|
||
footer={
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<Popconfirm
|
||
title={`确定删除"${viewingSource?.name}"的所有数据?`}
|
||
onConfirm={handleClearDataFromDrawer}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<Button danger icon={<ClearOutlined />}>
|
||
删除数据
|
||
</Button>
|
||
</Popconfirm>
|
||
<Space>
|
||
<Button
|
||
icon={<ExperimentOutlined />}
|
||
loading={testing}
|
||
onClick={handleTest}
|
||
>
|
||
测试连接
|
||
</Button>
|
||
<Button onClick={() => setViewDrawerVisible(false)}>关闭</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<SyncOutlined />}
|
||
onClick={handleUpdateSource}
|
||
>
|
||
更新
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
}
|
||
>
|
||
{viewingSource && (
|
||
<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 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>
|
||
|
||
<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>
|
||
)
|
||
}
|
||
|
||
export default DataSources
|