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";
+}