certimate/ui/src/components/workflow/WorkflowRuns.tsx
2025-04-05 21:23:55 +08:00

319 lines
9.7 KiB
TypeScript

import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
CheckCircleOutlined as CheckCircleOutlinedIcon,
ClockCircleOutlined as ClockCircleOutlinedIcon,
CloseCircleOutlined as CloseCircleOutlinedIcon,
DeleteOutlined as DeleteOutlinedIcon,
PauseOutlined as PauseOutlinedIcon,
SelectOutlined as SelectOutlinedIcon,
StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
import { useRequest } from "ahooks";
import { Alert, Button, Empty, Modal, Space, Table, type TableProps, Tag, Tooltip, notification } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import { cancelRun as cancelWorkflowRun } from "@/api/workflows";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import {
list as listWorkflowRuns,
remove as removeWorkflowRun,
subscribe as subscribeWorkflowRun,
unsubscribe as unsubscribeWorkflowRun,
} from "@/repository/workflowRun";
import { getErrMsg } from "@/utils/error";
import WorkflowRunDetailDrawer from "./WorkflowRunDetailDrawer";
export type WorkflowRunsProps = {
className?: string;
style?: React.CSSProperties;
workflowId: string;
};
const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const { t } = useTranslation();
const [modalApi, ModelContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const tableColumns: TableProps<WorkflowRunModel>["columns"] = [
{
key: "$index",
align: "center",
fixed: "left",
width: 50,
render: (_, __, index) => (page - 1) * pageSize + index + 1,
},
{
key: "id",
title: t("workflow_run.props.id"),
ellipsis: true,
render: (_, record) => <span className="font-mono">{record.id}</span>,
},
{
key: "status",
title: t("workflow_run.props.status"),
ellipsis: true,
render: (_, record) => {
if (record.status === WORKFLOW_RUN_STATUSES.PENDING) {
return <Tag icon={<ClockCircleOutlinedIcon />}>{t("workflow_run.props.status.pending")}</Tag>;
} else if (record.status === WORKFLOW_RUN_STATUSES.RUNNING) {
return (
<Tag icon={<SyncOutlinedIcon spin />} color="processing">
{t("workflow_run.props.status.running")}
</Tag>
);
} else if (record.status === WORKFLOW_RUN_STATUSES.SUCCEEDED) {
return (
<Tag icon={<CheckCircleOutlinedIcon />} color="success">
{t("workflow_run.props.status.succeeded")}
</Tag>
);
} else if (record.status === WORKFLOW_RUN_STATUSES.FAILED) {
return (
<Tag icon={<CloseCircleOutlinedIcon />} color="error">
{t("workflow_run.props.status.failed")}
</Tag>
);
} else if (record.status === WORKFLOW_RUN_STATUSES.CANCELED) {
return (
<Tag icon={<StopOutlinedIcon />} color="warning">
{t("workflow_run.props.status.canceled")}
</Tag>
);
}
return <></>;
},
},
{
key: "trigger",
title: t("workflow_run.props.trigger"),
ellipsis: true,
render: (_, record) => {
if (record.trigger === WORKFLOW_TRIGGERS.AUTO) {
return t("workflow_run.props.trigger.auto");
} else if (record.trigger === WORKFLOW_TRIGGERS.MANUAL) {
return t("workflow_run.props.trigger.manual");
}
return <></>;
},
},
{
key: "startedAt",
title: t("workflow_run.props.started_at"),
ellipsis: true,
render: (_, record) => {
if (record.startedAt) {
return dayjs(record.startedAt).format("YYYY-MM-DD HH:mm:ss");
}
return <></>;
},
},
{
key: "endedAt",
title: t("workflow_run.props.ended_at"),
ellipsis: true,
render: (_, record) => {
if (record.endedAt) {
return dayjs(record.endedAt).format("YYYY-MM-DD HH:mm:ss");
}
return <></>;
},
},
{
key: "$action",
align: "end",
fixed: "right",
width: 120,
render: (_, record) => {
const allowCancel = record.status === WORKFLOW_RUN_STATUSES.PENDING || record.status === WORKFLOW_RUN_STATUSES.RUNNING;
const aloowDelete =
record.status === WORKFLOW_RUN_STATUSES.SUCCEEDED ||
record.status === WORKFLOW_RUN_STATUSES.FAILED ||
record.status === WORKFLOW_RUN_STATUSES.CANCELED;
return (
<Space.Compact>
<WorkflowRunDetailDrawer
data={record}
trigger={
<Tooltip title={t("workflow_run.action.view")}>
<Button color="primary" icon={<SelectOutlinedIcon />} variant="text" />
</Tooltip>
}
/>
<Tooltip title={t("workflow_run.action.cancel")}>
<Button
color="default"
disabled={!allowCancel}
icon={<PauseOutlinedIcon />}
variant="text"
onClick={() => {
handleCancelClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow_run.action.delete")}>
<Button
color="danger"
danger
disabled={!aloowDelete}
icon={<DeleteOutlinedIcon />}
variant="text"
onClick={() => {
handleDeleteClick(record);
}}
/>
</Tooltip>
</Space.Compact>
);
},
},
];
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const {
loading,
error: loadedError,
run: refreshData,
} = useRequest(
() => {
return listWorkflowRuns({
workflowId: workflowId,
page: page,
perPage: pageSize,
});
},
{
refreshDeps: [workflowId, page, pageSize],
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
return;
}
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
}
);
useEffect(() => {
const items = tableData.filter((e) => e.status === WORKFLOW_RUN_STATUSES.PENDING || e.status === WORKFLOW_RUN_STATUSES.RUNNING);
for (const item of items) {
subscribeWorkflowRun(item.id, (cb) => {
setTableData((prev) => {
const index = prev.findIndex((e) => e.id === item.id);
if (index !== -1) {
prev[index] = cb.record;
}
return [...prev];
});
if (cb.record.status !== WORKFLOW_RUN_STATUSES.PENDING && cb.record.status !== WORKFLOW_RUN_STATUSES.RUNNING) {
unsubscribeWorkflowRun(item.id);
}
});
}
return () => {
for (const item of items) {
unsubscribeWorkflowRun(item.id);
}
};
}, [tableData]);
const handleCancelClick = (workflowRun: WorkflowRunModel) => {
modalApi.confirm({
title: t("workflow_run.action.cancel"),
content: t("workflow_run.action.cancel.confirm"),
onOk: async () => {
try {
const resp = await cancelWorkflowRun(workflowId, workflowRun.id);
if (resp) {
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleDeleteClick = (workflowRun: WorkflowRunModel) => {
modalApi.confirm({
title: t("workflow_run.action.delete"),
content: t("workflow_run.action.delete.confirm"),
onOk: async () => {
try {
const resp = await removeWorkflowRun(workflowRun);
if (resp) {
setTableData((prev) => prev.filter((item) => item.id !== workflowRun.id));
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
return (
<>
{ModelContextHolder}
{NotificationContextHolder}
<div className={className} style={style}>
<Alert className="mb-4" type="warning" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.table.alert") }}></span>} />
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadedError ? getErrMsg(loadedError) : undefined} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</div>
</>
);
};
export default WorkflowRuns;