From 0c7ba0503f80f25060f1ecea69199d13ddc285f2 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Tue, 26 Aug 2025 12:58:13 +0800 Subject: [PATCH] feat(ui): enhance WorkflowRunDetail --- .../components/workflow/WorkflowRunDetail.tsx | 276 ++++++++++++------ .../workflow/WorkflowRunDetailDrawer.tsx | 24 +- .../components/workflow/designer/Designer.tsx | 18 +- .../components/workflow/designer/Toolbar.tsx | 147 ++++++---- ui/src/domain/workflowRun.ts | 3 +- ui/src/i18n/locales/en/nls.workflow.runs.json | 8 + ui/src/i18n/locales/zh/nls.workflow.runs.json | 8 + ui/src/pages/ConsoleLayout.tsx | 6 +- ui/src/pages/dashboard/Dashboard.tsx | 61 +++- ui/src/pages/workflows/WorkflowDetailRuns.tsx | 6 +- ui/src/repository/workflow.ts | 2 +- ui/src/repository/workflowRun.ts | 11 +- 12 files changed, 400 insertions(+), 170 deletions(-) diff --git a/ui/src/components/workflow/WorkflowRunDetail.tsx b/ui/src/components/workflow/WorkflowRunDetail.tsx index fff54849a..36ee20e2b 100644 --- a/ui/src/components/workflow/WorkflowRunDetail.tsx +++ b/ui/src/components/workflow/WorkflowRunDetail.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { IconBrowserShare, IconCheck, IconChevronRight, IconDownload, IconSettings2 } from "@tabler/icons-react"; +import { EditorState, FlowLayoutDefault } from "@flowgram.ai/fixed-layout-editor"; +import { IconBrowserShare, IconCheck, IconDots, IconDownload, IconSettings2, IconTransferOut } from "@tabler/icons-react"; import { useRequest } from "ahooks"; -import { App, Button, Collapse, Divider, Dropdown, Empty, Skeleton, Spin, Table, type TableProps, Tooltip, Typography } from "antd"; +import { Alert, App, Button, Card, Divider, Dropdown, Empty, Skeleton, Spin, Table, type TableProps, Tooltip, Typography, theme } from "antd"; import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; @@ -17,6 +18,9 @@ import { listByWorkflowRunId as listLogsByWorkflowRunId } from "@/repository/wor import { mergeCls } from "@/utils/css"; import { getErrMsg } from "@/utils/error"; +import WorkflowDesigner from "./designer/Designer"; +import WorkflowToolbar from "./designer/Toolbar"; +import WorkflowGraphExportModal from "./WorkflowGraphExportModal"; import WorkflowStatus from "./WorkflowStatus"; export interface WorkflowRunDetailProps { @@ -25,21 +29,127 @@ export interface WorkflowRunDetailProps { data: WorkflowRunModel; } -const WorkflowRunDetail = ({ data, ...props }: WorkflowRunDetailProps) => { - return ( -
- - - +const WorkflowRunDetail = ({ className, style, data }: WorkflowRunDetailProps) => { + const { t } = useTranslation(); - - - + return ( +
+ + {data.endedAt + ? t("workflow_run.base.description_with_time_cost", { + trigger: t(`workflow_run.base.trigger.${data.trigger}`), + startedAt: dayjs(data.startedAt).format("YYYY-MM-DD HH:mm:ss"), + timeCost: dayjs(data.endedAt).diff(dayjs(data.startedAt), "second") + "s", + }) + : t("workflow_run.base.description", { + trigger: t(`workflow_run.base.trigger.${data.trigger}`), + startedAt: dayjs(data.startedAt).format("YYYY-MM-DD HH:mm:ss"), + })} +
+ } + showIcon + type={ + { + [WORKFLOW_RUN_STATUSES.SUCCEEDED]: "success" as const, + [WORKFLOW_RUN_STATUSES.FAILED]: "error" as const, + [WORKFLOW_RUN_STATUSES.CANCELED]: "warning" as const, + }[data.status] ?? ("info" as const) + } + /> + +
+ {t("workflow_run.process")} + +
+ +
+ {t("workflow_run.logs")} + +
+ + +
+ {t("workflow_run.artifacts")} + +
); }; +const WorkflowRunProcess = ({ runData }: { runData: WorkflowRunModel }) => { + const { t } = useTranslation(); + + const { token: themeToken } = theme.useToken(); + + const { modalProps: graphExportModalProps, ...graphExportModal } = WorkflowGraphExportModal.useModal(); + + const handleExportClick = () => { + graphExportModal.open({ data: runData.graph! }); + }; + + return ( + <> + + +
+
+
+ + + , + onClick: handleExportClick, + }, + ], + }} + trigger={["click"]} + > +
+
+
+
+
+ + + + ); +}; + const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: string }) => { const { t } = useTranslation(); @@ -131,7 +241,7 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin .map((group) => { const escape = (str: string) => str.replaceAll("\r", "\\r").replaceAll("\n", "\\n"); return ( - group.name + + `#${group.id} ${group.name}` + NEWLINE + group.records .map((record) => { @@ -156,49 +266,48 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin }; return ( - <> - {t("workflow_run.logs")} -
-
-
- -
-
- , - onClick: () => setShowTimestamp(!showTimestamp), - }, - { - key: "show-whitespace", - label: t("workflow_run.logs.menu.show_whitespaces"), - icon: , - onClick: () => setShowWhitespace(!showWhitespace), - }, - { - type: "divider", - }, - { - key: "download-logs", - label: t("workflow_run.logs.menu.download_logs"), - icon: , - onClick: handleDownloadClick, - }, - ], - }} - trigger={["click"]} - > -
+
+
+
+
+
+ , + onClick: () => setShowTimestamp(!showTimestamp), + }, + { + key: "show-whitespace", + label: t("workflow_run.logs.menu.show_whitespaces"), + icon: , + onClick: () => setShowWhitespace(!showWhitespace), + }, + { + type: "divider", + }, + { + key: "download-logs", + label: t("workflow_run.logs.menu.download_logs"), + icon: , + onClick: handleDownloadClick, + }, + ], + }} + trigger={["click"]} + > +
+
- + +
0} fallback={ @@ -207,32 +316,20 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin } > -
- group.id)} - expandIcon={({ isActive }) => } - items={listData.map((group) => { - return { - key: group.id, - classNames: { - header: "text-sm text-stone-200", - body: "text-stone-200", - }, - style: { color: "inherit", border: "none" }, - styles: { - header: { color: "inherit" }, - }, - label: group.name, - children:
{group.records.map((record) => renderRecord(record))}
, - }; - })} - /> -
+ {listData.map((group) => { + return ( +
+
+ {`#${group.id}\u00A0`} + {group.name} +
+
{group.records.map((record) => renderRecord(record))}
+
+ ); + })}
- +
); }; @@ -286,7 +383,7 @@ const WorkflowRunArtifacts = ({ runId }: { runId: string }) => { }, ]; const [tableData, setTableData] = useState([]); - const { loading: tableLoading } = useRequest( + const { loading } = useRequest( () => { return listCertificatesByWorkflowRunId(runId); }, @@ -309,20 +406,17 @@ const WorkflowRunArtifacts = ({ runId }: { runId: string }) => { ); return ( - <> - {t("workflow_run.artifacts")} - - columns={tableColumns} - dataSource={tableData} - loading={tableLoading} - locale={{ - emptyText: , - }} - pagination={false} - rowKey={(record) => record.id} - size="small" - /> - + + columns={tableColumns} + dataSource={tableData} + loading={loading} + locale={{ + emptyText: , + }} + pagination={false} + rowKey={(record) => record.id} + size="small" + /> ); }; diff --git a/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx b/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx index a667323da..77d6f3a48 100644 --- a/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx +++ b/ui/src/components/workflow/WorkflowRunDetailDrawer.tsx @@ -1,6 +1,6 @@ import { startTransition, useCallback, useState } from "react"; import { IconX } from "@tabler/icons-react"; -import { useControllableValue } from "ahooks"; +import { useControllableValue, useGetState } from "ahooks"; import { Button, Drawer, Flex } from "antd"; import Show from "@/components/Show"; @@ -64,7 +64,8 @@ const WorkflowRunDetailDrawer = ({ afterClose, data, loading, trigger, ...props const useDrawer = () => { type DataType = WorkflowRunDetailDrawerProps["data"]; - const [data, setData] = useState(); + const [data, setData, getData] = useGetState(); + const [loading, setLoading] = useState(); const [open, setOpen] = useState(false); const onOpenChange = useCallback((open: boolean) => { @@ -77,17 +78,34 @@ const useDrawer = () => { startTransition(() => { if (!open) { setData(void 0); + setLoading(void 0); } }); }, data, + loading, open, onOpenChange, }, - open: (data: NonNullable) => { + open: ({ data, loading }: { data: NonNullable; loading?: boolean }) => { setData(data); + setLoading(loading); setOpen(true); + + return { + safeUpdate: ({ data, loading }: { data?: NonNullable; loading?: boolean }) => { + if (data != null) { + if (data.id !== getData()?.id) return; // 确保数据不脏读 + + setData(data); + } + + if (loading != null) { + setLoading(loading); + } + }, + }; }, close: () => { setOpen(false); diff --git a/ui/src/components/workflow/designer/Designer.tsx b/ui/src/components/workflow/designer/Designer.tsx index 8acb7e65b..a93b1710b 100644 --- a/ui/src/components/workflow/designer/Designer.tsx +++ b/ui/src/components/workflow/designer/Designer.tsx @@ -26,6 +26,8 @@ export interface DesignerProps { className?: string; style?: React.CSSProperties; children?: React.ReactNode; + defaultEditorState?: string; + defaultLayout?: string; initialData?: FlowDocumentJSON; readonly?: boolean; onDocumentChange?: (ctx: FixedLayoutPluginContext) => void; @@ -39,7 +41,7 @@ export interface DesignerInstance extends FixedLayoutPluginContext { } const Designer = forwardRef( - ({ className, style, children, initialData, readonly, onDocumentChange, onNodeChange, onNodeClick }, ref) => { + ({ className, style, children, defaultEditorState, defaultLayout, initialData, readonly, onDocumentChange, onNodeChange, onNodeClick }, ref) => { const { token: themeToken } = theme.useToken(); const rendered = useRef(false); @@ -47,6 +49,8 @@ const Designer = forwardRef( const flowgramEditorRef = useRef(null); const flowgramEditorProps = useMemo( () => ({ + defaultLayout: defaultLayout, + initialData: initialData, constants: { @@ -121,9 +125,13 @@ const Designer = forwardRef( ], onInit: (ctx) => { - const maybeMobile = ["android", "ios", "iphone", "ipad", "micromessenger"].some((s) => navigator.userAgent.includes(s)); - if (maybeMobile) { - ctx.playground.editorState.changeState(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id); + if (defaultEditorState != null) { + ctx.playground.editorState.changeState(defaultEditorState); + } else { + const maybeMobile = ["android", "ios", "iphone", "ipad", "micromessenger"].some((s) => navigator.userAgent.includes(s)); + if (maybeMobile) { + ctx.playground.editorState.changeState(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id); + } } }, @@ -136,7 +144,7 @@ const Designer = forwardRef( }, 1); }, }), - [themeToken, initialData, readonly, onDocumentChange] + [defaultEditorState, defaultLayout, initialData, readonly, onDocumentChange, themeToken] ); useEffect(() => { diff --git a/ui/src/components/workflow/designer/Toolbar.tsx b/ui/src/components/workflow/designer/Toolbar.tsx index 8759b0c86..0edbe95d9 100644 --- a/ui/src/components/workflow/designer/Toolbar.tsx +++ b/ui/src/components/workflow/designer/Toolbar.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditorState, FlowLayoutDefault, useClientContext, usePlaygroundTools, useRefresh } from "@flowgram.ai/fixed-layout-editor"; import { IconHandStop, IconLayoutCards, IconMatrix, IconMaximize, IconMinus, IconPlus } from "@tabler/icons-react"; -import { Button, Dropdown, Tooltip } from "antd"; +import { Button, type ButtonProps, Dropdown, Tooltip } from "antd"; +import Show from "@/components/Show"; import { mergeCls } from "@/utils/css"; import Minimap from "./Minimap"; @@ -11,9 +12,26 @@ import Minimap from "./Minimap"; export interface ToolbarProps { className?: string; style?: React.CSSProperties; + size?: ButtonProps["size"]; + showLayout?: boolean; + showMinimap?: boolean; + showMouseState?: boolean; + showZoom?: boolean; + showZoomFit?: boolean; + showZoomLevel?: boolean; } -const Toolbar = ({ className, style }: ToolbarProps) => { +const Toolbar = ({ + className, + style, + size, + showLayout = true, + showMinimap = true, + showMouseState = true, + showZoom = true, + showZoomFit = true, + showZoomLevel = true, +}: ToolbarProps) => { const { t } = useTranslation(); const ctx = useClientContext(); @@ -29,6 +47,12 @@ const Toolbar = ({ className, style }: ToolbarProps) => { return () => d.dispose(); }, [playground]); + const buttonIconSize = useMemo(() => { + if (size === "large") return "1.5em"; + if (size === "small") return "1em"; + return "1.25em"; + }, [size]); + const [isMinimapVisible, setIsMinimapVisible] = useState(() => window.screen.availWidth >= 1024); const [isMouseFriendly, setIsMouseFriendly] = useState(() => playground.editorState.is(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id)); @@ -58,55 +82,80 @@ const Toolbar = ({ className, style }: ToolbarProps) => { return (
- - - - - + + + +
); diff --git a/ui/src/domain/workflowRun.ts b/ui/src/domain/workflowRun.ts index 855e5e312..167d5f117 100644 --- a/ui/src/domain/workflowRun.ts +++ b/ui/src/domain/workflowRun.ts @@ -1,4 +1,4 @@ -import { type WorkflowModel } from "./workflow"; +import { type WorkflowGraph, type WorkflowModel } from "./workflow"; export interface WorkflowRunModel extends BaseModel { workflowRef: string; @@ -6,6 +6,7 @@ export interface WorkflowRunModel extends BaseModel { trigger: string; startedAt: ISO8601String; endedAt: ISO8601String; + graph?: WorkflowGraph; error?: string; expand?: { workflowRef?: Pick; diff --git a/ui/src/i18n/locales/en/nls.workflow.runs.json b/ui/src/i18n/locales/en/nls.workflow.runs.json index 525a68079..18aa1e3a3 100644 --- a/ui/src/i18n/locales/en/nls.workflow.runs.json +++ b/ui/src/i18n/locales/en/nls.workflow.runs.json @@ -26,6 +26,14 @@ "workflow_run.props.started_at": "Started at", "workflow_run.props.ended_at": "Ended at", + "workflow_run.base.description": "Triggered {{trigger}} at {{startedAt}}", + "workflow_run.base.description_with_time_cost": "Triggered {{trigger}} at {{startedAt}}. Time cost: {{timeCost}}.", + "workflow_run.base.trigger.scheduled": "scheduledly", + "workflow_run.base.trigger.manual": "manually", + + "workflow_run.process": "Process", + "workflow_run.process.menu.export": "Export", + "workflow_run.logs": "Logs", "workflow_run.logs.menu.show_timestamps": "Show timestamps", "workflow_run.logs.menu.show_whitespaces": "Show whitespaces", diff --git a/ui/src/i18n/locales/zh/nls.workflow.runs.json b/ui/src/i18n/locales/zh/nls.workflow.runs.json index f38749b43..42076c397 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.runs.json +++ b/ui/src/i18n/locales/zh/nls.workflow.runs.json @@ -26,6 +26,14 @@ "workflow_run.props.started_at": "开始时间", "workflow_run.props.ended_at": "完成时间", + "workflow_run.base.description": "{{trigger}}触发于 {{startedAt}}", + "workflow_run.base.description_with_time_cost": "{{trigger}}触发于 {{startedAt}},总计用时 {{timeCost}}。", + "workflow_run.base.trigger.scheduled": "定时", + "workflow_run.base.trigger.manual": "手动", + + "workflow_run.process": "流程", + "workflow_run.process.menu.export": "导出", + "workflow_run.logs": "日志", "workflow_run.logs.menu.show_timestamps": "显示日期时间", "workflow_run.logs.menu.show_whitespaces": "显示转义换行符", diff --git a/ui/src/pages/ConsoleLayout.tsx b/ui/src/pages/ConsoleLayout.tsx index c2964657a..7d7f635f7 100644 --- a/ui/src/pages/ConsoleLayout.tsx +++ b/ui/src/pages/ConsoleLayout.tsx @@ -40,10 +40,10 @@ const ConsoleLayout = () => { }; const handleDocumentClick = () => { - if (i18n.language.startsWith("en")) { - window.open(APP_DOCUMENT_URL + "/en/", "_blank"); - } else { + if (i18n.language.startsWith("zh")) { window.open(APP_DOCUMENT_URL, "_blank"); + } else { + window.open(APP_DOCUMENT_URL + "/en/", "_blank"); } }; diff --git a/ui/src/pages/dashboard/Dashboard.tsx b/ui/src/pages/dashboard/Dashboard.tsx index 23f537c2b..7e322e992 100644 --- a/ui/src/pages/dashboard/Dashboard.tsx +++ b/ui/src/pages/dashboard/Dashboard.tsx @@ -26,13 +26,11 @@ import WorkflowStatus from "@/components/workflow/WorkflowStatus"; import { type Statistics } from "@/domain/statistics"; import { type WorkflowRunModel } from "@/domain/workflowRun"; import { useBrowserTheme } from "@/hooks"; -import { list as listWorkflowRuns } from "@/repository/workflowRun"; +import { get as getWorkflowRun, list as listWorkflowRuns } from "@/repository/workflowRun"; import { mergeCls } from "@/utils/css"; import { getErrMsg } from "@/utils/error"; const Dashboard = () => { - const navigate = useNavigate(); - const { t } = useTranslation(); return ( @@ -48,17 +46,7 @@ const Dashboard = () => {

{t("dashboard.shortcut")}

-
- - - -
+
@@ -224,6 +212,46 @@ const StatisticCards = ({ className, style }: { className?: string; style?: Reac ); }; +const Shortcuts = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { + const navigate = useNavigate(); + + const { t } = useTranslation(); + + return ( +
+
+ + + +
+
+ ); +}; + const WorkflowRunHistoryTable = ({ className, style }: { className?: string; style?: React.CSSProperties }) => { const navigate = useNavigate(); @@ -332,7 +360,10 @@ const WorkflowRunHistoryTable = ({ className, style }: { className?: string; sty const { drawerProps: detailDrawerProps, ...detailDrawer } = WorkflowRunDetailDrawer.useDrawer(); const handleRecordDetailClick = (workflowRun: WorkflowRunModel) => { - detailDrawer.open(workflowRun); + const drawer = detailDrawer.open({ data: workflowRun, loading: true }); + getWorkflowRun(workflowRun.id).then((data) => { + drawer.safeUpdate({ data, loading: false }); + }); }; return ( diff --git a/ui/src/pages/workflows/WorkflowDetailRuns.tsx b/ui/src/pages/workflows/WorkflowDetailRuns.tsx index 7659d35bd..8aafb8761 100644 --- a/ui/src/pages/workflows/WorkflowDetailRuns.tsx +++ b/ui/src/pages/workflows/WorkflowDetailRuns.tsx @@ -16,6 +16,7 @@ import { WORKFLOW_TRIGGERS } from "@/domain/workflow"; import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun"; import { useAppSettings, useZustandShallowSelector } from "@/hooks"; import { + get as getWorkflowRun, list as listWorkflowRuns, remove as removeWorkflowRun, subscribe as subscribeWorkflowRun, @@ -260,7 +261,10 @@ const WorkflowDetailRuns = () => { const { drawerProps: detailDrawerProps, ...detailDrawer } = WorkflowRunDetailDrawer.useDrawer(); const handleRecordDetailClick = (workflowRun: WorkflowRunModel) => { - detailDrawer.open(workflowRun); + const drawer = detailDrawer.open({ data: workflowRun, loading: true }); + getWorkflowRun(workflowRun.id).then((data) => { + drawer.safeUpdate({ data, loading: false }); + }); }; const handleRecordCancelClick = (workflowRun: WorkflowRunModel) => { diff --git a/ui/src/repository/workflow.ts b/ui/src/repository/workflow.ts index 38f8f5f02..e9ee43d92 100644 --- a/ui/src/repository/workflow.ts +++ b/ui/src/repository/workflow.ts @@ -29,7 +29,7 @@ export const list = async (request: ListRequest) => { const perPage = request.perPage || 10; return await pb.collection(COLLECTION_NAME_WORKFLOW).getList(page, perPage, { - expand: request.expand ? "lastRunRef" : void 0, + expand: request.expand ? ["lastRunRef"].join(",") : void 0, fields: [ "id", "name", diff --git a/ui/src/repository/workflowRun.ts b/ui/src/repository/workflowRun.ts index 21e8c179a..72ef9bf16 100644 --- a/ui/src/repository/workflowRun.ts +++ b/ui/src/repository/workflowRun.ts @@ -22,7 +22,7 @@ export const list = async (request: ListRequest) => { const page = request.page || 1; const perPage = request.perPage || 10; return await pb.collection(COLLECTION_NAME_WORKFLOW_RUN).getList(page, perPage, { - expand: request.expand ? "workflowRef" : void 0, + expand: request.expand ? ["workflowRef"].join(",") : void 0, fields: [ "id", "status", @@ -43,6 +43,15 @@ export const list = async (request: ListRequest) => { }); }; +export const get = async (id: string) => { + return await getPocketBase() + .collection(COLLECTION_NAME_WORKFLOW_RUN) + .getOne(id, { + expand: ["workflowRef"].join(","), + requestKey: null, + }); +}; + export const remove = async (record: MaybeModelRecordWithId | MaybeModelRecordWithId[]) => { const pb = getPocketBase();