diff --git a/src/components/shared/PipelineRunDisplay/PipelineRunInfoCondensed.tsx b/src/components/shared/PipelineRunDisplay/PipelineRunInfoCondensed.tsx index 791565611..a483f216e 100644 --- a/src/components/shared/PipelineRunDisplay/PipelineRunInfoCondensed.tsx +++ b/src/components/shared/PipelineRunDisplay/PipelineRunInfoCondensed.tsx @@ -3,7 +3,7 @@ import { InlineStack } from "@/components/ui/layout"; import { Skeleton } from "@/components/ui/skeleton"; import { Paragraph } from "@/components/ui/typography"; import type { PipelineRun } from "@/types/pipelineRun"; -import { formatDate, convertUTCToLocalTime } from "@/utils/date"; +import { convertUTCToLocalTime, formatDate } from "@/utils/date"; import { PipelineRunStatus } from "./components/PipelineRunStatus"; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx index 94581cd95..9406f2e46 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx @@ -1,6 +1,7 @@ import { CheckCircleIcon, CircleDashedIcon, + CircleMinusIcon, ClockIcon, Loader2Icon, XCircleIcon, @@ -10,10 +11,10 @@ import type { ContainerExecutionStatus } from "@/api/types.gen"; import { Icon } from "@/components/ui/icon"; import { QuickTooltip } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import type { RunStatus } from "@/types/pipelineRun"; +import { getExecutionStatusLabel } from "@/utils/executionStatus"; type StatusIndicatorProps = { - status: ContainerExecutionStatus | RunStatus; + status: ContainerExecutionStatus; disabledCache?: boolean; }; @@ -21,7 +22,8 @@ export const StatusIndicator = ({ status, disabledCache = false, }: StatusIndicatorProps) => { - const { style, text, icon } = getStatusMetadata(status); + const { style, icon } = getStatusMetadata(status); + const text = getExecutionStatusLabel(status); return (
@@ -46,12 +48,11 @@ export const StatusIndicator = ({ ); }; -const getStatusMetadata = (status: ContainerExecutionStatus | RunStatus) => { +const getStatusMetadata = (status: ContainerExecutionStatus) => { switch (status) { case "SUCCEEDED": return { style: "bg-emerald-500", - text: "Succeeded", icon: , }; case "FAILED": @@ -59,57 +60,43 @@ const getStatusMetadata = (status: ContainerExecutionStatus | RunStatus) => { case "INVALID": return { style: "bg-red-700", - text: "Failed", icon: , }; case "RUNNING": return { style: "bg-sky-500", - text: "Running", icon: , }; case "PENDING": + case "UNINITIALIZED": return { style: "bg-yellow-500", - text: "Pending", icon: , }; case "CANCELLING": case "CANCELLED": return { style: "bg-gray-800", - text: status === "CANCELLING" ? "Cancelling" : "Cancelled", icon: , }; case "SKIPPED": return { style: "bg-slate-400", - text: "Skipped", - icon: , + icon: , }; case "QUEUED": return { style: "bg-yellow-500", - text: "Queued", icon: , }; case "WAITING_FOR_UPSTREAM": return { style: "bg-slate-500", - text: "Waiting for upstream", - icon: , - }; - case "WAITING": - case "UNINITIALIZED": - return { - style: "bg-yellow-500", - text: "Pending", icon: , }; default: return { style: "bg-slate-300", - text: "Unknown", icon: , }; } diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx index 247465774..91a0007f2 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx @@ -1,11 +1,12 @@ import { type NodeProps } from "@xyflow/react"; import { memo, useMemo } from "react"; +import type { ContainerExecutionStatus } from "@/api/types.gen"; import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider"; import { TaskNodeProvider } from "@/providers/TaskNodeProvider"; -import { getRunStatus } from "@/services/executionService"; import type { TaskNodeData } from "@/types/taskNode"; import { isCacheDisabled } from "@/utils/cache"; +import { deriveExecutionStatusFromStats } from "@/utils/executionStatus"; import { StatusIndicator } from "./StatusIndicator"; import { TaskNodeCard } from "./TaskNodeCard"; @@ -15,16 +16,21 @@ const TaskNode = ({ data, selected }: NodeProps) => { const typedData = useMemo(() => data as TaskNodeData, [data]); - const status = useMemo(() => { + const status: ContainerExecutionStatus | undefined = useMemo(() => { const taskId = typedData.taskId ?? ""; - const statusCounts = executionData?.taskStatusCountsMap.get(taskId); - - if (!statusCounts) { - return undefined; - } - - return getRunStatus(statusCounts); - }, [executionData?.taskStatusCountsMap, typedData.taskId]); + const executionId = + executionData?.details?.child_task_execution_ids?.[taskId]; + if (!executionId) return undefined; + + const statusStats = + executionData?.state?.child_execution_status_stats?.[executionId]; + + return deriveExecutionStatusFromStats(statusStats); + }, [ + executionData?.details?.child_task_execution_ids, + executionData?.state?.child_execution_status_stats, + typedData.taskId, + ]); const disabledCache = isCacheDisabled(typedData.taskSpec); diff --git a/src/components/shared/Status/StatusIcon.tsx b/src/components/shared/Status/StatusIcon.tsx index 07672373f..951d20bc0 100644 --- a/src/components/shared/Status/StatusIcon.tsx +++ b/src/components/shared/Status/StatusIcon.tsx @@ -13,6 +13,10 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + getExecutionStatusLabel, + isContainerExecutionStatus, +} from "@/utils/executionStatus"; const StatusIcon = ({ status, @@ -23,9 +27,11 @@ const StatusIcon = ({ tooltip?: boolean; label?: "run" | "task" | "pipeline"; }) => { + const statusLabel = getStatusLabel(status); + if (tooltip) { const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1); - const tooltipText = `${capitalizedLabel} ${status?.toLowerCase() ?? "unknown"}`; + const tooltipText = `${capitalizedLabel} ${statusLabel.toLowerCase()}`; return ( @@ -43,6 +49,34 @@ const StatusIcon = ({ return ; }; +const getStatusLabel = (status: string | undefined) => { + if (!status) return "Unknown"; + + if (isContainerExecutionStatus(status)) { + return getExecutionStatusLabel(status); + } + + // Aggregate / run-level statuses (derived via getRunStatus) + switch (status) { + case "SUCCEEDED": + return "Succeeded"; + case "FAILED": + return "Failed"; + case "RUNNING": + return "Running"; + case "WAITING": + return "Waiting"; + case "CANCELLED": + return "Cancelled"; + case "SKIPPED": + return "Skipped"; + case "UNKNOWN": + return "Unknown"; + default: + return status; + } +}; + const Icon = ({ status }: { status?: string }) => { switch (status) { case "SUCCEEDED": diff --git a/src/components/shared/TaskDetails/Details.tsx b/src/components/shared/TaskDetails/Details.tsx index 3a134ae01..a0c8f337d 100644 --- a/src/components/shared/TaskDetails/Details.tsx +++ b/src/components/shared/TaskDetails/Details.tsx @@ -23,6 +23,10 @@ import { useCopyToClipboard } from "@/hooks/useCopyToClip"; import useToastNotification from "@/hooks/useToastNotification"; import { cn } from "@/lib/utils"; import type { ComponentSpec } from "@/utils/componentSpec"; +import { + getExecutionStatusLabel, + isContainerExecutionStatus, +} from "@/utils/executionStatus"; import { convertGithubUrlToDirectoryUrl, downloadYamlFromComponentText, @@ -157,10 +161,12 @@ const TaskDetails = ({ {status && (
- Run Status + Execution Status
- {status} + {isContainerExecutionStatus(status) + ? getExecutionStatusLabel(status) + : status}
)} diff --git a/src/providers/TaskNodeProvider.tsx b/src/providers/TaskNodeProvider.tsx index 5b5c02a04..62b98540a 100644 --- a/src/providers/TaskNodeProvider.tsx +++ b/src/providers/TaskNodeProvider.tsx @@ -6,7 +6,6 @@ import useComponentFromUrl from "@/hooks/useComponentFromUrl"; import { useTaskNodeDimensions } from "@/hooks/useTaskNodeDimensions"; import useToastNotification from "@/hooks/useToastNotification"; import type { Annotations } from "@/types/annotations"; -import type { RunStatus } from "@/types/pipelineRun"; import { DEFAULT_TASK_NODE_CALLBACKS, type TaskNodeData, @@ -37,7 +36,7 @@ type TaskNodeState = Readonly<{ readOnly: boolean; disabled: boolean; connectable: boolean; - status?: ContainerExecutionStatus | RunStatus; + status?: ContainerExecutionStatus; isCustomComponent: boolean; dimensions: TaskNodeDimensions; }>; @@ -55,7 +54,7 @@ type TaskNodeProviderProps = { children: ReactNode; data: TaskNodeData; selected: boolean; - status?: ContainerExecutionStatus | RunStatus; + status?: ContainerExecutionStatus; }; export type TaskNodeContextType = { diff --git a/src/utils/executionStatus.test.ts b/src/utils/executionStatus.test.ts new file mode 100644 index 000000000..7775d6758 --- /dev/null +++ b/src/utils/executionStatus.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { + deriveExecutionStatusFromStats, + getExecutionStatusLabel, +} from "@/utils/executionStatus"; + +describe("getExecutionStatusLabel", () => { + it("maps WAITING_FOR_UPSTREAM to the real name", () => { + expect(getExecutionStatusLabel("WAITING_FOR_UPSTREAM")).toBe( + "Waiting for upstream", + ); + }); + + it("maps QUEUED to the real name", () => { + expect(getExecutionStatusLabel("QUEUED")).toBe("Queued"); + }); + + it("maps SYSTEM_ERROR to Failed (per current decision)", () => { + expect(getExecutionStatusLabel("SYSTEM_ERROR")).toBe("Failed"); + }); +}); + +describe("deriveExecutionStatusFromStats", () => { + it("returns undefined when no stats are present", () => { + expect(deriveExecutionStatusFromStats(undefined)).toBeUndefined(); + }); + + it("returns RUNNING if any child is running", () => { + expect(deriveExecutionStatusFromStats({ RUNNING: 1, SUCCEEDED: 3 })).toBe( + "RUNNING", + ); + }); + + it("returns WAITING_FOR_UPSTREAM when present and nothing higher priority exists", () => { + expect(deriveExecutionStatusFromStats({ WAITING_FOR_UPSTREAM: 2 })).toBe( + "WAITING_FOR_UPSTREAM", + ); + }); + + it("returns FAILED when SYSTEM_ERROR is present", () => { + expect(deriveExecutionStatusFromStats({ SYSTEM_ERROR: 1 })).toBe("FAILED"); + }); + + it("returns SUCCEEDED when all known nodes are succeeded", () => { + expect(deriveExecutionStatusFromStats({ SUCCEEDED: 4 })).toBe("SUCCEEDED"); + }); +}); diff --git a/src/utils/executionStatus.ts b/src/utils/executionStatus.ts new file mode 100644 index 000000000..8074232fa --- /dev/null +++ b/src/utils/executionStatus.ts @@ -0,0 +1,124 @@ +import type { ContainerExecutionStatus } from "@/api/types.gen"; + +type ExecutionStatusStats = Record | undefined; + +const CONTAINER_EXECUTION_STATUSES: ReadonlySet = + new Set([ + "INVALID", + "UNINITIALIZED", + "QUEUED", + "WAITING_FOR_UPSTREAM", + "PENDING", + "RUNNING", + "SUCCEEDED", + "FAILED", + "SYSTEM_ERROR", + "CANCELLING", + "CANCELLED", + "SKIPPED", + ]); + +export function isContainerExecutionStatus( + value: string, +): value is ContainerExecutionStatus { + return CONTAINER_EXECUTION_STATUSES.has(value as ContainerExecutionStatus); +} + +/** + * User-facing label for execution/node statuses. + * + * Note: Per `tangle-ui#1540`, we display real status names (e.g. "Waiting for upstream"). + * Per the current decision, SYSTEM_ERROR is displayed as "Failed". + */ +export function getExecutionStatusLabel( + status: ContainerExecutionStatus, +): string { + switch (status) { + case "QUEUED": + return "Queued"; + case "WAITING_FOR_UPSTREAM": + return "Waiting for upstream"; + case "PENDING": + case "UNINITIALIZED": + return "Pending"; + case "RUNNING": + return "Running"; + case "SUCCEEDED": + return "Succeeded"; + case "FAILED": + case "SYSTEM_ERROR": + case "INVALID": + return "Failed"; + case "CANCELLING": + return "Cancelling"; + case "CANCELLED": + return "Cancelled"; + case "SKIPPED": + return "Skipped"; + } +} + +type Counts = Record; + +const EMPTY_COUNTS: Counts = { + INVALID: 0, + UNINITIALIZED: 0, + QUEUED: 0, + WAITING_FOR_UPSTREAM: 0, + PENDING: 0, + RUNNING: 0, + SUCCEEDED: 0, + FAILED: 0, + SYSTEM_ERROR: 0, + CANCELLING: 0, + CANCELLED: 0, + SKIPPED: 0, +}; + +function getCounts(stats: ExecutionStatusStats): Counts { + if (!stats) return EMPTY_COUNTS; + + const counts: Counts = { ...EMPTY_COUNTS }; + + for (const [status, rawCount] of Object.entries(stats)) { + if (!isContainerExecutionStatus(status)) continue; + const count = typeof rawCount === "number" && rawCount > 0 ? rawCount : 0; + counts[status] += count; + } + + return counts; +} + +/** + * Derives a single task/node status from an execution status histogram. + * + * This is needed because some tasks represent subgraphs, where multiple child nodes + * can be in different states; we pick a "dominant" status using a consistent priority. + */ +export function deriveExecutionStatusFromStats( + stats: ExecutionStatusStats, +): ContainerExecutionStatus | undefined { + const counts = getCounts(stats); + + const totalKnown = Object.values(counts).reduce((sum, n) => sum + n, 0); + if (totalKnown === 0) return undefined; + + const failedLike = counts.FAILED + counts.SYSTEM_ERROR + counts.INVALID; + + // Priority (highest → lowest): + // cancelling > cancelled > failed > running > pending > waiting_for_upstream > queued > skipped > succeeded + if (counts.CANCELLING > 0) return "CANCELLING"; + if (counts.CANCELLED > 0) return "CANCELLED"; + if (failedLike > 0) return "FAILED"; + if (counts.RUNNING > 0) return "RUNNING"; + if (counts.PENDING + counts.UNINITIALIZED > 0) return "PENDING"; + if (counts.WAITING_FOR_UPSTREAM > 0) return "WAITING_FOR_UPSTREAM"; + if (counts.QUEUED > 0) return "QUEUED"; + if (counts.SKIPPED > 0) return "SKIPPED"; + + if (counts.SUCCEEDED > 0 && counts.SUCCEEDED === totalKnown) + return "SUCCEEDED"; + + // Fall back to pending for any remaining/unknown mixes (should be rare). + return "PENDING"; +}