/** * 任务管理页面。 * * 三个 Tab:队列、调度、历史 * 队列 Tab:running 状态的任务可点击查看实时 WebSocket 日志流 * 历史 Tab:点击记录可查看执行详情和历史日志 */ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer, Typography, Descriptions, Empty, Spin, } from 'antd'; import { ReloadOutlined, DeleteOutlined, StopOutlined, UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined, FileTextOutlined, } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import type { QueuedTask, ExecutionLog } from '../types'; import { fetchQueue, fetchHistory, deleteFromQueue, cancelExecution, } from '../api/execution'; import { apiClient } from '../api/client'; import LogStream from '../components/LogStream'; import ScheduleTab from '../components/ScheduleTab'; const { Title, Text } = Typography; /* ------------------------------------------------------------------ */ /* 状态颜色映射 */ /* ------------------------------------------------------------------ */ const STATUS_COLOR: Record = { pending: 'default', running: 'processing', success: 'success', failed: 'error', cancelled: 'warning', }; /* ------------------------------------------------------------------ */ /* 工具函数 */ /* ------------------------------------------------------------------ */ function fmtTime(iso: string | null | undefined): string { if (!iso) return '—'; return new Date(iso).toLocaleString('zh-CN'); } function fmtDuration(ms: number | null | undefined): string { if (ms == null) return '—'; if (ms < 1000) return `${ms}ms`; const sec = ms / 1000; if (sec < 60) return `${sec.toFixed(1)}s`; const min = Math.floor(sec / 60); const remainSec = Math.round(sec % 60); return `${min}m${remainSec}s`; } /* ------------------------------------------------------------------ */ /* 队列 Tab */ /* ------------------------------------------------------------------ */ const QueueTab: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); /* WebSocket 日志流状态 */ const [logDrawerOpen, setLogDrawerOpen] = useState(false); const [logLines, setLogLines] = useState([]); const [logTaskId, setLogTaskId] = useState(null); const [wsConnected, setWsConnected] = useState(false); const wsRef = useRef(null); const load = useCallback(async () => { setLoading(true); try { setData(await fetchQueue()); } catch { message.error('加载队列失败'); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); /* 自动轮询队列状态(5 秒间隔),保持状态实时 */ useEffect(() => { const timer = setInterval(load, 5_000); return () => clearInterval(timer); }, [load]); /* 组件卸载时关闭 WebSocket */ useEffect(() => { return () => { wsRef.current?.close(); }; }, []); /** 打开日志抽屉并建立 WebSocket 连接 */ const handleViewLogs = useCallback((taskId: string) => { setLogTaskId(taskId); setLogLines([]); setLogDrawerOpen(true); // 关闭旧连接 wsRef.current?.close(); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const ws = new WebSocket(`${protocol}//${host}/ws/logs/${taskId}`); wsRef.current = ws; ws.onopen = () => { setWsConnected(true); }; ws.onmessage = (event) => { setLogLines((prev) => [...prev, event.data]); }; ws.onclose = () => { setWsConnected(false); }; ws.onerror = () => { message.error('WebSocket 连接失败'); setWsConnected(false); }; }, []); /** 关闭日志抽屉 */ const handleCloseLogDrawer = useCallback(() => { setLogDrawerOpen(false); wsRef.current?.close(); wsRef.current = null; setWsConnected(false); setLogTaskId(null); }, []); const handleDelete = async (id: string) => { try { await deleteFromQueue(id); message.success('已删除'); load(); } catch { message.error('删除失败'); } }; const handleCancel = async (id: string) => { try { await cancelExecution(id); message.success('已取消'); load(); } catch { message.error('取消失败'); } }; const columns: ColumnsType = [ { title: '任务', dataIndex: ['config', 'tasks'], key: 'tasks', render: (tasks: string[]) => ( {tasks?.join(', ') ?? '—'} ), }, { title: 'Flow', dataIndex: ['config', 'flow'], key: 'flow', width: 120, render: (v: string) => {v}, }, { title: '状态', dataIndex: 'status', key: 'status', width: 90, render: (s: string) => {s}, }, { title: '位置', dataIndex: 'position', key: 'position', width: 60, align: 'center' }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: fmtTime }, { title: '操作', key: 'action', width: 160, align: 'center', render: (_: unknown, record: QueuedTask) => { if (record.status === 'pending') { return ( handleDelete(record.id)}> ); } if (record.status === 'running') { return ( handleCancel(record.id)}> ); } return null; }, }, ]; return ( <>
共 {data.length} 个任务
rowKey="id" columns={columns} dataSource={data} loading={loading} pagination={false} size="small" locale={{ emptyText: }} /> {/* 实时日志抽屉 */} 执行日志 {wsConnected ? 实时连接中 : 未连接} } open={logDrawerOpen} onClose={handleCloseLogDrawer} width={720} styles={{ body: { padding: 12, display: 'flex', flexDirection: 'column', height: '100%' } }} > {logTaskId && (
)}
); }; /* ------------------------------------------------------------------ */ /* 历史 Tab */ /* ------------------------------------------------------------------ */ const HistoryTab: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [detail, setDetail] = useState(null); const [historyLogLines, setHistoryLogLines] = useState([]); const [logLoading, setLogLoading] = useState(false); const load = useCallback(async () => { setLoading(true); try { setData(await fetchHistory()); } catch { message.error('加载历史记录失败'); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); /** 点击行时加载详情和日志 */ const handleRowClick = useCallback(async (record: ExecutionLog) => { setDetail(record); setHistoryLogLines([]); setLogLoading(true); try { const { data: logData } = await apiClient.get<{ execution_id: string; output_log: string | null; error_log: string | null; }>(`/execution/${record.id}/logs`); const parts: string[] = []; if (logData.output_log) parts.push(logData.output_log); if (logData.error_log) parts.push(logData.error_log); setHistoryLogLines(parts.join('\n').split('\n').filter(Boolean)); } catch { /* 日志可能不存在,静默处理 */ } finally { setLogLoading(false); } }, []); const columns: ColumnsType = [ { title: '任务', dataIndex: 'task_codes', key: 'task_codes', render: (codes: string[]) => ( {codes?.join(', ') ?? '—'} ), }, { title: '状态', dataIndex: 'status', key: 'status', width: 90, render: (s: string) => {s}, }, { title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: fmtTime }, { title: '时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 90, render: fmtDuration }, { title: '退出码', dataIndex: 'exit_code', key: 'exit_code', width: 70, align: 'center', render: (v: number | null) => v != null ? ( {v} ) : '—', }, ]; return ( <>
最近 {data.length} 条记录
rowKey="id" columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }} size="small" onRow={(record) => ({ onClick: () => handleRowClick(record), style: { cursor: 'pointer' } })} /> setDetail(null)} width={720} styles={{ body: { padding: 12 } }} > {detail && ( <> {detail.task_codes?.join(', ')} {detail.status} {fmtTime(detail.started_at)} {fmtTime(detail.finished_at)} {fmtDuration(detail.duration_ms)} {detail.exit_code != null ? ( {detail.exit_code} ) : '—'} {detail.command || '—'} {/* 历史日志展示 */}
执行日志 {logLoading && }
)}
); }; /* ------------------------------------------------------------------ */ /* 主组件 */ /* ------------------------------------------------------------------ */ const TaskManager: React.FC = () => { const items = [ { key: 'queue', label: 队列, children: , }, { key: 'schedule', label: 调度, children: , }, { key: 'history', label: 历史, children: , }, ]; return (
<UnorderedListOutlined style={{ marginRight: 8 }} /> 任务管理
); }; export default TaskManager;