微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

View File

@@ -29,6 +29,7 @@ apps/admin-web/
│ │ ├── EnvConfig.tsx # 环境配置管理
│ │ └── OpsPanel.tsx # 运维面板(服务启停 + Git + 系统监控)
│ ├── components/ # 可复用组件
│ │ ├── BusinessDayHint.tsx # 营业日提示组件
│ │ ├── DwdTableSelector.tsx # DWD 表选择器
│ │ ├── ErrorBoundary.tsx # 错误边界
│ │ ├── LogStream.tsx # 实时日志流组件
@@ -36,6 +37,7 @@ apps/admin-web/
│ │ └── TaskSelector.tsx # 任务选择器
│ ├── api/ # API 调用层
│ │ ├── client.ts # Axios 实例baseURL + JWT 拦截器)
│ │ ├── businessDay.ts # 营业日配置 API
│ │ ├── tasks.ts # 任务配置 API
│ │ ├── execution.ts # 任务执行 API
│ │ ├── schedules.ts # 调度管理 API
@@ -44,7 +46,8 @@ apps/admin-web/
│ │ ├── envConfig.ts # 环境配置 API
│ │ └── opsPanel.ts # 运维面板 API
│ ├── store/
│ │ ── authStore.ts # Zustand 认证状态JWT 持久化 + hydrate
│ │ ── authStore.ts # Zustand 认证状态JWT 持久化 + hydrate
│ │ └── businessDayStore.ts # 营业日状态管理
│ └── types/ # TypeScript 类型定义
├── index.html # HTML 入口
├── vite.config.ts # Vite 配置
@@ -72,13 +75,13 @@ ETL 任务的核心配置界面:
- 选择执行流程7 种 Flow
- 勾选要执行的任务(按业务域分组)
- 设置处理模式(增量/校验/全窗口)
- 配置时间窗口参数
- 配置时间窗口参数(含营业日提示)
- 实时预览生成的 CLI 命令
- 一键执行或加入队列
### 任务管理 (`/task-manager`)
- 查看执行队列(拖拽排序、删除、取消)
- 执行历史列表(状态、耗时、退出码)
- 执行历史列表(状态、耗时、退出码、终止操作
- 实时日志流WebSocket 推送)
### ETL 状态 (`/etl-status`)
@@ -121,11 +124,17 @@ ETL 任务的核心配置界面:
## 状态管理
使用 Zustand 管理全局认证状态:
使用 Zustand 管理全局状态:
**认证状态** (`authStore`)
- `isAuthenticated`:是否已登录
- `token` / `refreshToken`JWT 令牌
- `login()` / `logout()` / `hydrate()`:状态操作
**营业日状态** (`businessDayStore`)
- `businessDayStartHour`:营业日分割点
- `init()` / `refresh()`:配置获取与刷新
## 与后端的关系
管理后台通过 REST API 与 `apps/backend/` 通信:

View File

@@ -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 }} />;

View 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([]);
});
});

View 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;
}

View File

@@ -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;
}

View 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;

View File

@@ -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>

View 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;

View File

@@ -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)}
/>
</>
);
};

View 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;

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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 } }}
>

View File

@@ -0,0 +1,41 @@
/**
* 营业日配置全局状态 — Zustand store。
*
* - 启动时请求一次 /api/config/business-day
* - API 不可用时降级为默认值 8console.warn 输出警告
*/
import { create } from "zustand";
import { fetchBusinessDayConfig } from "../api/businessDay";
const DEFAULT_START_HOUR = 8;
export interface BusinessDayState {
/** 营业日分割点小时值023 */
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 });
}
},
}));

View File

@@ -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;
}
/** 调度任务 */

View 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));
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/flowlayers.test.ts","./src/__tests__/logfilter.test.ts","./src/api/client.ts","./src/api/dbviewer.ts","./src/api/envconfig.ts","./src/api/etlstatus.ts","./src/api/execution.ts","./src/api/schedules.ts","./src/api/tasks.ts","./src/components/dwdtableselector.tsx","./src/components/errorboundary.tsx","./src/components/logstream.tsx","./src/components/scheduletab.tsx","./src/components/taskselector.tsx","./src/pages/dbviewer.tsx","./src/pages/etlstatus.tsx","./src/pages/envconfig.tsx","./src/pages/logviewer.tsx","./src/pages/login.tsx","./src/pages/taskconfig.tsx","./src/pages/taskmanager.tsx","./src/store/authstore.ts","./src/types/index.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/flowlayers.test.ts","./src/__tests__/logfilter.test.ts","./src/__tests__/tasklogparser.test.ts","./src/api/businessday.ts","./src/api/client.ts","./src/api/dbviewer.ts","./src/api/envconfig.ts","./src/api/etlstatus.ts","./src/api/execution.ts","./src/api/opspanel.ts","./src/api/schedules.ts","./src/api/tasks.ts","./src/components/businessdayhint.tsx","./src/components/dwdtableselector.tsx","./src/components/errorboundary.tsx","./src/components/logstream.tsx","./src/components/schedulehistorydrawer.tsx","./src/components/scheduletab.tsx","./src/components/tasklogviewer.tsx","./src/components/taskselector.tsx","./src/pages/dbviewer.tsx","./src/pages/etlstatus.tsx","./src/pages/envconfig.tsx","./src/pages/logviewer.tsx","./src/pages/login.tsx","./src/pages/opspanel.tsx","./src/pages/taskconfig.tsx","./src/pages/taskmanager.tsx","./src/store/authstore.ts","./src/store/businessdaystore.ts","./src/types/index.ts","./src/utils/tasklogparser.ts"],"version":"5.8.3"}