381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
/**
|
||
* 任务管理页面。
|
||
*
|
||
* 三个 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<string, string> = {
|
||
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<QueuedTask[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
/* WebSocket 日志流状态 */
|
||
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
|
||
const [logLines, setLogLines] = useState<string[]>([]);
|
||
const [logTaskId, setLogTaskId] = useState<string | null>(null);
|
||
const [wsConnected, setWsConnected] = useState(false);
|
||
const wsRef = useRef<WebSocket | null>(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<QueuedTask> = [
|
||
{
|
||
title: '任务', dataIndex: ['config', 'tasks'], key: 'tasks',
|
||
render: (tasks: string[]) => (
|
||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: tasks?.join(', ') }}>
|
||
{tasks?.join(', ') ?? '—'}
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
title: 'Flow', dataIndex: ['config', 'flow'], key: 'flow', width: 120,
|
||
render: (v: string) => <Tag>{v}</Tag>,
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||
},
|
||
{ 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 (
|
||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||
</Popconfirm>
|
||
);
|
||
}
|
||
if (record.status === 'running') {
|
||
return (
|
||
<Space size={4}>
|
||
<Button
|
||
type="link" icon={<FileTextOutlined />} size="small"
|
||
onClick={() => handleViewLogs(record.id)}
|
||
>
|
||
日志
|
||
</Button>
|
||
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
|
||
<Button type="link" danger icon={<StopOutlined />} size="small">取消</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
);
|
||
}
|
||
return null;
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Text type="secondary">共 {data.length} 个任务</Text>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||
</div>
|
||
<Table<QueuedTask>
|
||
rowKey="id" columns={columns} dataSource={data}
|
||
loading={loading} pagination={false} size="small"
|
||
locale={{ emptyText: <Empty description="队列为空" /> }}
|
||
/>
|
||
|
||
{/* 实时日志抽屉 */}
|
||
<Drawer
|
||
title={
|
||
<Space>
|
||
<FileTextOutlined />
|
||
<span>执行日志</span>
|
||
{wsConnected
|
||
? <Tag color="processing">实时连接中</Tag>
|
||
: <Tag>未连接</Tag>}
|
||
</Space>
|
||
}
|
||
open={logDrawerOpen}
|
||
onClose={handleCloseLogDrawer}
|
||
width={720}
|
||
styles={{ body: { padding: 12, display: 'flex', flexDirection: 'column', height: '100%' } }}
|
||
>
|
||
{logTaskId && (
|
||
<div style={{ flex: 1, minHeight: 0 }}>
|
||
<LogStream executionId={logTaskId} lines={logLines} />
|
||
</div>
|
||
)}
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 历史 Tab */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const HistoryTab: React.FC = () => {
|
||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||
const [historyLogLines, setHistoryLogLines] = useState<string[]>([]);
|
||
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<ExecutionLog> = [
|
||
{
|
||
title: '任务', dataIndex: 'task_codes', key: 'task_codes',
|
||
render: (codes: string[]) => (
|
||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: codes?.join(', ') }}>
|
||
{codes?.join(', ') ?? '—'}
|
||
</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||
},
|
||
{ 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 ? (
|
||
<Tag color={v === 0 ? 'success' : 'error'}>{v}</Tag>
|
||
) : '—',
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Text type="secondary">最近 {data.length} 条记录</Text>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||
</div>
|
||
<Table<ExecutionLog>
|
||
rowKey="id" columns={columns} dataSource={data}
|
||
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||
size="small"
|
||
onRow={(record) => ({ onClick: () => handleRowClick(record), style: { cursor: 'pointer' } })}
|
||
/>
|
||
|
||
<Drawer
|
||
title="执行详情" open={!!detail} onClose={() => setDetail(null)}
|
||
width={720}
|
||
styles={{ body: { padding: 12 } }}
|
||
>
|
||
{detail && (
|
||
<>
|
||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="任务">{detail.task_codes?.join(', ')}</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={STATUS_COLOR[detail.status] ?? 'default'}>{detail.status}</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="开始时间">{fmtTime(detail.started_at)}</Descriptions.Item>
|
||
<Descriptions.Item label="结束时间">{fmtTime(detail.finished_at)}</Descriptions.Item>
|
||
<Descriptions.Item label="时长">{fmtDuration(detail.duration_ms)}</Descriptions.Item>
|
||
<Descriptions.Item label="退出码">
|
||
{detail.exit_code != null ? (
|
||
<Tag color={detail.exit_code === 0 ? 'success' : 'error'}>{detail.exit_code}</Tag>
|
||
) : '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="命令">
|
||
<code style={{ wordBreak: 'break-all', fontSize: 12 }}>{detail.command || '—'}</code>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* 历史日志展示 */}
|
||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<FileTextOutlined />
|
||
<Text strong>执行日志</Text>
|
||
{logLoading && <Spin size="small" />}
|
||
</div>
|
||
<div style={{ height: 400 }}>
|
||
<LogStream executionId={detail.id} lines={historyLogLines} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 主组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const TaskManager: React.FC = () => {
|
||
const items = [
|
||
{
|
||
key: 'queue',
|
||
label: <Space><UnorderedListOutlined />队列</Space>,
|
||
children: <QueueTab />,
|
||
},
|
||
{
|
||
key: 'schedule',
|
||
label: <Space><ClockCircleOutlined />调度</Space>,
|
||
children: <ScheduleTab />,
|
||
},
|
||
{
|
||
key: 'history',
|
||
label: <Space><HistoryOutlined />历史</Space>,
|
||
children: <HistoryTab />,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Title level={4} style={{ marginBottom: 16 }}>
|
||
<UnorderedListOutlined style={{ marginRight: 8 }} />
|
||
任务管理
|
||
</Title>
|
||
<Tabs defaultActiveKey="queue" items={items} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TaskManager;
|