Files
Neo-ZQYY/apps/admin-web/src/pages/TaskManager.tsx

381 lines
13 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.
/**
* 任务管理页面。
*
* 三个 Tab队列、调度、历史
* 队列 Tabrunning 状态的任务可点击查看实时 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;