微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
import { useBusinessDayStore } from "./store/businessDayStore";
|
||||
import { fetchQueue } from "./api/execution";
|
||||
import type { QueuedTask } from "./types";
|
||||
import Login from "./pages/Login";
|
||||
@@ -179,12 +180,15 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
const App: React.FC = () => {
|
||||
const hydrate = useAuthStore((s) => s.hydrate);
|
||||
const initBusinessDay = useBusinessDayStore((s) => s.init);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
setHydrated(true);
|
||||
}, [hydrate]);
|
||||
// 启动时请求一次营业日配置,降级策略在 store 内部处理
|
||||
initBusinessDay();
|
||||
}, [hydrate, initBusinessDay]);
|
||||
|
||||
/* hydrate 完成前不渲染路由,避免 PrivateRoute 误判跳转到 /login */
|
||||
if (!hydrated) return <Spin style={{ display: "flex", justifyContent: "center", marginTop: 120 }} />;
|
||||
|
||||
170
apps/admin-web/src/__tests__/taskLogParser.test.ts
Normal file
170
apps/admin-web/src/__tests__/taskLogParser.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 任务日志解析与分组测试
|
||||
*
|
||||
* **Validates: Requirements 10.2, 10.5, 10.6**
|
||||
*
|
||||
* 验证 parseLogLine / groupLogsByTask / filterTaskGroups 的正确性。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseLogLine,
|
||||
groupLogsByTask,
|
||||
filterTaskGroups,
|
||||
} from "../utils/taskLogParser";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* parseLogLine */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("parseLogLine — 单行日志解析", () => {
|
||||
it("解析标准格式日志行", () => {
|
||||
const line = "[ODS_MEMBER] 2024-06-01 12:00:00 INFO 开始拉取会员数据";
|
||||
const result = parseLogLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.taskCode).toBe("ODS_MEMBER");
|
||||
expect(result!.timestamp).toBe("2024-06-01 12:00:00");
|
||||
expect(result!.level).toBe("INFO");
|
||||
expect(result!.message).toBe("开始拉取会员数据");
|
||||
});
|
||||
|
||||
it("解析带毫秒的时间戳", () => {
|
||||
const line = "[ODS_ORDER] 2024-06-01 12:00:00,123 ERROR 请求失败";
|
||||
const result = parseLogLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.taskCode).toBe("ODS_ORDER");
|
||||
expect(result!.timestamp).toBe("2024-06-01 12:00:00,123");
|
||||
expect(result!.level).toBe("ERROR");
|
||||
});
|
||||
|
||||
it("解析 WARNING 级别", () => {
|
||||
const line = "[DWD_LOAD_FROM_ODS] 2024-06-01 12:00:00 WARNING 队列积压";
|
||||
const result = parseLogLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.level).toBe("WARNING");
|
||||
});
|
||||
|
||||
it("解析 DEBUG 级别", () => {
|
||||
const line = "[ODS_MEMBER] 2024-06-01 12:00:00 DEBUG 调试信息";
|
||||
const result = parseLogLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.level).toBe("DEBUG");
|
||||
});
|
||||
|
||||
it("无法解析的行返回 null", () => {
|
||||
expect(parseLogLine("这是一行普通文本")).toBeNull();
|
||||
expect(parseLogLine("")).toBeNull();
|
||||
expect(parseLogLine("[lowercase] 2024-06-01 12:00:00 INFO msg")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* groupLogsByTask */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("groupLogsByTask — 按任务分组", () => {
|
||||
const sampleLines = [
|
||||
"[ODS_MEMBER] 2024-06-01 12:00:00 INFO 开始拉取",
|
||||
"[ODS_MEMBER] 2024-06-01 12:00:05 INFO 拉取完成",
|
||||
"[ODS_ORDER] 2024-06-01 12:00:10 INFO 开始拉取订单",
|
||||
"[ODS_ORDER] 2024-06-01 12:00:20 ERROR 请求超时",
|
||||
"[ODS_MEMBER] 2024-06-01 12:00:25 INFO 写入完成 success",
|
||||
];
|
||||
|
||||
it("正确分组到不同任务", () => {
|
||||
const groups = groupLogsByTask(sampleLines);
|
||||
expect(groups).toHaveLength(2);
|
||||
const memberGroup = groups.find((g) => g.taskCode === "ODS_MEMBER");
|
||||
const orderGroup = groups.find((g) => g.taskCode === "ODS_ORDER");
|
||||
expect(memberGroup).toBeDefined();
|
||||
expect(orderGroup).toBeDefined();
|
||||
expect(memberGroup!.entries).toHaveLength(3);
|
||||
expect(orderGroup!.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("提取正确的开始/结束时间", () => {
|
||||
const groups = groupLogsByTask(sampleLines);
|
||||
const memberGroup = groups.find((g) => g.taskCode === "ODS_MEMBER")!;
|
||||
expect(memberGroup.startTime).toBe("2024-06-01 12:00:00");
|
||||
expect(memberGroup.endTime).toBe("2024-06-01 12:00:25");
|
||||
});
|
||||
|
||||
it("有 ERROR 的任务状态为 failed", () => {
|
||||
const groups = groupLogsByTask(sampleLines);
|
||||
const orderGroup = groups.find((g) => g.taskCode === "ODS_ORDER")!;
|
||||
expect(orderGroup.status).toBe("failed");
|
||||
expect(orderGroup.counts.error).toBe(1);
|
||||
});
|
||||
|
||||
it("最后一条含 success 的任务状态为 success", () => {
|
||||
const groups = groupLogsByTask(sampleLines);
|
||||
const memberGroup = groups.find((g) => g.taskCode === "ODS_MEMBER")!;
|
||||
expect(memberGroup.status).toBe("success");
|
||||
});
|
||||
|
||||
it("空行被忽略", () => {
|
||||
const groups = groupLogsByTask(["", " ", "[ODS_A] 2024-01-01 00:00:00 INFO ok"]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].taskCode).toBe("ODS_A");
|
||||
});
|
||||
|
||||
it("无法解析的行归入 _UNKNOWN", () => {
|
||||
const groups = groupLogsByTask(["普通文本行", "另一行"]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].taskCode).toBe("_UNKNOWN");
|
||||
expect(groups[0].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("空数组返回空分组", () => {
|
||||
expect(groupLogsByTask([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("日志计数正确", () => {
|
||||
const lines = [
|
||||
"[T1] 2024-01-01 00:00:00 INFO a",
|
||||
"[T1] 2024-01-01 00:00:01 WARNING b",
|
||||
"[T1] 2024-01-01 00:00:02 ERROR c",
|
||||
"[T1] 2024-01-01 00:00:03 DEBUG d",
|
||||
"[T1] 2024-01-01 00:00:04 INFO e",
|
||||
];
|
||||
const groups = groupLogsByTask(lines);
|
||||
expect(groups[0].counts).toEqual({ info: 2, warning: 1, error: 1, debug: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* filterTaskGroups */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("filterTaskGroups — 按任务代码过滤", () => {
|
||||
const lines = [
|
||||
"[ODS_MEMBER] 2024-06-01 12:00:00 INFO a",
|
||||
"[ODS_ORDER] 2024-06-01 12:00:01 INFO b",
|
||||
"[DWD_LOAD_FROM_ODS] 2024-06-01 12:00:02 INFO c",
|
||||
];
|
||||
|
||||
it("空关键词返回全部", () => {
|
||||
const groups = groupLogsByTask(lines);
|
||||
expect(filterTaskGroups(groups, "")).toHaveLength(3);
|
||||
expect(filterTaskGroups(groups, " ")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("精确匹配任务代码", () => {
|
||||
const groups = groupLogsByTask(lines);
|
||||
const filtered = filterTaskGroups(groups, "ODS_MEMBER");
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].taskCode).toBe("ODS_MEMBER");
|
||||
});
|
||||
|
||||
it("部分匹配(大小写不敏感)", () => {
|
||||
const groups = groupLogsByTask(lines);
|
||||
const filtered = filterTaskGroups(groups, "ods");
|
||||
// "ODS_MEMBER", "ODS_ORDER", "DWD_LOAD_FROM_ODS" 都包含 "ods"
|
||||
expect(filtered).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("无匹配返回空数组", () => {
|
||||
const groups = groupLogsByTask(lines);
|
||||
expect(filterTaskGroups(groups, "NONEXISTENT")).toEqual([]);
|
||||
});
|
||||
});
|
||||
17
apps/admin-web/src/api/businessDay.ts
Normal file
17
apps/admin-web/src/api/businessDay.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 营业日配置 API。
|
||||
*
|
||||
* 从后端 /api/config/business-day 获取营业日分割点小时值。
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface BusinessDayConfig {
|
||||
business_day_start_hour: number;
|
||||
}
|
||||
|
||||
/** 获取营业日分割点配置 */
|
||||
export async function fetchBusinessDayConfig(): Promise<BusinessDayConfig> {
|
||||
const { data } = await apiClient.get<BusinessDayConfig>("/config/business-day");
|
||||
return data;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { ScheduledTask, ScheduleConfig, TaskConfig } from '../types';
|
||||
import type { ScheduledTask, ScheduleConfig, TaskConfig, ExecutionLog } from '../types';
|
||||
|
||||
/** 获取调度任务列表 */
|
||||
export async function fetchSchedules(): Promise<ScheduledTask[]> {
|
||||
@@ -17,6 +17,7 @@ export async function createSchedule(payload: {
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
run_immediately?: boolean;
|
||||
}): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.post<ScheduledTask>('/schedules', payload);
|
||||
return data;
|
||||
@@ -46,3 +47,21 @@ export async function toggleSchedule(id: string): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.patch<ScheduledTask>(`/schedules/${id}/toggle`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 手动执行调度任务一次(不更新调度间隔) */
|
||||
export async function runScheduleNow(id: string): Promise<{ message: string; task_id: string }> {
|
||||
const { data } = await apiClient.post<{ message: string; task_id: string }>(`/schedules/${id}/run`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取调度任务的执行历史 */
|
||||
export async function fetchScheduleHistory(
|
||||
id: string,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
): Promise<ExecutionLog[]> {
|
||||
const { data } = await apiClient.get<ExecutionLog[]>(`/schedules/${id}/history`, {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
29
apps/admin-web/src/components/BusinessDayHint.tsx
Normal file
29
apps/admin-web/src/components/BusinessDayHint.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 营业日口径提示组件。
|
||||
*
|
||||
* 在日期选择器旁显示 Tooltip + 文字标注,说明当前营业日分割点。
|
||||
* 例如:「营业日:08:00 起」
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Tooltip, Typography } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useBusinessDayStore } from "../store/businessDayStore";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const BusinessDayHint: React.FC = () => {
|
||||
const startHour = useBusinessDayStore((s) => s.startHour);
|
||||
const hh = String(startHour).padStart(2, "0");
|
||||
|
||||
return (
|
||||
<Tooltip title={`统计日期按营业日口径划分:每天 ${hh}:00 至次日 ${hh}:00`}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 2 }} />
|
||||
营业日:{hh}:00 起
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessDayHint;
|
||||
@@ -64,11 +64,16 @@ const LogStream: React.FC<LogStreamProps> = ({ lines }) => {
|
||||
{lines.length === 0 ? (
|
||||
<div style={{ color: "#888" }}>暂无日志</div>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
lines.map((line, i) => {
|
||||
let color = "#d4d4d4";
|
||||
if (/\bERROR\b/i.test(line)) color = "#f56c6c";
|
||||
else if (/\bWARN(?:ING)?\b/i.test(line)) color = "#e6a23c";
|
||||
return (
|
||||
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", color }}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
226
apps/admin-web/src/components/ScheduleHistoryDrawer.tsx
Normal file
226
apps/admin-web/src/components/ScheduleHistoryDrawer.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 调度任务执行历史抽屉组件。
|
||||
*
|
||||
* - 表格展示执行记录(ID、状态、开始时间、耗时、退出码),50 条/页分页
|
||||
* - 点击行打开详情(复用 LogStream 展示日志,running 任务走 WebSocket)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Drawer, Table, Tag, Typography, Descriptions, Spin, Space, message,
|
||||
} from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ExecutionLog } from '../types';
|
||||
import { fetchScheduleHistory } from '../api/schedules';
|
||||
import { apiClient } from '../api/client';
|
||||
import LogStream from './LogStream';
|
||||
|
||||
const { 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`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
scheduleId: string | null;
|
||||
scheduleName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ScheduleHistoryDrawer: React.FC<Props> = ({ open, scheduleId, scheduleName, onClose }) => {
|
||||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const closeWs = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
setWsConnected(false);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
if (!scheduleId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchScheduleHistory(scheduleId, p, 50));
|
||||
} catch {
|
||||
message.error('加载执行历史失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scheduleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && scheduleId) {
|
||||
setPage(1);
|
||||
setDetail(null);
|
||||
load(1);
|
||||
}
|
||||
return () => { closeWs(); };
|
||||
}, [open, scheduleId, load, closeWs]);
|
||||
|
||||
const handleRowClick = useCallback(async (record: ExecutionLog) => {
|
||||
setDetail(record);
|
||||
setLogLines([]);
|
||||
setLogLoading(true);
|
||||
closeWs();
|
||||
|
||||
if (record.status === 'running') {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${record.id}`);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => { setWsConnected(true); setLogLoading(false); };
|
||||
ws.onmessage = (event) => { setLogLines((prev) => [...prev, event.data]); };
|
||||
ws.onclose = () => { setWsConnected(false); };
|
||||
ws.onerror = () => {
|
||||
setWsConnected(false);
|
||||
apiClient.get<{ output_log: string | null; error_log: string | null }>(
|
||||
`/execution/${record.id}/logs`,
|
||||
).then(({ data: logData }) => {
|
||||
const parts: string[] = [];
|
||||
if (logData.output_log) parts.push(logData.output_log);
|
||||
if (logData.error_log) parts.push(logData.error_log);
|
||||
setLogLines(parts.join('\n').split('\n').filter(Boolean));
|
||||
}).catch(() => {}).finally(() => setLogLoading(false));
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const { data: logData } = await apiClient.get<{
|
||||
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);
|
||||
setLogLines(parts.join('\n').split('\n').filter(Boolean));
|
||||
} catch { /* 静默 */ }
|
||||
finally { setLogLoading(false); }
|
||||
}
|
||||
}, [closeWs]);
|
||||
|
||||
const columns: ColumnsType<ExecutionLog> = [
|
||||
{
|
||||
title: '执行 ID', dataIndex: 'id', key: 'id', width: 120,
|
||||
render: (id: string) => (
|
||||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||||
{id.slice(0, 8)}…
|
||||
</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 (
|
||||
<Drawer
|
||||
title={`执行历史 — ${scheduleName}`}
|
||||
open={open}
|
||||
onClose={() => { closeWs(); setDetail(null); onClose(); }}
|
||||
width={800}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<Table<ExecutionLog>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 50,
|
||||
onChange: (p) => { setPage(p); load(p); },
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* 执行详情 */}
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<span>执行详情</span>
|
||||
{detail?.status === 'running' && (
|
||||
wsConnected ? <Tag color="processing">实时连接中</Tag> : <Tag>未连接</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={!!detail}
|
||||
onClose={() => { closeWs(); setDetail(null); }}
|
||||
width={700}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="执行 ID">{detail.id}</Descriptions.Item>
|
||||
<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={logLines} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleHistoryDrawer;
|
||||
@@ -10,19 +10,22 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Tag, Button, Switch, Popconfirm, Space, Modal, Form,
|
||||
Input, Select, InputNumber, TimePicker, Checkbox, message,
|
||||
Input, Select, InputNumber, TimePicker, Checkbox, message, Typography,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, EditOutlined, DeleteOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ScheduledTask, ScheduleConfig } from '../types';
|
||||
import {
|
||||
fetchSchedules,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
toggleSchedule,
|
||||
runScheduleNow,
|
||||
} from '../api/schedules';
|
||||
import ScheduleHistoryDrawer from './ScheduleHistoryDrawer';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 & 工具 */
|
||||
@@ -150,6 +153,11 @@ const ScheduleTab: React.FC = () => {
|
||||
const [scheduleType, setScheduleType] = useState<string>('daily');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
/* 执行历史抽屉状态 */
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyScheduleId, setHistoryScheduleId] = useState<string | null>(null);
|
||||
const [historyScheduleName, setHistoryScheduleName] = useState('');
|
||||
|
||||
/* 加载列表 */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -164,25 +172,6 @@ const ScheduleTab: React.FC = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/* 打开创建 Modal */
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
schedule_config: {
|
||||
schedule_type: 'daily',
|
||||
interval_value: 1,
|
||||
interval_unit: 'hours',
|
||||
daily_time: dayjs('04:00', 'HH:mm'),
|
||||
weekly_days: [1],
|
||||
weekly_time: dayjs('04:00', 'HH:mm'),
|
||||
cron_expression: '0 4 * * *',
|
||||
},
|
||||
});
|
||||
setScheduleType('daily');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 打开编辑 Modal */
|
||||
const openEdit = (record: ScheduledTask) => {
|
||||
setEditing(record);
|
||||
@@ -199,13 +188,20 @@ const ScheduleTab: React.FC = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 提交创建/编辑 */
|
||||
/* 打开执行历史 */
|
||||
const openHistory = (record: ScheduledTask) => {
|
||||
setHistoryScheduleId(record.id);
|
||||
setHistoryScheduleName(record.name);
|
||||
setHistoryOpen(true);
|
||||
};
|
||||
|
||||
/* 提交编辑 */
|
||||
const handleSubmit = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// 将 dayjs 对象转为字符串
|
||||
const cfg = { ...values.schedule_config };
|
||||
if (cfg.daily_time && typeof cfg.daily_time !== 'string') {
|
||||
cfg.daily_time = cfg.daily_time.format('HH:mm');
|
||||
@@ -227,47 +223,16 @@ const ScheduleTab: React.FC = () => {
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateSchedule(editing.id, {
|
||||
name: values.name,
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已更新');
|
||||
} else {
|
||||
// 创建时使用默认 task_config(简化实现)
|
||||
await createSchedule({
|
||||
name: values.name,
|
||||
task_codes: [],
|
||||
task_config: {
|
||||
tasks: [],
|
||||
flow: 'api_full',
|
||||
processing_mode: 'increment_only',
|
||||
pipeline_flow: 'FULL',
|
||||
dry_run: false,
|
||||
window_mode: 'lookback',
|
||||
window_start: null,
|
||||
window_end: null,
|
||||
window_split: null,
|
||||
window_split_days: null,
|
||||
lookback_hours: 24,
|
||||
overlap_seconds: 600,
|
||||
fetch_before_verify: false,
|
||||
skip_ods_when_fetch_before_verify: false,
|
||||
ods_use_local_json: false,
|
||||
store_id: null,
|
||||
dwd_only_tables: null,
|
||||
force_full: false,
|
||||
extra_args: {},
|
||||
},
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已创建');
|
||||
}
|
||||
await updateSchedule(editing.id, {
|
||||
name: values.name,
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已更新');
|
||||
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch {
|
||||
// 表单验证失败,不做额外处理
|
||||
// 表单验证失败
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -294,8 +259,29 @@ const ScheduleTab: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* 手动执行一次(不更新调度间隔) */
|
||||
const handleRunNow = async (id: string) => {
|
||||
try {
|
||||
await runScheduleNow(id);
|
||||
message.success('已提交到执行队列');
|
||||
} catch {
|
||||
message.error('执行失败');
|
||||
}
|
||||
};
|
||||
|
||||
/* 表格列定义 */
|
||||
const columns: ColumnsType<ScheduledTask> = [
|
||||
{
|
||||
title: '调度 ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 120,
|
||||
render: (id: string) => (
|
||||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||||
{id.slice(0, 8)}…
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
@@ -338,9 +324,17 @@ const ScheduleTab: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
width: 300,
|
||||
render: (_: unknown, record: ScheduledTask) => (
|
||||
<Space size="small">
|
||||
<Popconfirm title="确认立即执行一次?(不影响调度间隔)" onConfirm={() => handleRunNow(record.id)}>
|
||||
<Button type="link" icon={<PlayCircleOutlined />} size="small">
|
||||
立即执行
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => openHistory(record)}>
|
||||
执行历史
|
||||
</Button>
|
||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
@@ -356,15 +350,11 @@ const ScheduleTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建调度
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
<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<ScheduledTask>
|
||||
@@ -376,9 +366,9 @@ const ScheduleTab: React.FC = () => {
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{/* 创建/编辑 Modal */}
|
||||
{/* 编辑 Modal */}
|
||||
<Modal
|
||||
title={editing ? '编辑调度任务' : '新建调度任务'}
|
||||
title="编辑调度任务"
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
@@ -400,6 +390,14 @@ const ScheduleTab: React.FC = () => {
|
||||
<ScheduleConfigFields scheduleType={scheduleType} />
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 执行历史抽屉 */}
|
||||
<ScheduleHistoryDrawer
|
||||
open={historyOpen}
|
||||
scheduleId={historyScheduleId}
|
||||
scheduleName={historyScheduleName}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
313
apps/admin-web/src/components/TaskLogViewer.tsx
Normal file
313
apps/admin-web/src/components/TaskLogViewer.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 按任务分组的日志展示组件。
|
||||
*
|
||||
* - 顶部:任务执行时间线概览(可点击跳转)
|
||||
* - 中部:按任务代码过滤搜索框
|
||||
* - 主体:Collapse 折叠面板,每个任务一个区块
|
||||
* - 展开后显示该任务的完整日志(时间戳、级别、消息)
|
||||
*
|
||||
* 需求: 10.2, 10.5, 10.6
|
||||
*/
|
||||
|
||||
import React, { useMemo, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
Collapse,
|
||||
Timeline,
|
||||
Tag,
|
||||
Input,
|
||||
Empty,
|
||||
Badge,
|
||||
Space,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
groupLogsByTask,
|
||||
filterTaskGroups,
|
||||
type TaskLogGroup,
|
||||
type ParsedLogEntry,
|
||||
} from "../utils/taskLogParser";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 状态 → 颜色/图标映射 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
TaskLogGroup["status"],
|
||||
{ color: string; icon: React.ReactNode; label: string }
|
||||
> = {
|
||||
success: { color: "green", icon: <CheckCircleOutlined />, label: "成功" },
|
||||
failed: { color: "red", icon: <CloseCircleOutlined />, label: "失败" },
|
||||
running: { color: "blue", icon: <ClockCircleOutlined />, label: "运行中" },
|
||||
unknown: { color: "default", icon: <QuestionCircleOutlined />, label: "未知" },
|
||||
};
|
||||
|
||||
const LEVEL_COLOR: Record<string, string> = {
|
||||
ERROR: "#ff4d4f",
|
||||
CRITICAL: "#ff4d4f",
|
||||
WARNING: "#faad14",
|
||||
INFO: "#52c41a",
|
||||
DEBUG: "#8c8c8c",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 日志级别标签 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LevelTag: React.FC<{ level: string }> = ({ level }) => (
|
||||
<span
|
||||
style={{
|
||||
color: LEVEL_COLOR[level] ?? "#8c8c8c",
|
||||
fontWeight: level === "ERROR" || level === "CRITICAL" ? 600 : 400,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
minWidth: 56,
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 单条日志行 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LogLine: React.FC<{ entry: ParsedLogEntry }> = ({ entry }) => (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
padding: "1px 0",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{entry.timestamp && (
|
||||
<Text type="secondary" style={{ fontSize: 12, marginRight: 8, fontFamily: "monospace" }}>
|
||||
{entry.timestamp}
|
||||
</Text>
|
||||
)}
|
||||
<LevelTag level={entry.level} />
|
||||
<span style={{ marginLeft: 8 }}>{entry.message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 任务面板头部 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskPanelHeader: React.FC<{ group: TaskLogGroup }> = ({ group }) => {
|
||||
const cfg = STATUS_CONFIG[group.status];
|
||||
return (
|
||||
<Space size={12}>
|
||||
<Text strong style={{ fontFamily: "monospace" }}>{group.taskCode}</Text>
|
||||
<Tag color={cfg.color} icon={cfg.icon}>{cfg.label}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{group.entries.length} 条日志
|
||||
</Text>
|
||||
{group.counts.error > 0 && (
|
||||
<Badge count={group.counts.error} size="small" title="错误数" />
|
||||
)}
|
||||
{group.counts.warning > 0 && (
|
||||
<Badge
|
||||
count={group.counts.warning}
|
||||
size="small"
|
||||
color="#faad14"
|
||||
title="警告数"
|
||||
/>
|
||||
)}
|
||||
{group.startTime && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{group.startTime}
|
||||
{group.endTime && group.endTime !== group.startTime
|
||||
? ` → ${group.endTime}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 时间线概览 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskTimeline: React.FC<{
|
||||
groups: TaskLogGroup[];
|
||||
onClickTask: (taskCode: string) => void;
|
||||
}> = ({ groups, onClickTask }) => {
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
const items = groups.map((g) => {
|
||||
const cfg = STATUS_CONFIG[g.status];
|
||||
return {
|
||||
color: cfg.color === "default" ? "gray" : cfg.color,
|
||||
children: (
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onClickTask(g.taskCode)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onClickTask(g.taskCode);
|
||||
}}
|
||||
>
|
||||
<Space size={8}>
|
||||
<Text strong style={{ fontFamily: "monospace", fontSize: 13 }}>
|
||||
{g.taskCode}
|
||||
</Text>
|
||||
<Tag color={cfg.color} style={{ fontSize: 11 }}>
|
||||
{cfg.label}
|
||||
</Tag>
|
||||
{g.startTime && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{g.startTime}
|
||||
{g.endTime && g.endTime !== g.startTime
|
||||
? ` → ${g.endTime}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
({g.entries.length} 条)
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12, padding: "8px 12px", background: "#fafafa", borderRadius: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: "block" }}>
|
||||
任务执行时间线(点击跳转)
|
||||
</Text>
|
||||
<Timeline items={items} style={{ marginBottom: 0, paddingTop: 8 }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface TaskLogViewerProps {
|
||||
/** 原始日志行数组 */
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const TaskLogViewer: React.FC<TaskLogViewerProps> = ({ lines }) => {
|
||||
const [taskFilter, setTaskFilter] = useState("");
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>([]);
|
||||
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// 解析并分组
|
||||
const allGroups = useMemo(() => groupLogsByTask(lines), [lines]);
|
||||
const filteredGroups = useMemo(
|
||||
() => filterTaskGroups(allGroups, taskFilter),
|
||||
[allGroups, taskFilter],
|
||||
);
|
||||
|
||||
// 点击时间线跳转到对应任务面板
|
||||
const handleTimelineClick = useCallback((taskCode: string) => {
|
||||
// 展开目标面板
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(taskCode) ? prev : [...prev, taskCode],
|
||||
);
|
||||
// 滚动到面板位置
|
||||
requestAnimationFrame(() => {
|
||||
const el = panelRefs.current[taskCode];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setPanelRef = useCallback(
|
||||
(taskCode: string) => (el: HTMLDivElement | null) => {
|
||||
panelRefs.current[taskCode] = el;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <Empty description="暂无日志数据" />;
|
||||
}
|
||||
|
||||
// 如果没有可解析的任务分组(全是无法解析的行),回退到普通展示
|
||||
if (allGroups.length === 0) {
|
||||
return <Empty description="无法解析任务分组" />;
|
||||
}
|
||||
|
||||
const collapseItems = filteredGroups.map((group) => ({
|
||||
key: group.taskCode,
|
||||
label: <TaskPanelHeader group={group} />,
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflow: "auto",
|
||||
background: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{group.entries.map((entry, i) => (
|
||||
<LogLine key={i} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 时间线概览 */}
|
||||
<TaskTimeline groups={allGroups} onClickTask={handleTimelineClick} />
|
||||
|
||||
{/* 任务代码过滤 */}
|
||||
{allGroups.length > 1 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="按任务代码过滤..."
|
||||
value={taskFilter}
|
||||
onChange={(e) => setTaskFilter(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
|
||||
{filteredGroups.length} / {allGroups.length} 个任务
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 折叠面板 */}
|
||||
{filteredGroups.length === 0 ? (
|
||||
<Empty description="无匹配的任务" />
|
||||
) : (
|
||||
<>
|
||||
{filteredGroups.map((g) => (
|
||||
<div key={g.taskCode} ref={setPanelRef(g.taskCode)} />
|
||||
))}
|
||||
<Collapse
|
||||
activeKey={activeKeys}
|
||||
onChange={(keys) => setActiveKeys(keys as string[])}
|
||||
items={collapseItems}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskLogViewer;
|
||||
@@ -7,13 +7,15 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Input, Button, Space, message, Card, Typography, Tag, Badge } from "antd";
|
||||
import { Input, Button, Space, message, Card, Typography, Tag, Badge, Segmented } from "antd";
|
||||
import {
|
||||
LinkOutlined, DisconnectOutlined, HistoryOutlined,
|
||||
FileTextOutlined, SearchOutlined, ClearOutlined,
|
||||
AppstoreOutlined, UnorderedListOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { apiClient } from "../api/client";
|
||||
import LogStream from "../components/LogStream";
|
||||
import TaskLogViewer from "../components/TaskLogViewer";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -36,6 +38,8 @@ const LogViewer: React.FC = () => {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [filterKeyword, setFilterKeyword] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
/** 展示模式:raw = 原始流,grouped = 按任务分组 */
|
||||
const [viewMode, setViewMode] = useState<"raw" | "grouped">("grouped");
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,20 +120,37 @@ const LogViewer: React.FC = () => {
|
||||
<Button icon={<HistoryOutlined />} onClick={handleLoadHistory}>加载历史</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleClear} disabled={lines.length === 0}>清空</Button>
|
||||
</Space>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="过滤关键词..."
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Space>
|
||||
<Segmented
|
||||
value={viewMode}
|
||||
onChange={(v) => setViewMode(v as "raw" | "grouped")}
|
||||
options={[
|
||||
{ value: "grouped", icon: <AppstoreOutlined />, label: "按任务" },
|
||||
{ value: "raw", icon: <UnorderedListOutlined />, label: "原始" },
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
{viewMode === "raw" && (
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="过滤关键词..."
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 日志流 */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<LogStream executionId={executionId} lines={filteredLines} />
|
||||
{/* 日志展示区域 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
{viewMode === "grouped" ? (
|
||||
<TaskLogViewer lines={lines} />
|
||||
) : (
|
||||
<LogStream executionId={executionId} lines={filteredLines} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
Tooltip,
|
||||
Segmented,
|
||||
Spin,
|
||||
Modal,
|
||||
Form,
|
||||
Select,
|
||||
TimePicker,
|
||||
Descriptions,
|
||||
Tag,
|
||||
} from "antd";
|
||||
import {
|
||||
SendOutlined,
|
||||
@@ -35,16 +41,20 @@ import {
|
||||
SyncOutlined,
|
||||
ShopOutlined,
|
||||
ApiOutlined,
|
||||
ScheduleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import TaskSelector from "../components/TaskSelector";
|
||||
import { validateTaskConfig, fetchFlows } from "../api/tasks";
|
||||
import type { FlowDef, ProcessingModeDef } from "../api/tasks";
|
||||
import { submitToQueue, executeDirectly } from "../api/execution";
|
||||
import { createSchedule } from "../api/schedules";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import BusinessDayHint from "../components/BusinessDayHint";
|
||||
import type { RadioChangeEvent } from "antd";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import type { TaskConfig as TaskConfigType } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
import type { TaskConfig as TaskConfigType, ScheduleConfig } from "../types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -224,6 +234,12 @@ const TaskConfig: React.FC = () => {
|
||||
const [forceFull, setForceFull] = useState(false);
|
||||
const [useLocalJson, setUseLocalJson] = useState(false);
|
||||
|
||||
/* ---------- Pipeline 调优 ---------- */
|
||||
const [pipelineWorkers, setPipelineWorkers] = useState<number | null>(null);
|
||||
const [pipelineBatchSize, setPipelineBatchSize] = useState<number | null>(null);
|
||||
const [pipelineRateMin, setPipelineRateMin] = useState<number | null>(null);
|
||||
const [pipelineRateMax, setPipelineRateMax] = useState<number | null>(null);
|
||||
|
||||
/* ---------- CLI 预览 ---------- */
|
||||
const [cliCommand, setCliCommand] = useState<string>("");
|
||||
const [cliEdited, setCliEdited] = useState(false);
|
||||
@@ -232,6 +248,12 @@ const TaskConfig: React.FC = () => {
|
||||
/* ---------- 提交状态 ---------- */
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/* ---------- 调度任务弹窗 ---------- */
|
||||
const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
|
||||
const [scheduleSubmitting, setScheduleSubmitting] = useState(false);
|
||||
const [scheduleType, setScheduleType] = useState<string>("daily");
|
||||
const [scheduleForm] = Form.useForm();
|
||||
|
||||
/* ---------- 派生状态 ---------- */
|
||||
const layers = flowDefs[flow]?.layers ?? [];
|
||||
const showVerifyOption = processingMode === "verify_only";
|
||||
@@ -262,6 +284,10 @@ const TaskConfig: React.FC = () => {
|
||||
/* CHANGE [2026-02-19] intent: DWD 表正向勾选,选中=装载 */
|
||||
dwd_only_tables: layers.includes("DWD") ? (selectedDwdTables.length > 0 ? selectedDwdTables : null) : null,
|
||||
force_full: forceFull,
|
||||
pipeline_workers: pipelineWorkers,
|
||||
pipeline_batch_size: pipelineBatchSize,
|
||||
pipeline_rate_min: pipelineRateMin,
|
||||
pipeline_rate_max: pipelineRateMax,
|
||||
extra_args: {},
|
||||
};
|
||||
};
|
||||
@@ -288,7 +314,8 @@ const TaskConfig: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flow, processingMode, fetchBeforeVerify, windowMode, lookbackHours, overlapSeconds,
|
||||
windowStart, windowEnd, windowSplitDays, selectedTasks, selectedDwdTables,
|
||||
dryRun, forceFull, useLocalJson, selectedConnectorStores]);
|
||||
dryRun, forceFull, useLocalJson, selectedConnectorStores,
|
||||
pipelineWorkers, pipelineBatchSize, pipelineRateMin, pipelineRateMax]);
|
||||
|
||||
/* ---------- 事件处理 ---------- */
|
||||
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
|
||||
@@ -321,6 +348,67 @@ const TaskConfig: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 调度任务弹窗 ---------- */
|
||||
const handleOpenScheduleModal = () => {
|
||||
scheduleForm.resetFields();
|
||||
scheduleForm.setFieldsValue({
|
||||
schedule_config: {
|
||||
schedule_type: "daily",
|
||||
interval_value: 1,
|
||||
interval_unit: "hours",
|
||||
daily_time: dayjs("04:00", "HH:mm"),
|
||||
weekly_days: [1],
|
||||
weekly_time: dayjs("04:00", "HH:mm"),
|
||||
cron_expression: "0 4 * * *",
|
||||
},
|
||||
});
|
||||
setScheduleType("daily");
|
||||
setScheduleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleScheduleSubmit = async () => {
|
||||
try {
|
||||
const values = await scheduleForm.validateFields();
|
||||
setScheduleSubmitting(true);
|
||||
|
||||
const cfg = { ...values.schedule_config };
|
||||
if (cfg.daily_time && typeof cfg.daily_time !== "string") {
|
||||
cfg.daily_time = cfg.daily_time.format("HH:mm");
|
||||
}
|
||||
if (cfg.weekly_time && typeof cfg.weekly_time !== "string") {
|
||||
cfg.weekly_time = cfg.weekly_time.format("HH:mm");
|
||||
}
|
||||
|
||||
const scheduleConfig: ScheduleConfig = {
|
||||
schedule_type: cfg.schedule_type ?? "daily",
|
||||
interval_value: cfg.interval_value ?? 1,
|
||||
interval_unit: cfg.interval_unit ?? "hours",
|
||||
daily_time: cfg.daily_time ?? "04:00",
|
||||
weekly_days: cfg.weekly_days ?? [1],
|
||||
weekly_time: cfg.weekly_time ?? "04:00",
|
||||
cron_expression: cfg.cron_expression ?? "0 4 * * *",
|
||||
enabled: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
const taskConfig = buildTaskConfig();
|
||||
await createSchedule({
|
||||
name: values.name,
|
||||
task_codes: taskConfig.tasks,
|
||||
task_config: taskConfig,
|
||||
schedule_config: scheduleConfig,
|
||||
run_immediately: !!values.run_immediately,
|
||||
});
|
||||
message.success("调度任务已创建");
|
||||
setScheduleModalOpen(false);
|
||||
} catch {
|
||||
// 表单验证失败
|
||||
} finally {
|
||||
setScheduleSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 样式常量 ---------- */
|
||||
const cardStyle = { marginBottom: 12 };
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
@@ -464,6 +552,7 @@ const TaskConfig: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>开始日期</Text>
|
||||
@@ -481,6 +570,10 @@ const TaskConfig: React.FC = () => {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<BusinessDayHint />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
@@ -526,6 +619,65 @@ const TaskConfig: React.FC = () => {
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Pipeline 调优参数 */}
|
||||
<div style={{ marginTop: 12, borderTop: "1px solid #f0f0f0", paddingTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: "block" }}>
|
||||
Pipeline 调优(留空使用默认值)
|
||||
</Text>
|
||||
<Row gutter={[24, 8]}>
|
||||
<Col span={6}>
|
||||
<Text style={{ fontSize: 12 }}>并发 workers</Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={32}
|
||||
placeholder="默认 3"
|
||||
value={pipelineWorkers}
|
||||
onChange={(v) => setPipelineWorkers(v)}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text style={{ fontSize: 12 }}>批量大小</Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={10000}
|
||||
placeholder="默认 200"
|
||||
value={pipelineBatchSize}
|
||||
onChange={(v) => setPipelineBatchSize(v)}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text style={{ fontSize: 12 }}>限流下限(秒)</Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.1}
|
||||
placeholder="默认 1.0"
|
||||
value={pipelineRateMin}
|
||||
onChange={(v) => setPipelineRateMin(v)}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text style={{ fontSize: 12 }}>限流上限(秒)</Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.1}
|
||||
placeholder="默认 3.0"
|
||||
value={pipelineRateMax}
|
||||
onChange={(v) => setPipelineRateMax(v)}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ---- 任务选择(含 DWD 表过滤) ---- */}
|
||||
@@ -587,26 +739,142 @@ const TaskConfig: React.FC = () => {
|
||||
|
||||
{/* ---- 操作按钮 ---- */}
|
||||
<Card size="small" style={{ marginBottom: 24 }}>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SendOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmitToQueue}
|
||||
>
|
||||
提交到队列
|
||||
</Button>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SendOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmitToQueue}
|
||||
>
|
||||
提交到队列
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
icon={<ScheduleOutlined />}
|
||||
onClick={handleOpenScheduleModal}
|
||||
>
|
||||
添加到调度任务
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button
|
||||
size="large"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleExecuteDirectly}
|
||||
>
|
||||
直接执行
|
||||
直接执行!
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ---- 调度任务创建 Modal ---- */}
|
||||
<Modal
|
||||
title="添加到调度任务"
|
||||
open={scheduleModalOpen}
|
||||
onOk={handleScheduleSubmit}
|
||||
onCancel={() => setScheduleModalOpen(false)}
|
||||
confirmLoading={scheduleSubmitting}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
>
|
||||
{/* 当前配置摘要(只读) */}
|
||||
<Descriptions
|
||||
column={1}
|
||||
bordered
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
title="当前任务配置(只读)"
|
||||
>
|
||||
<Descriptions.Item label="任务">
|
||||
{selectedTasks.length > 0
|
||||
? selectedTasks.map((t) => <Tag key={t}>{t}</Tag>)
|
||||
: <Text type="secondary">未选择</Text>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Flow">
|
||||
<Tag>{flow}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="处理模式">{processingMode}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间窗口">
|
||||
{windowMode === "lookback"
|
||||
? `回溯 ${lookbackHours} 小时`
|
||||
: `${windowStart?.format("YYYY-MM-DD") ?? "?"} ~ ${windowEnd?.format("YYYY-MM-DD") ?? "?"}`}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Form form={scheduleForm} layout="vertical" preserve={false}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="调度任务名称"
|
||||
rules={[{ required: true, message: "请输入调度任务名称" }]}
|
||||
>
|
||||
<Input placeholder="例如:每日全量同步" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={["schedule_config", "schedule_type"]}
|
||||
label="调度类型"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "一次性", value: "once" },
|
||||
{ label: "固定间隔", value: "interval" },
|
||||
{ label: "每日", value: "daily" },
|
||||
{ label: "每周", value: "weekly" },
|
||||
{ label: "Cron", value: "cron" },
|
||||
]}
|
||||
onChange={(v: string) => setScheduleType(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{scheduleType === "interval" && (
|
||||
<Space>
|
||||
<Form.Item name={["schedule_config", "interval_value"]} noStyle rules={[{ required: true }]}>
|
||||
<InputNumber min={1} placeholder="间隔值" />
|
||||
</Form.Item>
|
||||
<Form.Item name={["schedule_config", "interval_unit"]} noStyle rules={[{ required: true }]}>
|
||||
<Select style={{ width: 100 }} options={[
|
||||
{ label: "分钟", value: "minutes" },
|
||||
{ label: "小时", value: "hours" },
|
||||
{ label: "天", value: "days" },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
)}
|
||||
{scheduleType === "daily" && (
|
||||
<Form.Item name={["schedule_config", "daily_time"]} label="执行时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{scheduleType === "weekly" && (
|
||||
<>
|
||||
<Form.Item name={["schedule_config", "weekly_days"]} label="星期" rules={[{ required: true }]}>
|
||||
<Checkbox.Group options={[
|
||||
{ label: "周一", value: 1 }, { label: "周二", value: 2 },
|
||||
{ label: "周三", value: 3 }, { label: "周四", value: 4 },
|
||||
{ label: "周五", value: 5 }, { label: "周六", value: 6 },
|
||||
{ label: "周日", value: 0 },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name={["schedule_config", "weekly_time"]} label="执行时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{scheduleType === "cron" && (
|
||||
<Form.Item name={["schedule_config", "cron_expression"]} label="Cron 表达式" rules={[{ required: true }]}>
|
||||
<Input placeholder="0 4 * * *" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="run_immediately" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<Checkbox>创建后立即执行一次</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -138,6 +138,14 @@ const QueueTab: React.FC = () => {
|
||||
};
|
||||
|
||||
const columns: ColumnsType<QueuedTask> = [
|
||||
{
|
||||
title: '任务 ID', dataIndex: 'id', key: 'id', width: 120,
|
||||
render: (id: string) => (
|
||||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||||
{id.slice(0, 8)}…
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '任务', dataIndex: ['config', 'tasks'], key: 'tasks',
|
||||
render: (tasks: string[]) => (
|
||||
@@ -234,6 +242,26 @@ const HistoryTab: React.FC = () => {
|
||||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||||
const [historyLogLines, setHistoryLogLines] = useState<string[]>([]);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const historyWsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
/** 关闭 WebSocket 连接并重置状态 */
|
||||
const closeHistoryWs = useCallback(() => {
|
||||
historyWsRef.current?.close();
|
||||
historyWsRef.current = null;
|
||||
setWsConnected(false);
|
||||
}, []);
|
||||
|
||||
/* 组件卸载时清理 WebSocket */
|
||||
useEffect(() => {
|
||||
return () => { historyWsRef.current?.close(); };
|
||||
}, []);
|
||||
|
||||
const handleCancelHistory = useCallback(async (id: string) => {
|
||||
try { await cancelExecution(id); message.success('已发送终止信号'); load(); }
|
||||
catch { message.error('终止失败'); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -244,29 +272,73 @@ const HistoryTab: React.FC = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/** 点击行时加载详情和日志 */
|
||||
/** 点击行时加载详情和日志;running 任务走 WebSocket 实时流 */
|
||||
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);
|
||||
closeHistoryWs();
|
||||
|
||||
if (record.status === 'running') {
|
||||
// running 任务:通过 WebSocket 实时推送日志
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${record.id}`);
|
||||
historyWsRef.current = ws;
|
||||
|
||||
ws.onopen = () => { setWsConnected(true); setLogLoading(false); };
|
||||
ws.onmessage = (event) => {
|
||||
setHistoryLogLines((prev) => [...prev, event.data]);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setWsConnected(false);
|
||||
// 任务结束后刷新历史列表以更新状态
|
||||
load();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
message.error('WebSocket 连接失败,回退到静态日志');
|
||||
setWsConnected(false);
|
||||
// 回退:用 REST API 拉取已有日志
|
||||
apiClient.get<{
|
||||
execution_id: string;
|
||||
output_log: string | null;
|
||||
error_log: string | null;
|
||||
}>(`/execution/${record.id}/logs`).then(({ data: logData }) => {
|
||||
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));
|
||||
};
|
||||
} else {
|
||||
// 已完成任务:REST API 一次性拉取
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [closeHistoryWs, load]);
|
||||
|
||||
const columns: ColumnsType<ExecutionLog> = [
|
||||
{
|
||||
title: '执行 ID', dataIndex: 'id', key: 'id', width: 120,
|
||||
render: (id: string) => (
|
||||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||||
{id.slice(0, 8)}…
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '任务', dataIndex: 'task_codes', key: 'task_codes',
|
||||
render: (codes: string[]) => (
|
||||
@@ -275,6 +347,12 @@ const HistoryTab: React.FC = () => {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '调度 ID', dataIndex: 'schedule_id', key: 'schedule_id', width: 120,
|
||||
render: (id: string | null) => id
|
||||
? <Text copyable={{ text: id }} style={{ fontSize: 11 }}>{id.slice(0, 8)}…</Text>
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||||
@@ -287,6 +365,21 @@ const HistoryTab: React.FC = () => {
|
||||
<Tag color={v === 0 ? 'success' : 'error'}>{v}</Tag>
|
||||
) : '—',
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 80, align: 'center',
|
||||
render: (_: unknown, record: ExecutionLog) => {
|
||||
if (record.status === 'running') {
|
||||
return (
|
||||
<Popconfirm title="确认终止该任务?" onConfirm={(e) => { e?.stopPropagation(); handleCancelHistory(record.id); }} onCancel={(e) => e?.stopPropagation()}>
|
||||
<Button type="link" danger icon={<StopOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
|
||||
终止
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -303,7 +396,18 @@ const HistoryTab: React.FC = () => {
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="执行详情" open={!!detail} onClose={() => setDetail(null)}
|
||||
title={
|
||||
<Space>
|
||||
<span>执行详情</span>
|
||||
{detail?.status === 'running' && (
|
||||
wsConnected
|
||||
? <Tag color="processing">实时连接中</Tag>
|
||||
: <Tag>未连接</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={!!detail}
|
||||
onClose={() => { closeHistoryWs(); setDetail(null); }}
|
||||
width={720}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
|
||||
41
apps/admin-web/src/store/businessDayStore.ts
Normal file
41
apps/admin-web/src/store/businessDayStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 营业日配置全局状态 — Zustand store。
|
||||
*
|
||||
* - 启动时请求一次 /api/config/business-day
|
||||
* - API 不可用时降级为默认值 8,console.warn 输出警告
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { fetchBusinessDayConfig } from "../api/businessDay";
|
||||
|
||||
const DEFAULT_START_HOUR = 8;
|
||||
|
||||
export interface BusinessDayState {
|
||||
/** 营业日分割点小时值(0–23) */
|
||||
startHour: number;
|
||||
/** 是否已完成加载(含降级) */
|
||||
loaded: boolean;
|
||||
|
||||
/** 启动时调用一次,从后端获取配置 */
|
||||
init: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useBusinessDayStore = create<BusinessDayState>((set, get) => ({
|
||||
startHour: DEFAULT_START_HOUR,
|
||||
loaded: false,
|
||||
|
||||
async init() {
|
||||
// 避免重复请求
|
||||
if (get().loaded) return;
|
||||
try {
|
||||
const config = await fetchBusinessDayConfig();
|
||||
set({ startHour: config.business_day_start_hour, loaded: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
"[BusinessDay] 无法获取营业日配置,使用默认值:",
|
||||
DEFAULT_START_HOUR,
|
||||
);
|
||||
set({ startHour: DEFAULT_START_HOUR, loaded: true });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -32,6 +32,11 @@ export interface TaskConfig {
|
||||
dwd_only_tables: string[] | null;
|
||||
/** 强制全量处理(跳过 hash 去重和变更对比) */
|
||||
force_full: boolean;
|
||||
/** Pipeline 调优参数(可选) */
|
||||
pipeline_workers: number | null;
|
||||
pipeline_batch_size: number | null;
|
||||
pipeline_rate_min: number | null;
|
||||
pipeline_rate_max: number | null;
|
||||
extra_args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -102,6 +107,7 @@ export interface QueuedTask {
|
||||
finished_at: string | null;
|
||||
exit_code: number | null;
|
||||
error_message: string | null;
|
||||
schedule_id: string | null;
|
||||
}
|
||||
|
||||
/** 执行历史记录 */
|
||||
@@ -116,6 +122,7 @@ export interface ExecutionLog {
|
||||
duration_ms: number | null;
|
||||
command: string;
|
||||
summary: Record<string, unknown> | null;
|
||||
schedule_id: string | null;
|
||||
}
|
||||
|
||||
/** 调度任务 */
|
||||
|
||||
130
apps/admin-web/src/utils/taskLogParser.ts
Normal file
130
apps/admin-web/src/utils/taskLogParser.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 任务日志解析与分组工具函数。
|
||||
*
|
||||
* 解析 TaskLogBuffer 输出的日志格式:[TASK_CODE] timestamp level message
|
||||
* 将扁平日志行按任务代码分组,提取时间线信息。
|
||||
*/
|
||||
|
||||
/** 单条解析后的日志条目 */
|
||||
export interface ParsedLogEntry {
|
||||
/** 原始行文本 */
|
||||
raw: string;
|
||||
/** 任务代码(如 ODS_MEMBER) */
|
||||
taskCode: string;
|
||||
/** 时间戳字符串(原始格式) */
|
||||
timestamp: string;
|
||||
/** 日志级别:INFO / WARNING / ERROR / DEBUG */
|
||||
level: string;
|
||||
/** 日志消息正文 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 按任务分组后的日志集合 */
|
||||
export interface TaskLogGroup {
|
||||
taskCode: string;
|
||||
entries: ParsedLogEntry[];
|
||||
/** 该任务第一条日志的时间戳 */
|
||||
startTime: string | null;
|
||||
/** 该任务最后一条日志的时间戳 */
|
||||
endTime: string | null;
|
||||
/** 推断的任务状态:success / failed / running / unknown */
|
||||
status: "success" | "failed" | "running" | "unknown";
|
||||
/** 各级别日志计数 */
|
||||
counts: { info: number; warning: number; error: number; debug: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配 TaskLogBuffer 输出格式:[TASK_CODE] 2024-01-01 12:00:00 INFO 消息内容
|
||||
* 也兼容:[TASK_CODE] 2024-01-01 12:00:00,123 INFO 消息内容
|
||||
*/
|
||||
const LOG_LINE_REGEX =
|
||||
/^\[([A-Z][A-Z0-9_]*)\]\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(.*)$/;
|
||||
|
||||
/**
|
||||
* 解析单行日志。无法解析时返回 null。
|
||||
*/
|
||||
export function parseLogLine(line: string): ParsedLogEntry | null {
|
||||
const match = LOG_LINE_REGEX.exec(line.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
raw: line,
|
||||
taskCode: match[1],
|
||||
timestamp: match[2],
|
||||
level: match[3],
|
||||
message: match[4],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将日志行数组按任务代码分组。
|
||||
* 无法解析的行归入 "_UNKNOWN" 分组。
|
||||
*/
|
||||
export function groupLogsByTask(lines: string[]): TaskLogGroup[] {
|
||||
const groupMap = new Map<string, ParsedLogEntry[]>();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const parsed = parseLogLine(line);
|
||||
const code = parsed?.taskCode ?? "_UNKNOWN";
|
||||
const entry: ParsedLogEntry = parsed ?? {
|
||||
raw: line,
|
||||
taskCode: "_UNKNOWN",
|
||||
timestamp: "",
|
||||
level: "INFO",
|
||||
message: line,
|
||||
};
|
||||
const arr = groupMap.get(code);
|
||||
if (arr) {
|
||||
arr.push(entry);
|
||||
} else {
|
||||
groupMap.set(code, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: TaskLogGroup[] = [];
|
||||
for (const [taskCode, entries] of groupMap) {
|
||||
const counts = { info: 0, warning: 0, error: 0, debug: 0 };
|
||||
for (const e of entries) {
|
||||
const lvl = e.level.toLowerCase();
|
||||
if (lvl === "info") counts.info++;
|
||||
else if (lvl === "warning" || lvl === "critical") counts.warning++;
|
||||
else if (lvl === "error") counts.error++;
|
||||
else if (lvl === "debug") counts.debug++;
|
||||
}
|
||||
|
||||
// 推断状态:有 ERROR 级别 → failed;否则看最后一条消息
|
||||
let status: TaskLogGroup["status"] = "unknown";
|
||||
if (counts.error > 0) {
|
||||
status = "failed";
|
||||
} else if (entries.length > 0) {
|
||||
const lastMsg = entries[entries.length - 1].message.toLowerCase();
|
||||
if (lastMsg.includes("完成") || lastMsg.includes("success") || lastMsg.includes("finished")) {
|
||||
status = "success";
|
||||
}
|
||||
}
|
||||
|
||||
const timestamps = entries.map((e) => e.timestamp).filter(Boolean);
|
||||
groups.push({
|
||||
taskCode,
|
||||
entries,
|
||||
startTime: timestamps[0] ?? null,
|
||||
endTime: timestamps.length > 0 ? timestamps[timestamps.length - 1] : null,
|
||||
status,
|
||||
counts,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按任务代码过滤分组。空关键词返回全部。
|
||||
*/
|
||||
export function filterTaskGroups(
|
||||
groups: TaskLogGroup[],
|
||||
keyword: string,
|
||||
): TaskLogGroup[] {
|
||||
const kw = keyword.trim().toUpperCase();
|
||||
if (!kw) return groups;
|
||||
return groups.filter((g) => g.taskCode.toUpperCase().includes(kw));
|
||||
}
|
||||
Reference in New Issue
Block a user