在前后端开发联调前 的提交20260223

This commit is contained in:
Neo
2026-02-23 23:02:20 +08:00
parent 254ccb1e77
commit fafc95e64c
1142 changed files with 10366960 additions and 36957 deletions

View File

@@ -2,22 +2,27 @@
* 任务管理页面。
*
* 三个 Tab队列、调度、历史
* 队列 Tabrunning 状态的任务可点击查看实时 WebSocket 日志流
* 历史 Tab点击记录可查看执行详情和历史日志
*/
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
Typography, Descriptions, Empty,
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;
@@ -61,6 +66,13 @@ 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()); }
@@ -70,6 +82,51 @@ const QueueTab: React.FC = () => {
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('删除失败'); }
@@ -90,7 +147,7 @@ const QueueTab: React.FC = () => {
),
},
{
title: 'Flow', dataIndex: ['config', 'pipeline'], key: 'pipeline', width: 120,
title: 'Flow', dataIndex: ['config', 'flow'], key: 'flow', width: 120,
render: (v: string) => <Tag>{v}</Tag>,
},
{
@@ -100,7 +157,7 @@ const QueueTab: React.FC = () => {
{ title: '位置', dataIndex: 'position', key: 'position', width: 60, align: 'center' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: fmtTime },
{
title: '操作', key: 'action', width: 100, align: 'center',
title: '操作', key: 'action', width: 160, align: 'center',
render: (_: unknown, record: QueuedTask) => {
if (record.status === 'pending') {
return (
@@ -111,9 +168,17 @@ const QueueTab: React.FC = () => {
}
if (record.status === 'running') {
return (
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
<Button type="link" danger icon={<StopOutlined />} size="small"></Button>
</Popconfirm>
<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;
@@ -132,6 +197,29 @@ const QueueTab: React.FC = () => {
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>
</>
);
};
@@ -144,6 +232,8 @@ 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);
@@ -154,6 +244,28 @@ const HistoryTab: React.FC = () => {
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',
@@ -187,31 +299,44 @@ const HistoryTab: React.FC = () => {
rowKey="id" columns={columns} dataSource={data}
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
size="small"
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
onRow={(record) => ({ onClick: () => handleRowClick(record), style: { cursor: 'pointer' } })}
/>
<Drawer
title="执行详情" open={!!detail} onClose={() => setDetail(null)}
width={520}
width={720}
styles={{ body: { padding: 12 } }}
>
{detail && (
<Descriptions column={1} bordered size="small">
<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>
<>
<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>
</>