deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {Breadcrumb, App, Tabs} from "antd";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Activity, LayoutList,
|
||||
} from "lucide-react";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
deleteCleaningTaskByIdUsingDelete,
|
||||
executeCleaningTaskUsingPost,
|
||||
queryCleaningTaskByIdUsingGet, queryCleaningTaskLogByIdUsingGet, queryCleaningTaskResultByIdUsingGet,
|
||||
stopCleaningTaskUsingPost,
|
||||
} from "../cleansing.api";
|
||||
import {getTaskStatusMap, mapTask} from "../cleansing.const";
|
||||
import {CleansingResult, TaskStatus} from "@/pages/DataCleansing/cleansing.model";
|
||||
import BasicInfo from "./components/BasicInfo";
|
||||
import OperatorTable from "./components/OperatorTable";
|
||||
import FileTable from "./components/FileTable";
|
||||
import LogsTable from "./components/LogsTable";
|
||||
import {formatExecutionDuration} from "@/utils/unit.ts";
|
||||
import {ReloadOutlined} from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// 任务详情页面组件
|
||||
export default function CleansingTaskDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id = "" } = useParams(); // 获取动态路由参数
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchTaskDetail = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskByIdUsingGet(id);
|
||||
setTask(mapTask(data, t));
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.taskDetailFailed"));
|
||||
navigate("/data/cleansing");
|
||||
}
|
||||
};
|
||||
|
||||
const pauseTask = async () => {
|
||||
await stopCleaningTaskUsingPost(id);
|
||||
message.success(t("dataCleansing.task.messages.taskPaused"));
|
||||
fetchTaskDetail();
|
||||
};
|
||||
|
||||
const startTask = async () => {
|
||||
await executeCleaningTaskUsingPost(id);
|
||||
message.success(t("dataCleansing.task.messages.taskStarted"));
|
||||
fetchTaskDetail();
|
||||
};
|
||||
|
||||
const deleteTask = async () => {
|
||||
await deleteCleaningTaskByIdUsingDelete(id);
|
||||
message.success(t("dataCleansing.task.messages.taskDeleted"));
|
||||
navigate("/data/cleansing");
|
||||
};
|
||||
|
||||
const [result, setResult] = useState<CleansingResult[]>();
|
||||
|
||||
const fetchTaskResult = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskResultByIdUsingGet(id);
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.fetchTaskResultFailed"));
|
||||
navigate("/data/cleansing/task-detail/" + id);
|
||||
}
|
||||
};
|
||||
|
||||
const [taskLog, setTaskLog] = useState();
|
||||
|
||||
const fetchTaskLog = async (retryCount: number) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTaskLogByIdUsingGet(id, retryCount);
|
||||
setTaskLog(data);
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.task.messages.fetchTaskLogFailed"));
|
||||
navigate("/data/cleansing/task-detail/" + id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
fetchTaskDetail();
|
||||
{activeTab === "files" && await fetchTaskResult()}
|
||||
{activeTab === "logs" && await fetchTaskLog(task.retryCount)}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskDetail();
|
||||
}, [id, t]);
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
const headerData = {
|
||||
...task,
|
||||
icon: <LayoutList className="w-8 h-8" />,
|
||||
status: getTaskStatusMap(t)[task?.status],
|
||||
createdAt: task?.createdAt,
|
||||
lastUpdated: task?.updatedAt,
|
||||
};
|
||||
|
||||
const statistics = [
|
||||
{
|
||||
icon: <Clock className="w-4 h-4 text-blue-500" />,
|
||||
label: t("dataCleansing.detail.statistics.totalDuration"),
|
||||
value: formatExecutionDuration(task?.startedAt, task?.finishedAt) || "--",
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle className="w-4 h-4 text-green-500" />,
|
||||
label: t("dataCleansing.detail.statistics.successFiles"),
|
||||
value: task?.progress?.succeedFileNum || 0,
|
||||
},
|
||||
{
|
||||
icon: <AlertCircle className="w-4 h-4 text-red-500" />,
|
||||
label: t("dataCleansing.detail.statistics.failedFiles"),
|
||||
value: task?.progress?.failedFileNum || 0,
|
||||
},
|
||||
{
|
||||
icon: <Activity className="w-4 h-4 text-purple-500" />,
|
||||
label: t("dataCleansing.detail.statistics.successRate"),
|
||||
value: task?.progress?.process ? task?.progress?.process + "%" : "--",
|
||||
},
|
||||
];
|
||||
|
||||
const operations = [
|
||||
...(task?.status?.value === TaskStatus.RUNNING
|
||||
? [
|
||||
{
|
||||
key: "pause",
|
||||
label: t("dataCleansing.actions.pauseTask"),
|
||||
icon: <Pause className="w-4 h-4" />,
|
||||
onClick: pauseTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...([TaskStatus.PENDING, TaskStatus.STOPPED, TaskStatus.FAILED, TaskStatus.PARTIAL_SUCCESS].includes(task?.status?.value)
|
||||
? [
|
||||
{
|
||||
key: "start",
|
||||
label: t("dataCleansing.actions.start"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onClick: startTask,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "refresh",
|
||||
label: t("dataCleansing.actions.refreshTask"),
|
||||
icon: <ReloadOutlined className="w-4 h-4" />,
|
||||
onClick: handleRefresh,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("dataCleansing.actions.deleteTask"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
onClick: deleteTask,
|
||||
},
|
||||
];
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
key: "basic",
|
||||
label: t("dataCleansing.detail.tabs.basicInfo"),
|
||||
},
|
||||
{
|
||||
key: "operators",
|
||||
label: t("dataCleansing.detail.tabs.operators"),
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
label: t("dataCleansing.detail.tabs.files"),
|
||||
},
|
||||
{
|
||||
key: "logs",
|
||||
label: t("dataCleansing.detail.tabs.logs"),
|
||||
},
|
||||
];
|
||||
|
||||
const breadItems = [
|
||||
{
|
||||
title: <Link to="/data/cleansing">{t("dataCleansing.detail.breadcrumb.dataProcessing")}</Link>,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.breadcrumb.taskDetail"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb items={breadItems} />
|
||||
<div className="mb-4 mt-4">
|
||||
<DetailHeader
|
||||
data={headerData}
|
||||
statistics={statistics}
|
||||
operations={operations}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-overflow-auto p-6 pt-2 bg-white rounded-md shadow">
|
||||
<Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
|
||||
<div className="h-full flex-1 overflow-auto">
|
||||
{activeTab === "basic" && (
|
||||
<BasicInfo task={task} />
|
||||
)}
|
||||
{activeTab === "operators" && <OperatorTable task={task} />}
|
||||
{activeTab === "files" && <FileTable result={result} fetchTaskResult={fetchTaskResult} />}
|
||||
{activeTab === "logs" && <LogsTable taskLog={taskLog} fetchTaskLog={fetchTaskLog} retryCount={task.retryCount} taskName={task.name} taskStatus={task?.status?.value} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {Breadcrumb, App, Tabs} from "antd";
|
||||
import {
|
||||
Trash2,
|
||||
LayoutList,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
deleteCleaningTemplateByIdUsingDelete,
|
||||
queryCleaningTemplateByIdUsingGet,
|
||||
} from "../cleansing.api";
|
||||
import {mapTemplate} from "../cleansing.const";
|
||||
import TemplateOperatorTable from "./components/TemplateOperatorTable";
|
||||
import {EditOutlined, ReloadOutlined, NumberOutlined} from "@ant-design/icons";
|
||||
|
||||
// 任务详情页面组件
|
||||
export default function CleansingTemplateDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id = "" } = useParams(); // 获取动态路由参数
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [template, setTemplate] = useState();
|
||||
|
||||
const fetchTemplateDetail = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { data } = await queryCleaningTemplateByIdUsingGet(id);
|
||||
setTemplate(mapTemplate(data, t));
|
||||
} catch (error) {
|
||||
message.error(t("dataCleansing.template.messages.templateDetailFailed"));
|
||||
navigate("/data/cleansing");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTemplate = async () => {
|
||||
await deleteCleaningTemplateByIdUsingDelete(id);
|
||||
message.success(t("dataCleansing.template.messages.templateDeleted"));
|
||||
navigate("/data/cleansing");
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
fetchTemplateDetail();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplateDetail();
|
||||
}, [id]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("operators");
|
||||
|
||||
const headerData = {
|
||||
...template,
|
||||
icon: <LayoutList className="w-8 h-8" />,
|
||||
createdAt: template?.createdAt,
|
||||
lastUpdated: template?.updatedAt,
|
||||
};
|
||||
|
||||
const statistics = [
|
||||
{
|
||||
icon: <NumberOutlined className="w-4 h-4 text-green-500" />,
|
||||
label: t("dataCleansing.detail.statistics.operatorCount"),
|
||||
value: template?.instance?.length || 0,
|
||||
},
|
||||
];
|
||||
|
||||
const operations = [
|
||||
{
|
||||
key: "update",
|
||||
label: t("dataCleansing.actions.updateTemplate"),
|
||||
icon: <EditOutlined className="w-4 h-4" />,
|
||||
onClick: () => navigate(`/data/cleansing/update-template/${id}`),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
label: t("dataCleansing.actions.refreshTemplate"),
|
||||
icon: <ReloadOutlined className="w-4 h-4" />,
|
||||
onClick: handleRefresh,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("dataCleansing.actions.deleteTemplate"),
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
danger: true,
|
||||
onClick: deleteTemplate,
|
||||
},
|
||||
];
|
||||
|
||||
const tabList = [
|
||||
{
|
||||
key: "operators",
|
||||
label: t("dataCleansing.detail.tabs.operators"),
|
||||
},
|
||||
];
|
||||
|
||||
const breadItems = [
|
||||
{
|
||||
title: <Link to="/data/cleansing">{t("dataCleansing.detail.breadcrumb.dataProcessing")}</Link>,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.breadcrumb.templateDetail"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb items={breadItems} />
|
||||
<div className="mb-4 mt-4">
|
||||
<DetailHeader
|
||||
data={headerData}
|
||||
statistics={statistics}
|
||||
operations={operations}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-overflow-auto p-6 pt-2 bg-white rounded-md shadow">
|
||||
<Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
|
||||
<div className="h-full flex-1 overflow-auto">
|
||||
<TemplateOperatorTable template={template} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {CleansingTask, TaskStatus} from "@/pages/DataCleansing/cleansing.model";
|
||||
import { Button, Card, Descriptions, Progress } from "antd";
|
||||
import { Activity, AlertCircle, BookOpen, CheckCircle, Clock } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {formatExecutionDuration} from "@/utils/unit.ts";
|
||||
import {CardHeader, CardTitle} from "@/components/Card.tsx";
|
||||
|
||||
export default function BasicInfo({ task }: { task: CleansingTask }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptionItems = [
|
||||
{
|
||||
key: "id",
|
||||
label: t("dataCleansing.detail.basicInfo.taskId"),
|
||||
children: <span className="font-mono">{task?.id}</span>,
|
||||
},
|
||||
{ key: "name", label: t("dataCleansing.detail.basicInfo.taskName"), children: task?.name },
|
||||
{
|
||||
key: "dataset",
|
||||
label: t("dataCleansing.detail.basicInfo.srcDataset"),
|
||||
children: (
|
||||
<Button
|
||||
style={{ paddingLeft: 0, marginLeft: 0 }}
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
navigate("/data/management/detail/" + task?.srcDatasetId)
|
||||
}
|
||||
>
|
||||
{task?.srcDatasetName}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "targetDataset",
|
||||
label: t("dataCleansing.detail.basicInfo.destDataset"),
|
||||
children: (
|
||||
<Button
|
||||
style={{ paddingLeft: 0, marginLeft: 0 }}
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
navigate("/data/management/detail/" + task?.destDatasetId)
|
||||
}
|
||||
>
|
||||
{task?.destDatasetName}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ key: "startTime", label: t("dataCleansing.detail.basicInfo.startTime"), children: task?.startedAt },
|
||||
{
|
||||
key: "description",
|
||||
label: t("dataCleansing.detail.basicInfo.description"),
|
||||
children: (
|
||||
<span className="text-gray-600">{task?.description || "--"}</span>
|
||||
),
|
||||
span: 2,
|
||||
},
|
||||
{ key: "finishedTime", label: t("dataCleansing.detail.basicInfo.endTime"), children: task?.finishedAt },
|
||||
{ key: "retryCount", label: t("dataCleansing.detail.basicInfo.retryCount"), children: task?.retryCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 执行摘要 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="p-1 pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="w h text-blue-600" />
|
||||
{t("dataCleansing.detail.basicInfo.executionSummary")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg">
|
||||
<Clock className="w-8 h-8 text-blue-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-blue-500">
|
||||
{formatExecutionDuration(task?.startedAt, task?.finishedAt) || "--"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.totalDuration")}</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gradient-to-br from-green-50 to-green-100 rounded-lg">
|
||||
<CheckCircle className="w-8 h-8 text-green-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-green-500">
|
||||
{task?.progress?.succeedFileNum ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.successFiles")}</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gradient-to-br from-red-50 to-red-100 rounded-lg">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-red-500">
|
||||
{(task?.status.value === TaskStatus.RUNNING || task?.status.value === TaskStatus.PENDING) ?
|
||||
(task?.progress?.failedFileNum ?? 0) :
|
||||
Math.max(0, (task?.progress?.totalFileNum ?? 0) - (task?.progress?.succeedFileNum ?? 0))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.failedFiles")}</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg">
|
||||
<Activity className="w-8 h-8 text-purple-500 mb-2 mx-auto" />
|
||||
<div className="text-xl font-bold text-purple-500">
|
||||
{task?.progress?.successRate ? task?.progress?.successRate + "%" : "--"}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.successRate")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 基本信息 */}
|
||||
<Card>
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t("dataCleansing.detail.basicInfo.basicInformation")}</h3>
|
||||
<Descriptions
|
||||
column={2}
|
||||
bordered={false}
|
||||
size="middle"
|
||||
styles={{ label: { fontWeight: 500, color: "#555" }, content: { fontSize: 14 } }}
|
||||
items={descriptionItems}
|
||||
></Descriptions>
|
||||
</div>
|
||||
{/* 处理进度 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t("dataCleansing.detail.basicInfo.processingProgress")}</h3>
|
||||
{ task?.status?.value === TaskStatus.FAILED ? (
|
||||
<Progress percent={task?.progress?.process} size="small" status="exception" />
|
||||
) : task?.status?.value === TaskStatus.PARTIAL_SUCCESS ? (
|
||||
<Progress percent={task?.progress?.process} size="small" strokeColor="#f59e0b" />
|
||||
) : (
|
||||
<Progress percent={task?.progress?.process} size="small"/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full inline-block" />
|
||||
<span>{t("dataCleansing.detail.basicInfo.completed", { count: task?.progress?.succeedFileNum ?? 0 })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-blue-500 rounded-full inline-block" />
|
||||
<span>{t("dataCleansing.detail.basicInfo.processing", { count: (task?.status.value === TaskStatus.RUNNING || task?.status.value === TaskStatus.PENDING) ?
|
||||
Math.max(0, (task?.progress?.totalFileNum ?? 0) - (task?.progress?.succeedFileNum ?? 0)) : 0 })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 bg-red-500 rounded-full inline-block" />
|
||||
<span>{t("dataCleansing.detail.basicInfo.failed", { count: (task?.status.value === TaskStatus.RUNNING || task?.status.value === TaskStatus.PENDING) ?
|
||||
(task?.progress?.failedFileNum ?? 0) :
|
||||
Math.max(0, (task?.progress?.totalFileNum ?? 0) - (task?.progress?.succeedFileNum ?? 0)) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
import {Button, Modal, Table, Badge, Input, Popover} from "antd";
|
||||
import { Download } from "lucide-react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useParams} from "react-router";
|
||||
import {TaskStatus} from "@/pages/DataCleansing/cleansing.model.ts";
|
||||
import {getTaskStatusMap} from "@/pages/DataCleansing/cleansing.const.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// 渲染 JSON 值的辅助组件
|
||||
function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) {
|
||||
if (value === null) {
|
||||
return <span className="text-gray-500">null</span>;
|
||||
}
|
||||
if (value === undefined) {
|
||||
return <span className="text-gray-500">undefined</span>;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className="text-purple-600">{value.toString()}</span>;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return <span className="text-blue-600">{value}</span>;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
// 处理换行符,保留空白格式
|
||||
const lines = value.split('\n');
|
||||
if (lines.length > 1) {
|
||||
return (
|
||||
<span className="text-green-700">
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-green-700">{value}</span>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-gray-500">[]</span>;
|
||||
}
|
||||
return (
|
||||
<div className="pl-3 border-l-2 border-gray-200">
|
||||
{value.map((item, i) => (
|
||||
<div key={i} className="py-0.5">
|
||||
<span className="text-gray-400 mr-1">[{i}]</span>
|
||||
<JsonValue value={item} depth={depth + 1} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 0) {
|
||||
return <span className="text-gray-500">{"{}"}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="pl-3 border-l-2 border-gray-200">
|
||||
{keys.map((key) => (
|
||||
<div key={key} className="py-0.5">
|
||||
<span className="text-blue-800 font-medium">{key}</span>
|
||||
<span className="text-gray-500 mx-1">:</span>
|
||||
<JsonValue value={value[key]} depth={depth + 1} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
// 模拟文件列表数据
|
||||
export default function FileTable({result, fetchTaskResult}) {
|
||||
const { id = "" } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const [showFileCompareDialog, setShowFileCompareDialog] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskResult();
|
||||
}, [id]);
|
||||
|
||||
const handleSelectAllFiles = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds(result.map((file) => file.srcFileId));
|
||||
} else {
|
||||
setSelectedFileIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFile = (fileId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds([...selectedFileIds, fileId]);
|
||||
} else {
|
||||
setSelectedFileIds(selectedFileIds.filter((id) => id !== fileId));
|
||||
}
|
||||
};
|
||||
const handleViewFileCompare = (file: any) => {
|
||||
setSelectedFile(file);
|
||||
setShowFileCompareDialog(true);
|
||||
};
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/cleaning/tasks/${id}/result/download`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `task_${id}_files.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
message.error(t("dataCleansing.detail.fileTable.downloadFilesFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
function formatFileSize(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
const fileColumns = [
|
||||
{
|
||||
title: (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selectedFileIds.length === result?.length && result?.length > 0
|
||||
}
|
||||
onChange={(e) => handleSelectAllFiles(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
),
|
||||
dataIndex: "select",
|
||||
key: "select",
|
||||
width: 50,
|
||||
render: (_text: string, record: any) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFileIds.includes(record.srcFileId)}
|
||||
onChange={(e) => handleSelectFile(record.srcFileId, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.fileName"),
|
||||
dataIndex: "srcName",
|
||||
key: "srcName",
|
||||
width: 200,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileName")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.srcName.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string) => (
|
||||
<span>{text?.replace(/\.[^/.]+$/, "")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.processedFileName"),
|
||||
dataIndex: "destName",
|
||||
key: "destName",
|
||||
width: 200,
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileName")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.destName.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string) => (
|
||||
<span>{text?.replace(/\.[^/.]+$/, "")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.fileType"),
|
||||
dataIndex: "srcType",
|
||||
key: "srcType",
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileType")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.srcType.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string) => (
|
||||
<span className="font-mono text-sm">{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.processedFileType"),
|
||||
dataIndex: "destType",
|
||||
key: "destType",
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
}: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.fileTable.searchFileType")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) =>
|
||||
record.destType.toLowerCase().includes(value.toLowerCase()),
|
||||
render: (text: string) => (
|
||||
<span className="font-mono text-sm">{text || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.beforeSize"),
|
||||
dataIndex: "srcSize",
|
||||
key: "srcSize",
|
||||
sorter: (a: any, b: any) => {
|
||||
const getSizeInBytes = (size: string) => {
|
||||
if (!size || size === "-") return 0;
|
||||
const num = Number.parseFloat(size);
|
||||
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
|
||||
if (size.includes("MB")) return num * 1024 * 1024;
|
||||
if (size.includes("KB")) return num * 1024;
|
||||
return num;
|
||||
};
|
||||
return getSizeInBytes(a.originalSize) - getSizeInBytes(b.originalSize);
|
||||
},
|
||||
render: (number: number) => (
|
||||
<span className="font-mono text-sm">{formatFileSize(number)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.afterSize"),
|
||||
dataIndex: "destSize",
|
||||
key: "destSize",
|
||||
sorter: (a: any, b: any) => {
|
||||
const getSizeInBytes = (size: string) => {
|
||||
if (!size || size === "-") return 0;
|
||||
const num = Number.parseFloat(size);
|
||||
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
|
||||
if (size.includes("MB")) return num * 1024 * 1024;
|
||||
if (size.includes("KB")) return num * 1024;
|
||||
return num;
|
||||
};
|
||||
return (
|
||||
getSizeInBytes(a.processedSize) - getSizeInBytes(b.processedSize)
|
||||
);
|
||||
},
|
||||
render: (number: number) => (
|
||||
<span className="font-mono text-sm">{formatFileSize(number)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.result"),
|
||||
dataIndex: "result",
|
||||
key: "result",
|
||||
width: 120,
|
||||
render: (text: string, record: any) => {
|
||||
// 如果结果为空或特殊值,则不展示
|
||||
if (!text || text === '' || text === '{}' || text === '[]' ||
|
||||
text === 'null' || text === 'undefined' || text.trim() === '') {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
// 尝试解析JSON
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
// 如果是空对象或空数组则不展示
|
||||
if (Array.isArray(parsed) && parsed.length === 0) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
// 检查 reason 字段:如果只有 reason 且为 null/空,则不展示
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
// 如果只有 reason 字段且为空
|
||||
if (keys.length > 0 && keys.includes('reason')) {
|
||||
const reason = parsed['reason'];
|
||||
if (reason === null || reason === undefined || reason === '' ||
|
||||
(typeof reason === 'string' && reason.trim() === '')) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 有内容需要展示,显示 {...} 点击查看详情
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className="max-w-md max-h-64 overflow-auto text-sm bg-gray-50 p-3 rounded">
|
||||
<JsonValue value={parsed} />
|
||||
</div>
|
||||
}
|
||||
title={t("dataCleansing.detail.fileTable.resultDetail")}
|
||||
trigger="click"
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600 cursor-pointer hover:text-blue-800 underline">
|
||||
{"{...}"}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
} catch {
|
||||
// 普通字符串:如果为空则不展示
|
||||
if (text.trim() === '') {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
// 有内容的普通字符串,点击查看详情(支持换行)
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className="max-w-md max-h-64 overflow-auto text-sm bg-gray-50 p-3 rounded whitespace-pre-wrap">
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
title={t("dataCleansing.detail.fileTable.resultDetail")}
|
||||
trigger="click"
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600 cursor-pointer hover:text-blue-800 underline">
|
||||
{"{...}"}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
filters: [
|
||||
{ text: t("dataCleansing.status.completed"), value: "COMPLETED" },
|
||||
{ text: t("dataCleansing.status.failed"), value: "FAILED" },
|
||||
],
|
||||
onFilter: (value: string, record: any) => record.status === value,
|
||||
render: (status: string) => (
|
||||
<Badge
|
||||
status={
|
||||
status === "COMPLETED"
|
||||
? "success"
|
||||
: "error"
|
||||
}
|
||||
text={getTaskStatusMap(t)[status as TaskStatus].label}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.fileTable.actions"),
|
||||
key: "action",
|
||||
width: 150,
|
||||
render: (_text: string, record: any) => (
|
||||
<div className="flex">
|
||||
{record.status === "COMPLETED" ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleViewFileCompare(record)}
|
||||
>
|
||||
{t("dataCleansing.actions.compare")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled
|
||||
>
|
||||
{t("dataCleansing.actions.compare")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleDownload()}
|
||||
>
|
||||
{t("dataCleansing.actions.download")}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedFileIds.length > 0 && (
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">
|
||||
{t("dataCleansing.detail.fileTable.selectedFilesCount", { count: selectedFileIds.length })}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<Download className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
{t("dataCleansing.actions.download")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
columns={fileColumns}
|
||||
dataSource={result}
|
||||
pagination={{ pageSize: 10, showSizeChanger: true }}
|
||||
size="middle"
|
||||
rowKey={(record) => record.srcFileId || record.instanceId}
|
||||
/>
|
||||
|
||||
{/* 文件对比弹窗 */}
|
||||
<Modal
|
||||
open={showFileCompareDialog}
|
||||
onCancel={() => setShowFileCompareDialog(false)}
|
||||
footer={null}
|
||||
width={900}
|
||||
title={<span>{t("dataCleansing.detail.fileTable.compareDialogTitle", {fileName: selectedFile?.fileName})}</span>}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6 py-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{t("dataCleansing.detail.fileTable.beforeProcessing")}</h4>
|
||||
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="w-16 h-16 bg-gray-300 rounded-lg mx-auto mb-2" />
|
||||
<div className="text-sm">{t("dataCleansing.detail.fileTable.originalFilePreview")}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("dataCleansing.detail.fileTable.size")}: {formatFileSize(selectedFile?.srcSize)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-3 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">{t("dataCleansing.detail.fileTable.fileFormat")}:</span> {selectedFile?.srcType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{t("dataCleansing.detail.fileTable.afterProcessing")}</h4>
|
||||
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="w-16 h-16 bg-blue-300 rounded-lg mx-auto mb-2" />
|
||||
<div className="text-sm">{t("dataCleansing.detail.fileTable.processedFilePreview")}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("dataCleansing.detail.fileTable.size")}: {formatFileSize(selectedFile?.destSize)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-3 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">{t("dataCleansing.detail.fileTable.fileFormat")}:</span> {selectedFile?.destType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 mt-6 pt-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">{t("dataCleansing.detail.fileTable.processingEffect")}</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="font-medium text-green-700">{t("dataCleansing.detail.fileTable.sizeOptimization")}</div>
|
||||
<div className="text-green-600">{t("dataCleansing.detail.fileTable.reduced", {percent: (100 * (selectedFile?.srcSize - selectedFile?.destSize) / selectedFile?.srcSize).toFixed(2)})}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { FileClock, Download } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { App } from "antd";
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { streamCleaningTaskLog, downloadCleaningTaskLog } from "../../cleansing.api";
|
||||
import { TaskStatus } from "../../cleansing.model";
|
||||
|
||||
interface LogEntry {
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LogEntryWithIndex extends LogEntry {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function LogsTable({
|
||||
taskLog: initialLogs,
|
||||
fetchTaskLog,
|
||||
retryCount,
|
||||
taskName,
|
||||
taskStatus
|
||||
}: {
|
||||
taskLog: LogEntry[],
|
||||
fetchTaskLog: () => Promise<LogEntry[]>,
|
||||
retryCount: number,
|
||||
taskName: string,
|
||||
taskStatus?: TaskStatus
|
||||
}) {
|
||||
const { id = "" } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const [selectedLog, setSelectedLog] = useState(retryCount + 1);
|
||||
const [streamingLogs, setStreamingLogs] = useState<LogEntry[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const { message } = App.useApp();
|
||||
|
||||
// Only stream when task is RUNNING and viewing the latest run
|
||||
const shouldStream = taskStatus === TaskStatus.RUNNING && selectedLog - 1 === retryCount;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldStream) {
|
||||
startStreaming();
|
||||
} else {
|
||||
stopStreaming();
|
||||
fetchTaskLog(selectedLog - 1).then(() => {
|
||||
// Static logs loaded, safe to clear streaming logs now
|
||||
setStreamingLogs([]);
|
||||
});
|
||||
}
|
||||
return () => stopStreaming();
|
||||
}, [id, selectedLog, retryCount, shouldStream]);
|
||||
|
||||
const startStreaming = () => {
|
||||
stopStreaming();
|
||||
setStreamingLogs([]);
|
||||
setIsStreaming(true);
|
||||
|
||||
const eventSource = streamCleaningTaskLog(id, selectedLog - 1);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const logEntry: LogEntry = JSON.parse(event.data);
|
||||
if (logEntry.message === "[END_OF_STREAM]" || logEntry.message === "[HEARTBEAT]") {
|
||||
if (logEntry.message === "[END_OF_STREAM]") {
|
||||
// Don't clear streamingLogs immediately - keep them visible
|
||||
// while the static fetch completes
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStreamingLogs(prev => [...prev, logEntry]);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse log entry:", e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("SSE error:", error);
|
||||
stopStreaming();
|
||||
};
|
||||
};
|
||||
|
||||
const stopStreaming = () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
// Use streaming logs only when actively streaming, otherwise use initial logs
|
||||
// Keep streamingLogs visible until initialLogs are populated (avoids blank flash)
|
||||
const displayLogs = isStreaming || streamingLogs.length > 0
|
||||
? (streamingLogs.length > 0 ? streamingLogs : initialLogs)
|
||||
: initialLogs;
|
||||
|
||||
// Add index to logs for virtual list key
|
||||
const logsWithIndex: LogEntryWithIndex[] = useMemo(() => {
|
||||
return (displayLogs || []).map((log, index) => ({ ...log, index }));
|
||||
}, [displayLogs]);
|
||||
|
||||
// Get log level color class
|
||||
const getLevelClass = (level: string) => {
|
||||
if (level === "ERROR" || level === "FATAL") return "text-red-500";
|
||||
if (level === "WARNING" || level === "WARN") return "text-yellow-500";
|
||||
return "text-green-500";
|
||||
};
|
||||
|
||||
const handleSelectChange = (value: number) => {
|
||||
setSelectedLog(value);
|
||||
setStreamingLogs([]);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const blob = await downloadCleaningTaskLog(id, selectedLog - 1);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${taskName}_${t("dataCleansing.logTable.nthRun", { num: selectedLog })}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("Failed to download log:", error);
|
||||
message.error(t("dataCleansing.detail.logTable.downloadFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return displayLogs?.length > 0 || isStreaming ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-500">{t("dataCleansing.detail.logTable.selectRun")}:</label>
|
||||
<select
|
||||
value={selectedLog}
|
||||
onChange={(e) => handleSelectChange(Number(e.target.value))}
|
||||
className="bg-gray-700 border border-gray-600 !text-white text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block px-2.5 py-1.5 min-w-[120px]"
|
||||
>
|
||||
{Array.from({ length: retryCount + 1 }, (_, i) => retryCount + 1 - i).map((num) => (
|
||||
<option key={num} value={num}>
|
||||
{t("dataCleansing.detail.logTable.currentDisplay", { num: num })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStreaming && (
|
||||
<span className="text-xs text-blue-400 animate-pulse">{t("dataCleansing.detail.logTable.streaming")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-s text-gray-500 px-2">{t("dataCleansing.detail.logTable.nthRun", { num: selectedLog })}</span>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isStreaming}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 border border-blue-500 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>{t("dataCleansing.detail.logTable.download")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-300 p-4 border border-gray-700 bg-gray-800 rounded-lg">
|
||||
<div className="font-mono text-sm">
|
||||
<VirtualList
|
||||
data={logsWithIndex}
|
||||
height={580}
|
||||
itemHeight={22}
|
||||
itemKey="index"
|
||||
>
|
||||
{(log: LogEntryWithIndex) => (
|
||||
<div className="flex gap-3 py-0.5">
|
||||
<span className={`min-w-20 ${getLevelClass(log.level)}`}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="text-gray-100">{log.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</VirtualList>
|
||||
{isStreaming && (
|
||||
<div className="flex gap-3 animate-pulse py-0.5">
|
||||
<span className="text-gray-400">...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<FileClock className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{t("dataCleansing.detail.logTable.noLogs")}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {Badge, Button, Input, Table, Typography} from "antd";
|
||||
import {useNavigate} from "react-router";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/Card"
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskStatus } from "@/pages/DataCleansing/cleansing.model";
|
||||
|
||||
export default function OperatorTable({ task }: { task: any }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const operatorColumns = [
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.serialNumber"),
|
||||
dataIndex: "index",
|
||||
key: "index",
|
||||
width: 80,
|
||||
render: (text: any, record: any, index: number) => index + 1,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.operatorName"),
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text, record) => (
|
||||
<Typography.Link
|
||||
onClick={() => navigate(`/data/operator-market/plugin-detail/${record.id}`)}
|
||||
>
|
||||
{text}
|
||||
</Typography.Link>
|
||||
),
|
||||
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: any) => (
|
||||
<div className="p-4 w-64">
|
||||
<Input
|
||||
placeholder={t("dataCleansing.detail.operatorTable.searchOperatorName")}
|
||||
value={selectedKeys[0]}
|
||||
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
|
||||
onPressEnter={() => confirm()}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => confirm()}>
|
||||
{t("dataCleansing.actions.search")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => clearFilters()}>
|
||||
{t("dataCleansing.actions.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: any) => record.name.toLowerCase().includes(value.toLowerCase()),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.startTime"),
|
||||
dataIndex: "startTime",
|
||||
key: "startTime",
|
||||
sorter: (a: any, b: any) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.endTime"),
|
||||
dataIndex: "endTime",
|
||||
key: "endTime",
|
||||
sorter: (a: any, b: any) => new Date(a.endTime).getTime() - new Date(b.endTime).getTime(),
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.duration"),
|
||||
dataIndex: "duration",
|
||||
key: "duration",
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.processedFiles"),
|
||||
dataIndex: "processedFiles",
|
||||
key: "processedFiles",
|
||||
sorter: (a: any, b: any) => a.processedFiles - b.processedFiles,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.successRate"),
|
||||
dataIndex: "successRate",
|
||||
key: "successRate",
|
||||
sorter: (a: any, b: any) => a.successRate - b.successRate,
|
||||
render: (rate: number) => `${rate}%`,
|
||||
},
|
||||
{
|
||||
title: t("dataCleansing.detail.operatorTable.status"),
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
filters: [
|
||||
{ text: t("dataCleansing.detail.operatorTable.completed"), value: "completed" },
|
||||
{ text: t("dataCleansing.detail.operatorTable.failed"), value: "failed" },
|
||||
{ text: t("dataCleansing.detail.operatorTable.running"), value: "running" },
|
||||
{ text: t("dataCleansing.detail.operatorTable.partialSuccess"), value: "partialSuccess" },
|
||||
],
|
||||
onFilter: (value: string, record: any) => record.statusValue === value,
|
||||
render: (statusObj: { label: string; value: TaskStatus }) => {
|
||||
let badgeStatus: "default" | "processing" | "success" | "error" | "warning" = "default";
|
||||
|
||||
if (statusObj?.value === TaskStatus.COMPLETED) {
|
||||
badgeStatus = "success";
|
||||
} else if (statusObj?.value === TaskStatus.RUNNING) {
|
||||
badgeStatus = "processing";
|
||||
} else if (statusObj?.value === TaskStatus.PARTIAL_SUCCESS) {
|
||||
badgeStatus = "warning";
|
||||
} else if (statusObj?.value === TaskStatus.FAILED) {
|
||||
badgeStatus = "error";
|
||||
}
|
||||
|
||||
return <Badge status={badgeStatus} text={statusObj?.label} />;
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return task?.instance?.length > 0 && (
|
||||
<>
|
||||
<Card className="bg-white/70 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-5 h-5 text-purple-600" />
|
||||
{t("dataCleansing.detail.operatorTable.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("dataCleansing.detail.operatorTable.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table
|
||||
columns={operatorColumns}
|
||||
dataSource={Object.values(task?.instance).map((item) => ({
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
startTime: new Date(task?.startedAt).toLocaleTimeString(),
|
||||
endTime: task?.finishedAt
|
||||
? new Date(task.finishedAt).toLocaleTimeString()
|
||||
: '-',
|
||||
duration: task.duration,
|
||||
status: task.status,
|
||||
statusValue: task.status.value,
|
||||
processedFiles: task.progress.finishedFileNum,
|
||||
successRate: task?.progress.successRate,
|
||||
}))}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {Steps, Typography} from "antd";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
export default function TemplateOperatorTable({ template }: { template: any }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return template?.instance?.length > 0 && (
|
||||
<>
|
||||
<Steps
|
||||
progressDot
|
||||
direction="vertical"
|
||||
items={Object.values(template?.instance).map((item) => ({
|
||||
title: <Typography.Link
|
||||
onClick={() => navigate(`/data/operator-market/plugin-detail/${item?.id}`)}
|
||||
>
|
||||
{item?.name}
|
||||
</Typography.Link>,
|
||||
description: item?.description,
|
||||
status: "finish"
|
||||
}))}
|
||||
className="overflow-auto"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user