Files
planet/frontend/src/pages/DataSources/DataSources.tsx
2026-04-14 17:34:04 +08:00

1508 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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