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

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

View File

@@ -0,0 +1,8 @@
{
"hash": "75f75ae2",
"configHash": "3c6579c7",
"lockfileHash": "4e1d8c76",
"browserHash": "dc64490c",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -17,6 +17,7 @@ import {
DashboardOutlined,
FileTextOutlined,
LogoutOutlined,
DesktopOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { useAuthStore } from "./store/authStore";
@@ -29,6 +30,7 @@ import EnvConfig from "./pages/EnvConfig";
import DBViewer from "./pages/DBViewer";
import ETLStatus from "./pages/ETLStatus";
import LogViewer from "./pages/LogViewer";
import OpsPanel from "./pages/OpsPanel";
const { Sider, Content, Footer } = Layout;
const { Text } = Typography;
@@ -44,6 +46,7 @@ const NAV_ITEMS: MenuProps["items"] = [
{ key: "/db-viewer", icon: <DatabaseOutlined />, label: "数据库" },
{ key: "/log-viewer", icon: <FileTextOutlined />, label: "日志" },
{ key: "/env-config", icon: <ToolOutlined />, label: "环境配置" },
{ key: "/ops-panel", icon: <DesktopOutlined />, label: "运维面板" },
];
/* ------------------------------------------------------------------ */
@@ -140,6 +143,7 @@ const AppLayout: React.FC = () => {
<Route path="/db-viewer" element={<DBViewer />} />
<Route path="/etl-status" element={<ETLStatus />} />
<Route path="/log-viewer" element={<LogViewer />} />
<Route path="/ops-panel" element={<OpsPanel />} />
</Routes>
</Content>
<Footer
@@ -154,7 +158,7 @@ const AppLayout: React.FC = () => {
<Space size={8}>
<Spin size="small" />
<Text></Text>
<Tag color="processing">{runningTask.config.pipeline}</Tag>
<Tag color="processing">{runningTask.config.flow}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{runningTask.config.tasks.slice(0, 3).join(", ")}
{runningTask.config.tasks.length > 3 && ` +${runningTask.config.tasks.length - 3}`}

View File

@@ -0,0 +1,100 @@
/**
* 运维控制面板 API
*
* 对接后端 /api/ops/* 端点提供服务状态、Git 操作、系统信息等。
*/
import { apiClient } from "./client";
// ---- 类型定义 ----
export interface SystemInfo {
cpu_percent: number;
memory_total_gb: number;
memory_used_gb: number;
memory_percent: number;
disk_total_gb: number;
disk_used_gb: number;
disk_percent: number;
boot_time: string;
}
export interface ServiceStatus {
env: string;
label: string;
running: boolean;
pid: number | null;
port: number;
uptime_seconds: number | null;
memory_mb: number | null;
cpu_percent: number | null;
}
export interface GitInfo {
env: string;
branch: string;
last_commit_hash: string;
last_commit_message: string;
last_commit_time: string;
has_local_changes: boolean;
}
export interface ActionResult {
env: string;
action: string;
success: boolean;
message: string;
}
export interface GitPullResult {
env: string;
success: boolean;
output: string;
}
// ---- API 调用 ----
export async function fetchSystemInfo(): Promise<SystemInfo> {
const { data } = await apiClient.get<SystemInfo>("/ops/system");
return data;
}
export async function fetchServicesStatus(): Promise<ServiceStatus[]> {
const { data } = await apiClient.get<ServiceStatus[]>("/ops/services");
return data;
}
export async function fetchGitInfo(): Promise<GitInfo[]> {
const { data } = await apiClient.get<GitInfo[]>("/ops/git");
return data;
}
export async function startService(env: string): Promise<ActionResult> {
const { data } = await apiClient.post<ActionResult>(`/ops/services/${env}/start`);
return data;
}
export async function stopService(env: string): Promise<ActionResult> {
const { data } = await apiClient.post<ActionResult>(`/ops/services/${env}/stop`);
return data;
}
export async function restartService(env: string): Promise<ActionResult> {
const { data } = await apiClient.post<ActionResult>(`/ops/services/${env}/restart`);
return data;
}
export async function gitPull(env: string): Promise<GitPullResult> {
const { data } = await apiClient.post<GitPullResult>(`/ops/git/${env}/pull`);
return data;
}
export async function syncDeps(env: string): Promise<ActionResult> {
const { data } = await apiClient.post<ActionResult>(`/ops/git/${env}/sync-deps`);
return data;
}
export async function fetchEnvFile(env: string): Promise<{ env: string; content: string }> {
const { data } = await apiClient.get<{ env: string; content: string }>(`/ops/env-file/${env}`);
return data;
}

View File

@@ -240,7 +240,7 @@ const ScheduleTab: React.FC = () => {
task_codes: [],
task_config: {
tasks: [],
pipeline: 'api_full',
flow: 'api_full',
processing_mode: 'increment_only',
pipeline_flow: 'FULL',
dry_run: false,

View File

@@ -7,7 +7,7 @@
* 功能:
* - 同步检查:工具栏右侧 Badge 指示,点击展示差异 Modal
* - 全选常用 / 全选 / 反选 / 清空 按钮
* - DWD 表选 = 过滤 DWD_LOAD_FROM_ODS 的装载范围
* - DWD 表选 = 选择要装载的 DWD 表(正向选择,和 ODS/DWS 一致)
*/
import React, { useEffect, useState, useMemo, useCallback } from "react";
@@ -151,6 +151,16 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registry]);
/* CHANGE [2026-02-19] intent: DWD 表正向勾选,加载后默认全选 */
useEffect(() => {
if (!onDwdTablesChange) return;
const allTables = Object.values(dwdTableGroups).flat().map((t) => t.table_name);
if (allTables.length > 0 && selectedDwdTables.length === 0) {
onDwdTablesChange(allTables);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dwdTableGroups]);
const domainGroups = useMemo(
() => buildDomainGroups(registry, dwdTableGroups, layers),
[registry, dwdTableGroups, layers],
@@ -251,9 +261,9 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
<Space size={4}>
<TableOutlined style={{ color: "#52c41a", fontSize: 12 }} />
<Text style={{ fontSize: 12, fontWeight: 500 }}>DWD </Text>
<Text style={{ fontSize: 12, fontWeight: 500 }}>DWD </Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{domainDwdSelected.length === 0 ? "(未选 = 全部装载)" : `${domainDwdSelected.length}/${dwdTables.length}`}
{`${domainDwdSelected.length}/${dwdTables.length}`}
</Text>
</Space>
<Space size={4}>
@@ -370,6 +380,9 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
>
<Text strong style={!t.is_common ? { color: "#999" } : undefined}>{t.code}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{t.name}</Text>
{t.description && (t.layer === "DWS" || t.layer === "INDEX") && (
<Text type="secondary" style={{ marginLeft: 6, fontSize: 10, color: "#8c8c8c" }}>({t.description})</Text>
)}
{!t.is_common && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}></Tag>}
</Checkbox>
</div>

View File

@@ -0,0 +1,365 @@
/**
* 运维控制面板页面
*
* 功能:
* - 服务器系统资源概况CPU / 内存 / 磁盘)
* - 各环境服务状态 + 启停重启按钮
* - 各环境 Git 状态 + pull / 同步依赖按钮
* - 各环境 .env 配置查看(敏感值脱敏)
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Card,
Row,
Col,
Tag,
Button,
Space,
Statistic,
Progress,
Modal,
message,
Descriptions,
Spin,
Tooltip,
Typography,
Input,
} from "antd";
import {
PlayCircleOutlined,
PauseCircleOutlined,
ReloadOutlined,
CloudDownloadOutlined,
SyncOutlined,
FileTextOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
DesktopOutlined,
} from "@ant-design/icons";
import type {
SystemInfo,
ServiceStatus,
GitInfo,
} from "../api/opsPanel";
import {
fetchSystemInfo,
fetchServicesStatus,
fetchGitInfo,
startService,
stopService,
restartService,
gitPull,
syncDeps,
fetchEnvFile,
} from "../api/opsPanel";
const { Text, Title } = Typography;
const { TextArea } = Input;
/* ------------------------------------------------------------------ */
/* 工具函数 */
/* ------------------------------------------------------------------ */
/** 秒数格式化为 "Xd Xh Xm" */
function formatUptime(seconds: number | null): string {
if (seconds == null) return "-";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (d > 0) parts.push(`${d}`);
if (h > 0) parts.push(`${h}`);
parts.push(`${m}`);
return parts.join(" ");
}
/* ------------------------------------------------------------------ */
/* 组件 */
/* ------------------------------------------------------------------ */
const OpsPanel: React.FC = () => {
const [system, setSystem] = useState<SystemInfo | null>(null);
const [services, setServices] = useState<ServiceStatus[]>([]);
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [envModalOpen, setEnvModalOpen] = useState(false);
const [envModalContent, setEnvModalContent] = useState("");
const [envModalTitle, setEnvModalTitle] = useState("");
// ---- 数据加载 ----
const loadAll = useCallback(async () => {
try {
const [sys, svc, git] = await Promise.all([
fetchSystemInfo(),
fetchServicesStatus(),
fetchGitInfo(),
]);
setSystem(sys);
setServices(svc);
setGitInfos(git);
} catch {
message.error("加载运维数据失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAll();
const timer = setInterval(loadAll, 15_000);
return () => clearInterval(timer);
}, [loadAll]);
// ---- 操作处理 ----
const withAction = async (key: string, fn: () => Promise<void>) => {
setActionLoading((prev) => ({ ...prev, [key]: true }));
try {
await fn();
} finally {
setActionLoading((prev) => ({ ...prev, [key]: false }));
}
};
const handleStart = (env: string) =>
withAction(`start-${env}`, async () => {
const r = await startService(env);
r.success ? message.success(r.message) : message.warning(r.message);
await loadAll();
});
const handleStop = (env: string) =>
withAction(`stop-${env}`, async () => {
const r = await stopService(env);
r.success ? message.success(r.message) : message.warning(r.message);
await loadAll();
});
const handleRestart = (env: string) =>
withAction(`restart-${env}`, async () => {
const r = await restartService(env);
r.success ? message.success(r.message) : message.warning(r.message);
await loadAll();
});
const handlePull = (env: string) =>
withAction(`pull-${env}`, async () => {
const r = await gitPull(env);
if (r.success) {
message.success("拉取成功");
Modal.info({ title: `Git Pull - ${env}`, content: <pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>{r.output}</pre>, width: 600 });
} else {
message.error("拉取失败");
Modal.error({ title: `Git Pull 失败 - ${env}`, content: <pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>{r.output}</pre>, width: 600 });
}
await loadAll();
});
const handleSyncDeps = (env: string) =>
withAction(`sync-${env}`, async () => {
const r = await syncDeps(env);
r.success ? message.success("依赖同步完成") : message.error(r.message);
});
const handleViewEnv = async (env: string, label: string) => {
try {
const r = await fetchEnvFile(env);
setEnvModalTitle(`${label} .env 配置`);
setEnvModalContent(r.content);
setEnvModalOpen(true);
} catch {
message.error("读取配置文件失败");
}
};
// ---- 渲染 ----
if (loading) {
return <Spin size="large" style={{ display: "flex", justifyContent: "center", marginTop: 120 }} />;
}
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<DesktopOutlined style={{ marginRight: 8 }} />
</Title>
{/* ---- 系统资源 ---- */}
{system && (
<Card size="small" title="服务器资源" style={{ marginBottom: 16 }}>
<Row gutter={24}>
<Col span={8}>
<Statistic title="CPU 使用率" value={system.cpu_percent} suffix="%" />
<Progress percent={system.cpu_percent} size="small" status={system.cpu_percent > 80 ? "exception" : "normal"} showInfo={false} />
</Col>
<Col span={8}>
<Statistic title="内存" value={system.memory_used_gb} suffix={`/ ${system.memory_total_gb} GB`} precision={1} />
<Progress percent={system.memory_percent} size="small" status={system.memory_percent > 85 ? "exception" : "normal"} showInfo={false} />
</Col>
<Col span={8}>
<Statistic title="磁盘" value={system.disk_used_gb} suffix={`/ ${system.disk_total_gb} GB`} precision={1} />
<Progress percent={system.disk_percent} size="small" status={system.disk_percent > 90 ? "exception" : "normal"} showInfo={false} />
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: "block" }}>
{new Date(system.boot_time).toLocaleString()}
</Text>
</Card>
)}
{/* ---- 服务状态 ---- */}
<Card size="small" title="服务状态" style={{ marginBottom: 16 }}>
<Row gutter={16}>
{services.map((svc) => (
<Col span={12} key={svc.env}>
<Card
size="small"
type="inner"
title={
<Space>
{svc.running
? <CheckCircleOutlined style={{ color: "#52c41a" }} />
: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
{svc.label}
<Tag color={svc.running ? "success" : "error"}>
{svc.running ? "运行中" : "已停止"}
</Tag>
</Space>
}
extra={<Tag>:{svc.port}</Tag>}
>
{svc.running && (
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
<Descriptions.Item label="PID">{svc.pid}</Descriptions.Item>
<Descriptions.Item label="运行时长">
<ClockCircleOutlined style={{ marginRight: 4 }} />
{formatUptime(svc.uptime_seconds)}
</Descriptions.Item>
<Descriptions.Item label="内存">{svc.memory_mb ?? "-"} MB</Descriptions.Item>
</Descriptions>
)}
<Space>
{!svc.running && (
<Button
type="primary"
size="small"
icon={<PlayCircleOutlined />}
loading={actionLoading[`start-${svc.env}`]}
onClick={() => handleStart(svc.env)}
>
</Button>
)}
{svc.running && (
<>
<Button
danger
size="small"
icon={<PauseCircleOutlined />}
loading={actionLoading[`stop-${svc.env}`]}
onClick={() => handleStop(svc.env)}
>
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
loading={actionLoading[`restart-${svc.env}`]}
onClick={() => handleRestart(svc.env)}
>
</Button>
</>
)}
</Space>
</Card>
</Col>
))}
</Row>
</Card>
{/* ---- Git 状态 & 配置 ---- */}
<Card size="small" title="代码与配置" style={{ marginBottom: 16 }}>
<Row gutter={16}>
{gitInfos.map((git) => {
const envCfg = services.find((s) => s.env === git.env);
const label = envCfg?.label ?? git.env;
return (
<Col span={12} key={git.env}>
<Card size="small" type="inner" title={label}>
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
<Descriptions.Item label="分支">
<Tag color="blue">{git.branch}</Tag>
{git.has_local_changes && (
<Tooltip title="工作区有未提交的变更">
<Tag color="warning"></Tag>
</Tooltip>
)}
</Descriptions.Item>
<Descriptions.Item label="最新提交">
<Text code style={{ fontSize: 12 }}>{git.last_commit_hash}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
{git.last_commit_message}
</Text>
</Descriptions.Item>
<Descriptions.Item label="提交时间">
<Text type="secondary" style={{ fontSize: 12 }}>{git.last_commit_time}</Text>
</Descriptions.Item>
</Descriptions>
<Space>
<Button
size="small"
icon={<CloudDownloadOutlined />}
loading={actionLoading[`pull-${git.env}`]}
onClick={() => handlePull(git.env)}
>
Git Pull
</Button>
<Button
size="small"
icon={<SyncOutlined />}
loading={actionLoading[`sync-${git.env}`]}
onClick={() => handleSyncDeps(git.env)}
>
</Button>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleViewEnv(git.env, label)}
>
</Button>
</Space>
</Card>
</Col>
);
})}
</Row>
</Card>
{/* ---- 配置查看弹窗 ---- */}
<Modal
title={envModalTitle}
open={envModalOpen}
onCancel={() => setEnvModalOpen(false)}
footer={null}
width={700}
>
<TextArea
value={envModalContent}
readOnly
autoSize={{ minRows: 10, maxRows: 30 }}
style={{ fontFamily: "monospace", fontSize: 12 }}
/>
</Modal>
</div>
);
};
export default OpsPanel;

View File

@@ -71,6 +71,7 @@ const FALLBACK_PROCESSING_MODES: ProcModeEntry[] = [
{ value: "increment_only", label: "仅增量", desc: "按游标增量抓取和装载" },
{ value: "verify_only", label: "校验并修复", desc: "对比源和目标,修复差异" },
{ value: "increment_verify", label: "增量+校验", desc: "先增量再校验" },
{ value: "full_window", label: "全窗口", desc: "用 API 返回数据的时间范围处理所有层" },
];
/** 将 API 返回的 FlowDef[] 转为 Record<id, FlowEntry> */
@@ -243,7 +244,7 @@ const TaskConfig: React.FC = () => {
: selectedTasks;
return {
tasks,
pipeline: flow,
flow: flow,
processing_mode: processingMode,
pipeline_flow: "FULL",
dry_run: dryRun,
@@ -258,7 +259,8 @@ const TaskConfig: React.FC = () => {
skip_ods_when_fetch_before_verify: false,
ods_use_local_json: useLocalJson,
store_id: effectiveStoreId,
dwd_only_tables: selectedDwdTables.length > 0 ? selectedDwdTables : null,
/* CHANGE [2026-02-19] intent: DWD 表正向勾选,选中=装载 */
dwd_only_tables: layers.includes("DWD") ? (selectedDwdTables.length > 0 ? selectedDwdTables : null) : null,
force_full: forceFull,
extra_args: {},
};

View File

@@ -2,22 +2,27 @@
* 任务管理页面。
*
* 三个 Tab队列、调度、历史
* 队列 Tabrunning 状态的任务可点击查看实时 WebSocket 日志流
* 历史 Tab点击记录可查看执行详情和历史日志
*/
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
Typography, Descriptions, Empty,
Typography, Descriptions, Empty, Spin,
} from 'antd';
import {
ReloadOutlined, DeleteOutlined, StopOutlined,
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { QueuedTask, ExecutionLog } from '../types';
import {
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution,
} from '../api/execution';
import { apiClient } from '../api/client';
import LogStream from '../components/LogStream';
import ScheduleTab from '../components/ScheduleTab';
const { Title, Text } = Typography;
@@ -61,6 +66,13 @@ const QueueTab: React.FC = () => {
const [data, setData] = useState<QueuedTask[]>([]);
const [loading, setLoading] = useState(false);
/* WebSocket 日志流状态 */
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
const [logLines, setLogLines] = useState<string[]>([]);
const [logTaskId, setLogTaskId] = useState<string | null>(null);
const [wsConnected, setWsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const load = useCallback(async () => {
setLoading(true);
try { setData(await fetchQueue()); }
@@ -70,6 +82,51 @@ const QueueTab: React.FC = () => {
useEffect(() => { load(); }, [load]);
/* 自动轮询队列状态5 秒间隔),保持状态实时 */
useEffect(() => {
const timer = setInterval(load, 5_000);
return () => clearInterval(timer);
}, [load]);
/* 组件卸载时关闭 WebSocket */
useEffect(() => {
return () => { wsRef.current?.close(); };
}, []);
/** 打开日志抽屉并建立 WebSocket 连接 */
const handleViewLogs = useCallback((taskId: string) => {
setLogTaskId(taskId);
setLogLines([]);
setLogDrawerOpen(true);
// 关闭旧连接
wsRef.current?.close();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${taskId}`);
wsRef.current = ws;
ws.onopen = () => { setWsConnected(true); };
ws.onmessage = (event) => {
setLogLines((prev) => [...prev, event.data]);
};
ws.onclose = () => { setWsConnected(false); };
ws.onerror = () => {
message.error('WebSocket 连接失败');
setWsConnected(false);
};
}, []);
/** 关闭日志抽屉 */
const handleCloseLogDrawer = useCallback(() => {
setLogDrawerOpen(false);
wsRef.current?.close();
wsRef.current = null;
setWsConnected(false);
setLogTaskId(null);
}, []);
const handleDelete = async (id: string) => {
try { await deleteFromQueue(id); message.success('已删除'); load(); }
catch { message.error('删除失败'); }
@@ -90,7 +147,7 @@ const QueueTab: React.FC = () => {
),
},
{
title: 'Flow', dataIndex: ['config', 'pipeline'], key: 'pipeline', width: 120,
title: 'Flow', dataIndex: ['config', 'flow'], key: 'flow', width: 120,
render: (v: string) => <Tag>{v}</Tag>,
},
{
@@ -100,7 +157,7 @@ const QueueTab: React.FC = () => {
{ title: '位置', dataIndex: 'position', key: 'position', width: 60, align: 'center' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: fmtTime },
{
title: '操作', key: 'action', width: 100, align: 'center',
title: '操作', key: 'action', width: 160, align: 'center',
render: (_: unknown, record: QueuedTask) => {
if (record.status === 'pending') {
return (
@@ -111,9 +168,17 @@ const QueueTab: React.FC = () => {
}
if (record.status === 'running') {
return (
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
<Button type="link" danger icon={<StopOutlined />} size="small"></Button>
</Popconfirm>
<Space size={4}>
<Button
type="link" icon={<FileTextOutlined />} size="small"
onClick={() => handleViewLogs(record.id)}
>
</Button>
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
<Button type="link" danger icon={<StopOutlined />} size="small"></Button>
</Popconfirm>
</Space>
);
}
return null;
@@ -132,6 +197,29 @@ const QueueTab: React.FC = () => {
loading={loading} pagination={false} size="small"
locale={{ emptyText: <Empty description="队列为空" /> }}
/>
{/* 实时日志抽屉 */}
<Drawer
title={
<Space>
<FileTextOutlined />
<span></span>
{wsConnected
? <Tag color="processing"></Tag>
: <Tag></Tag>}
</Space>
}
open={logDrawerOpen}
onClose={handleCloseLogDrawer}
width={720}
styles={{ body: { padding: 12, display: 'flex', flexDirection: 'column', height: '100%' } }}
>
{logTaskId && (
<div style={{ flex: 1, minHeight: 0 }}>
<LogStream executionId={logTaskId} lines={logLines} />
</div>
)}
</Drawer>
</>
);
};
@@ -144,6 +232,8 @@ const HistoryTab: React.FC = () => {
const [data, setData] = useState<ExecutionLog[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<ExecutionLog | null>(null);
const [historyLogLines, setHistoryLogLines] = useState<string[]>([]);
const [logLoading, setLogLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
@@ -154,6 +244,28 @@ const HistoryTab: React.FC = () => {
useEffect(() => { load(); }, [load]);
/** 点击行时加载详情和日志 */
const handleRowClick = useCallback(async (record: ExecutionLog) => {
setDetail(record);
setHistoryLogLines([]);
setLogLoading(true);
try {
const { data: logData } = await apiClient.get<{
execution_id: string;
output_log: string | null;
error_log: string | null;
}>(`/execution/${record.id}/logs`);
const parts: string[] = [];
if (logData.output_log) parts.push(logData.output_log);
if (logData.error_log) parts.push(logData.error_log);
setHistoryLogLines(parts.join('\n').split('\n').filter(Boolean));
} catch {
/* 日志可能不存在,静默处理 */
} finally {
setLogLoading(false);
}
}, []);
const columns: ColumnsType<ExecutionLog> = [
{
title: '任务', dataIndex: 'task_codes', key: 'task_codes',
@@ -187,31 +299,44 @@ const HistoryTab: React.FC = () => {
rowKey="id" columns={columns} dataSource={data}
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
size="small"
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
onRow={(record) => ({ onClick: () => handleRowClick(record), style: { cursor: 'pointer' } })}
/>
<Drawer
title="执行详情" open={!!detail} onClose={() => setDetail(null)}
width={520}
width={720}
styles={{ body: { padding: 12 } }}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="任务">{detail.task_codes?.join(', ')}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status] ?? 'default'}>{detail.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="开始时间">{fmtTime(detail.started_at)}</Descriptions.Item>
<Descriptions.Item label="结束时间">{fmtTime(detail.finished_at)}</Descriptions.Item>
<Descriptions.Item label="时长">{fmtDuration(detail.duration_ms)}</Descriptions.Item>
<Descriptions.Item label="退出码">
{detail.exit_code != null ? (
<Tag color={detail.exit_code === 0 ? 'success' : 'error'}>{detail.exit_code}</Tag>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="命令">
<code style={{ wordBreak: 'break-all', fontSize: 12 }}>{detail.command || '—'}</code>
</Descriptions.Item>
</Descriptions>
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="任务">{detail.task_codes?.join(', ')}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status] ?? 'default'}>{detail.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="开始时间">{fmtTime(detail.started_at)}</Descriptions.Item>
<Descriptions.Item label="结束时间">{fmtTime(detail.finished_at)}</Descriptions.Item>
<Descriptions.Item label="时长">{fmtDuration(detail.duration_ms)}</Descriptions.Item>
<Descriptions.Item label="退出码">
{detail.exit_code != null ? (
<Tag color={detail.exit_code === 0 ? 'success' : 'error'}>{detail.exit_code}</Tag>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="命令">
<code style={{ wordBreak: 'break-all', fontSize: 12 }}>{detail.command || '—'}</code>
</Descriptions.Item>
</Descriptions>
{/* 历史日志展示 */}
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<FileTextOutlined />
<Text strong></Text>
{logLoading && <Spin size="small" />}
</div>
<div style={{ height: 400 }}>
<LogStream executionId={detail.id} lines={historyLogLines} />
</div>
</>
)}
</Drawer>
</>

View File

@@ -6,8 +6,8 @@
/** ETL 任务执行配置 */
export interface TaskConfig {
tasks: string[];
/** 执行流程 Flow ID对应 CLI --pipeline */
pipeline: string;
/** 执行流程 Flow ID对应 CLI --flow */
flow: string;
/** 处理模式 */
processing_mode: string;
/** 传统模式兼容(已弃用) */
@@ -36,7 +36,7 @@ export interface TaskConfig {
}
/** 执行流程Flow定义 */
export interface PipelineDefinition {
export interface FlowDefinition {
id: string;
name: string;
/** 包含的层ODS / DWD / DWS / INDEX */