diff --git a/package-lock.json b/package-lock.json index 265def002..12b77beee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", @@ -207,7 +206,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -714,7 +712,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -761,7 +758,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1557,7 +1553,6 @@ "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.89.0.tgz", "integrity": "sha512-6cnHppAR6vM8osyWqiCoHy35J3CqFz114ggOLHwaTb795XUnzoP/pdbvyz+TBpukY08QQh69kHMAXdi2Kuq9Ow==", "license": "MIT", - "peer": true, "dependencies": { "@hey-api/codegen-core": "^0.4.0", "@hey-api/json-schema-ref-parser": "1.2.2", @@ -1656,7 +1651,6 @@ "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.3.1.tgz", "integrity": "sha512-Le5XZUjnVqVjkgLYv6yyWgALat/0HpB1XaCPuCZ+GCFki9NvXloSZITIJ0H+wRW7mb9At1SxvohKBbNQbrr/cw==", "license": "MIT", - "peer": true, "dependencies": { "@hyperjump/json-pointer": "^1.1.0", "@hyperjump/uri": "^1.2.0", @@ -2616,37 +2610,6 @@ } } }, - "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", - "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -4026,7 +3989,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.12" }, @@ -4060,7 +4022,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", @@ -4131,7 +4092,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.141.6.tgz", "integrity": "sha512-AqH61axLq2xFaM+B0veGQ4OOzMzr2Ih+qXzBmGRy5e0wMJkr1efPZXLF0K7nEjF++bmL/excew2Br6v9xrZ/5g==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", @@ -4222,7 +4182,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4458,7 +4417,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4468,7 +4426,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4479,7 +4436,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4528,7 +4484,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -4934,7 +4889,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5310,7 +5264,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -5704,8 +5657,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-color": { "version": "3.1.0", @@ -5764,7 +5716,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6038,8 +5989,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.2.1.tgz", "integrity": "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/dexie-react-hooks": { "version": "4.2.0", @@ -6088,7 +6038,8 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true }, "node_modules/dotenv": { "version": "17.2.3", @@ -6419,7 +6370,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6550,7 +6500,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -8228,7 +8177,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -8785,7 +8733,6 @@ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -8821,6 +8768,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -9519,7 +9467,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9546,7 +9493,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -9690,7 +9636,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9700,7 +9645,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10028,7 +9972,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10202,7 +10145,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -10418,6 +10360,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -10439,6 +10382,7 @@ "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz", "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10796,8 +10740,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -11575,7 +11518,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11766,7 +11708,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12320,7 +12261,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/package.json b/package.json index f988e611d..861ee47d0 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", diff --git a/src/components/Home/RunSection/RunRow.tsx b/src/components/Home/RunSection/RunRow.tsx index c89aa6e21..d84efabbe 100644 --- a/src/components/Home/RunSection/RunRow.tsx +++ b/src/components/Home/RunSection/RunRow.tsx @@ -2,13 +2,8 @@ import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent } from "react"; import type { PipelineRunResponse } from "@/api/types.gen"; -import { StatusBar, StatusIcon, StatusText } from "@/components/shared/Status"; +import { StatusBar, StatusIcon } from "@/components/shared/Status"; import { Button } from "@/components/ui/button"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; import { TableCell, TableRow } from "@/components/ui/table"; import { Tooltip, @@ -18,11 +13,8 @@ import { import { Paragraph } from "@/components/ui/typography"; import useToastNotification from "@/hooks/useToastNotification"; import { APP_ROUTES } from "@/routes/router"; -import { - convertExecutionStatsToStatusCounts, - getRunStatus, -} from "@/services/executionService"; import { convertUTCToLocalTime, formatDate } from "@/utils/date"; +import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus"; const RunRow = ({ run }: { run: PipelineRunResponse }) => { const navigate = useNavigate(); @@ -42,8 +34,8 @@ const RunRow = ({ run }: { run: PipelineRunResponse }) => { notify(`"${createdBy}" copied to clipboard`, "success"); }; - const statusCounts = convertExecutionStatsToStatusCounts( - run.execution_status_stats, + const overallStatus = getOverallExecutionStatusFromStats( + run.execution_status_stats ?? undefined, ); const clickThroughUrl = `${APP_ROUTES.RUNS}/${runId}`; @@ -77,26 +69,16 @@ const RunRow = ({ run }: { run: PipelineRunResponse }) => { className="cursor-pointer text-gray-500 text-xs" > - + {name} {`#${runId}`} - - - - - - - - - Status - - - - + + + {run.created_at diff --git a/src/components/PipelineRun/RunDetails.test.tsx b/src/components/PipelineRun/RunDetails.test.tsx index 8c82c7b23..98a4488e9 100644 --- a/src/components/PipelineRun/RunDetails.test.tsx +++ b/src/components/PipelineRun/RunDetails.test.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { screen, waitFor } from "@testing-library/dom"; import { cleanup, render } from "@testing-library/react"; +import { ReactFlowProvider } from "@xyflow/react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { @@ -12,13 +13,11 @@ import { useCheckComponentSpecFromPath } from "@/hooks/useCheckComponentSpecFrom import { usePipelineRunData } from "@/hooks/usePipelineRunData"; import { useBackend } from "@/providers/BackendProvider"; import { ComponentSpecProvider } from "@/providers/ComponentSpecProvider"; +import { ContextPanelProvider } from "@/providers/ContextPanelProvider"; import { ExecutionDataProvider } from "@/providers/ExecutionDataProvider"; -import * as executionService from "@/services/executionService"; import type { ComponentSpec } from "@/utils/componentSpec"; import { RunDetails } from "./RunDetails"; -import { ContextPanelProvider } from "@/providers/ContextPanelProvider"; -import { ReactFlowProvider } from "@xyflow/react"; // Mock the hooks and services vi.mock("@tanstack/react-router", async (importOriginal) => { @@ -41,10 +40,6 @@ vi.mock("@/services/executionService", async (importOriginal) => { await importOriginal(); return { ...actual, - countTaskStatuses: vi.fn(), - getRunStatus: vi.fn(), - isStatusInProgress: vi.fn(), - isStatusComplete: vi.fn(), }; }); vi.mock("@/providers/BackendProvider"); @@ -140,22 +135,6 @@ describe("", () => { queryClient.setQueryData(["pipeline-run-metadata", "123"], mockPipelineRun); - vi.mocked(executionService.countTaskStatuses).mockReturnValue({ - total: 2, - succeeded: 1, - failed: 0, - running: 1, - waiting: 0, - skipped: 0, - cancelled: 0, - }); - - vi.mocked(executionService.getRunStatus).mockReturnValue("RUNNING"); - - vi.mocked(executionService.isStatusInProgress).mockReturnValue(true); - - vi.mocked(executionService.isStatusComplete).mockReturnValue(false); - vi.mocked(useBackend).mockReturnValue({ configured: true, available: true, @@ -249,21 +228,22 @@ describe("", () => { }); test("should NOT render cancel button when status is not RUNNING", async () => { - // arrange - vi.mocked(executionService.countTaskStatuses).mockReturnValue({ - total: 2, - succeeded: 1, - failed: 0, - running: 0, - waiting: 0, - skipped: 0, - cancelled: 1, + // arrange - mock a cancelled execution state (no in-progress statuses) + vi.mocked(usePipelineRunData).mockReturnValue({ + executionData: { + details: mockExecutionDetails, + state: { + child_execution_status_stats: { + execution1: { SUCCEEDED: 1 }, + execution2: { CANCELLED: 1 }, + }, + }, + }, + rootExecutionId: "456", + isLoading: false, + error: null, }); - vi.mocked(executionService.getRunStatus).mockReturnValue("CANCELLED"); - vi.mocked(executionService.isStatusInProgress).mockReturnValue(false); - vi.mocked(executionService.isStatusComplete).mockReturnValue(true); - // act renderWithProviders(); @@ -299,21 +279,22 @@ describe("", () => { describe("Rerun Pipeline Run Button", () => { test("should render rerun button when status is CANCELLED", async () => { - // arrange - vi.mocked(executionService.countTaskStatuses).mockReturnValue({ - total: 2, - succeeded: 1, - failed: 0, - running: 0, - waiting: 0, - skipped: 0, - cancelled: 1, + // arrange - mock a completed execution state (no in-progress statuses) + vi.mocked(usePipelineRunData).mockReturnValue({ + executionData: { + details: mockExecutionDetails, + state: { + child_execution_status_stats: { + execution1: { SUCCEEDED: 1 }, + execution2: { CANCELLED: 1 }, + }, + }, + }, + rootExecutionId: "456", + isLoading: false, + error: null, }); - vi.mocked(executionService.getRunStatus).mockReturnValue("CANCELLED"); - vi.mocked(executionService.isStatusInProgress).mockReturnValue(false); - vi.mocked(executionService.isStatusComplete).mockReturnValue(true); - // act renderWithProviders(); diff --git a/src/components/PipelineRun/RunDetails.tsx b/src/components/PipelineRun/RunDetails.tsx index 9e7cc0a5d..49390cc38 100644 --- a/src/components/PipelineRun/RunDetails.tsx +++ b/src/components/PipelineRun/RunDetails.tsx @@ -1,4 +1,16 @@ +import { + ActionBlock, + type ActionOrReactNode, +} from "@/components/shared/ContextPanel/Blocks/ActionBlock"; +import { ContentBlock } from "@/components/shared/ContextPanel/Blocks/ContentBlock"; +import { ListBlock } from "@/components/shared/ContextPanel/Blocks/ListBlock"; +import { TextBlock } from "@/components/shared/ContextPanel/Blocks/TextBlock"; import { CopyText } from "@/components/shared/CopyText/CopyText"; +import PipelineIO from "@/components/shared/Execution/PipelineIO"; +import { InfoBox } from "@/components/shared/InfoBox"; +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { StatusBar } from "@/components/shared/Status"; +import { TaskImplementation } from "@/components/shared/TaskDetails"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import { useCheckComponentSpecFromPath } from "@/hooks/useCheckComponentSpecFromPath"; @@ -7,24 +19,13 @@ import { useBackend } from "@/providers/BackendProvider"; import { useComponentSpec } from "@/providers/ComponentSpecProvider"; import { useExecutionData } from "@/providers/ExecutionDataProvider"; import { - countTaskStatuses, - getRunStatus, - isStatusComplete, - isStatusInProgress, -} from "@/services/executionService"; + countInProgressFromStats, + flattenExecutionStatusStats, + getExecutionStatusLabel, + getOverallExecutionStatusFromStats, + isExecutionComplete, +} from "@/utils/executionStatus"; -import { - ActionBlock, - type ActionOrReactNode, -} from "../shared/ContextPanel/Blocks/ActionBlock"; -import { ContentBlock } from "../shared/ContextPanel/Blocks/ContentBlock"; -import { ListBlock } from "../shared/ContextPanel/Blocks/ListBlock"; -import { TextBlock } from "../shared/ContextPanel/Blocks/TextBlock"; -import PipelineIO from "../shared/Execution/PipelineIO"; -import { InfoBox } from "../shared/InfoBox"; -import { LoadingScreen } from "../shared/LoadingScreen"; -import { StatusBar, StatusText } from "../shared/Status"; -import { TaskImplementation } from "../shared/TaskDetails"; import { CancelPipelineRunButton } from "./components/CancelPipelineRunButton"; import { ClonePipelineButton } from "./components/ClonePipelineButton"; import { InspectPipelineButton } from "./components/InspectPipelineButton"; @@ -79,11 +80,16 @@ export const RunDetails = () => { ); } - const statusCounts = countTaskStatuses(details, state); - const runStatus = getRunStatus(statusCounts); - const hasRunningTasks = statusCounts.running > 0; - const isInProgress = isStatusInProgress(runStatus) || hasRunningTasks; - const isComplete = isStatusComplete(runStatus); + const executionStatusStats = + metadata?.execution_status_stats ?? + flattenExecutionStatusStats(state.child_execution_status_stats); + + const overallStatus = + getOverallExecutionStatusFromStats(executionStatusStats); + const statusLabel = getExecutionStatusLabel(overallStatus); + + const isInProgress = countInProgressFromStats(executionStatusStats) > 0; + const isComplete = isExecutionComplete(executionStatusStats); const annotations = componentSpec.metadata?.annotations || {}; @@ -154,11 +160,10 @@ export const RunDetails = () => { - {runStatus} + {statusLabel} - - + {Object.keys(annotations).length > 0 && ( diff --git a/src/components/shared/Execution/PipelineIO.tsx b/src/components/shared/Execution/PipelineIO.tsx index d2397775a..72e40e3e3 100644 --- a/src/components/shared/Execution/PipelineIO.tsx +++ b/src/components/shared/Execution/PipelineIO.tsx @@ -1,7 +1,7 @@ import { type ReactNode } from "react"; -import { ContentBlock } from "@/components/shared/ContextPanel/Blocks/ContentBlock"; import { Attribute } from "@/components/shared/ContextPanel/Blocks/Attribute"; +import { ContentBlock } from "@/components/shared/ContextPanel/Blocks/ContentBlock"; import { typeSpecToString } from "@/components/shared/ReactFlow/FlowCanvas/TaskNode/ArgumentsEditor/utils"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; diff --git a/src/components/shared/PipelineRunDisplay/RunOverview.tsx b/src/components/shared/PipelineRunDisplay/RunOverview.tsx index 76c08f203..0d4a84926 100644 --- a/src/components/shared/PipelineRunDisplay/RunOverview.tsx +++ b/src/components/shared/PipelineRunDisplay/RunOverview.tsx @@ -3,11 +3,32 @@ import { useNavigate } from "@tanstack/react-router"; import { StatusBar, StatusText } from "@/components/shared/Status/"; import { cn } from "@/lib/utils"; import { APP_ROUTES } from "@/routes/router"; -import type { PipelineRun } from "@/types/pipelineRun"; +import type { PipelineRun, TaskStatusCounts } from "@/types/pipelineRun"; import { formatDate } from "@/utils/date"; +import type { ExecutionStatusStats } from "@/utils/executionStatus"; import { PipelineRunStatus } from "./components/PipelineRunStatus"; +/** + * Convert TaskStatusCounts (lowercase keys) to ExecutionStatusStats (uppercase keys) + * for use with the simplified StatusBar component. + */ +const statusCountsToExecutionStats = ( + counts: TaskStatusCounts | undefined, +): ExecutionStatusStats | undefined => { + if (!counts) return undefined; + + const stats: ExecutionStatusStats = {}; + if (counts.succeeded > 0) stats.SUCCEEDED = counts.succeeded; + if (counts.failed > 0) stats.FAILED = counts.failed; + if (counts.running > 0) stats.RUNNING = counts.running; + if (counts.pending > 0) stats.PENDING = counts.pending; + if (counts.waiting > 0) stats.WAITING_FOR_UPSTREAM = counts.waiting; + if (counts.skipped > 0) stats.SKIPPED = counts.skipped; + if (counts.cancelled > 0) stats.CANCELLED = counts.cancelled; + return stats; +}; + interface RunOverviewProps { run: PipelineRun; config?: { @@ -90,7 +111,9 @@ const RunOverview = ({ run, config, className = "" }: RunOverviewProps) => { {combinedConfig?.showTaskStatusBar && ( - + )} ); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx index 94581cd95..8d1a96d90 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx @@ -6,14 +6,16 @@ import { XCircleIcon, } from "lucide-react"; -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 { + EXECUTION_STATUS_BG_COLORS, + getExecutionStatusLabel, +} from "@/utils/executionStatus"; type StatusIndicatorProps = { - status: ContainerExecutionStatus | RunStatus; + status: string; disabledCache?: boolean; }; @@ -46,71 +48,34 @@ export const StatusIndicator = ({ ); }; -const getStatusMetadata = (status: ContainerExecutionStatus | RunStatus) => { +const getStatusIcon = (status: string) => { switch (status) { case "SUCCEEDED": - return { - style: "bg-emerald-500", - text: "Succeeded", - icon: , - }; + return ; case "FAILED": case "SYSTEM_ERROR": case "INVALID": - return { - style: "bg-red-700", - text: "Failed", - icon: , - }; + return ; case "RUNNING": - return { - style: "bg-sky-500", - text: "Running", - icon: , - }; + return ; case "PENDING": - return { - style: "bg-yellow-500", - text: "Pending", - icon: , - }; + case "QUEUED": + case "UNINITIALIZED": + case "WAITING_FOR_UPSTREAM": + return ; 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: , - }; - 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: , - }; + return ; default: - return { - style: "bg-slate-300", - text: "Unknown", - icon: , - }; + return ; } }; + +const getStatusMetadata = (status: string) => { + return { + style: EXECUTION_STATUS_BG_COLORS[status] ?? "bg-slate-300", + text: getExecutionStatusLabel(status), + icon: getStatusIcon(status), + }; +}; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx index 247465774..ef4b137c3 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNode.tsx @@ -3,7 +3,6 @@ import { memo, useMemo } from "react"; 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"; @@ -17,14 +16,8 @@ const TaskNode = ({ data, selected }: NodeProps) => { const status = useMemo(() => { const taskId = typedData.taskId ?? ""; - const statusCounts = executionData?.taskStatusCountsMap.get(taskId); - - if (!statusCounts) { - return undefined; - } - - return getRunStatus(statusCounts); - }, [executionData?.taskStatusCountsMap, typedData.taskId]); + return executionData?.taskExecutionStatusMap.get(taskId); + }, [executionData?.taskExecutionStatusMap, typedData.taskId]); const disabledCache = isCacheDisabled(typedData.taskSpec); diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx index fd028b843..7e3ba5365 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/logs.tsx @@ -1,13 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import type { ContainerExecutionStatus } from "@/api/types.gen"; import { CodeViewer } from "@/components/shared/CodeViewer"; import { InfoBox } from "@/components/shared/InfoBox"; import { Link } from "@/components/ui/link"; import { Spinner } from "@/components/ui/spinner"; import { useBackend } from "@/providers/BackendProvider"; -import type { RunStatus } from "@/types/pipelineRun"; import { getBackendStatusString } from "@/utils/backend"; const LogDisplay = ({ @@ -52,9 +50,7 @@ const LogDisplay = ({ ); }; -const isStatusActivelyLogging = ( - status?: ContainerExecutionStatus | RunStatus, -): boolean => { +const isStatusActivelyLogging = (status?: string): boolean => { if (!status) { return false; } @@ -70,9 +66,7 @@ const isStatusActivelyLogging = ( } }; -const shouldStatusHaveLogs = ( - status?: ContainerExecutionStatus | RunStatus, -): boolean => { +const shouldStatusHaveLogs = (status?: string): boolean => { if (!status) { return false; } @@ -104,7 +98,7 @@ const Logs = ({ status, }: { executionId?: string | number; - status?: ContainerExecutionStatus | RunStatus; + status?: string; }) => { const { backendUrl, configured, available } = useBackend(); @@ -194,7 +188,7 @@ export const OpenLogsInNewWindowLink = ({ status, }: { executionId: string; - status?: ContainerExecutionStatus | RunStatus; + status?: string; }) => { const { backendUrl, available } = useBackend(); const logsUrl = `${backendUrl}/api/executions/${executionId}/stream_container_log`; diff --git a/src/components/shared/Status/StatusIcon.tsx b/src/components/shared/Status/StatusIcon.tsx index 07672373f..1d361beb7 100644 --- a/src/components/shared/Status/StatusIcon.tsx +++ b/src/components/shared/Status/StatusIcon.tsx @@ -13,6 +13,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { getExecutionStatusLabel } from "@/utils/executionStatus"; const StatusIcon = ({ status, @@ -25,7 +26,8 @@ const StatusIcon = ({ }) => { if (tooltip) { const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1); - const tooltipText = `${capitalizedLabel} ${status?.toLowerCase() ?? "unknown"}`; + const displayStatus = getExecutionStatusLabel(status); + const tooltipText = `${capitalizedLabel} ${displayStatus}`; return ( @@ -61,7 +63,6 @@ const Icon = ({ status }: { status?: string }) => { return ; case "CANCELLING": return ; - case "WAITING": case "UNINITIALIZED": return ; case "WAITING_FOR_UPSTREAM": diff --git a/src/components/shared/Status/StatusText.tsx b/src/components/shared/Status/StatusText.tsx index 901690fa8..fcd872026 100644 --- a/src/components/shared/Status/StatusText.tsx +++ b/src/components/shared/Status/StatusText.tsx @@ -5,15 +5,12 @@ const STATUS_COLORS: Record = { succeeded: "text-green-500", failed: "text-red-500", running: "text-blue-500", + pending: "text-yellow-600", + waiting: "text-slate-600", skipped: "text-gray-800", - waiting: "text-yellow-600", cancelled: "text-gray-800", }; -const STATUS_DISPLAY_NAMES: Record = { - waiting: "pending", -}; - const StatusText = ({ statusCounts, shorthand, @@ -31,10 +28,9 @@ const StatusText = ({ {Object.entries(statusCounts).map(([key, count]) => { if (key === "total" || count === 0) return null; - const displayKey = STATUS_DISPLAY_NAMES[key] ?? key; const statusText = shorthand - ? `${displayKey[0]}` - : `${displayKey}${count > 1 ? " " : ""}`; + ? `${key[0]}` + : `${key}${count > 1 ? " " : ""}`; const statusColor = STATUS_COLORS[key]; diff --git a/src/components/shared/Status/TaskStatusBar.tsx b/src/components/shared/Status/TaskStatusBar.tsx index b352c7db0..628869187 100644 --- a/src/components/shared/Status/TaskStatusBar.tsx +++ b/src/components/shared/Status/TaskStatusBar.tsx @@ -1,82 +1,118 @@ -import type { TaskStatusCounts } from "@/types/pipelineRun"; +import { InlineStack } from "@/components/ui/layout"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + EXECUTION_STATUS_BG_COLORS, + type ExecutionStatusStats, + getExecutionStatusLabel, +} from "@/utils/executionStatus"; -const getSegmentStyle = (width: string, hatched: boolean = false) => - hatched - ? { - width, - height: "100%", - backgroundImage: - "repeating-linear-gradient(135deg, transparent, transparent 6px, rgba(0,0,0,0.5) 6px, rgba(0,0,0,0.5) 12px)", - backgroundBlendMode: "multiply", - backgroundRepeat: "repeat", - backgroundSize: "512px 24px", - backgroundPosition: "left top", - } - : { width, height: "100%" }; +/** + * Display order for status segments in the bar. + * Ordered from success → in-progress → waiting → errors. + */ +const STATUS_DISPLAY_ORDER = [ + "SUCCEEDED", + "RUNNING", + "PENDING", + "UNINITIALIZED", + "QUEUED", + "WAITING_FOR_UPSTREAM", + "CANCELLING", + "CANCELLED", + "FAILED", + "INVALID", + "SYSTEM_ERROR", + "SKIPPED", +] as const; + +const HATCHED_SEGMENT_CLASS = + "bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.5)_6px,rgba(0,0,0,0.5)_12px)] bg-blend-multiply bg-repeat bg-[length:512px_24px] bg-[position:left_top]"; + +const BAR_CLASS = "h-2 w-full rounded overflow-hidden bg-gray-200"; + +const StatusSegment = ({ + status, + count, + total, + hatched, +}: { + status: string; + count: number; + total: number; + hatched: boolean; +}) => { + const label = getExecutionStatusLabel(status); + const colorClass = EXECUTION_STATUS_BG_COLORS[status] ?? "bg-slate-300"; + const width = `${(count / total) * 100}%`; + + return ( + + + + + + + {count} {label} + + + + ); +}; const TaskStatusBar = ({ - statusCounts, + executionStatusStats, }: { - statusCounts?: TaskStatusCounts; + executionStatusStats?: ExecutionStatusStats | null; }) => { - if (!statusCounts || statusCounts.total === 0) { - return ( - - ); + if (!executionStatusStats) { + return ; } - const { total, succeeded, failed, running, waiting, skipped, cancelled } = - statusCounts; + const entries = Object.entries(executionStatusStats).filter( + ([, count]) => (count ?? 0) > 0, + ); - // Calculate percentages for each segment - const successWidth = `${(succeeded / total) * 100}%`; - const failedWidth = `${(failed / total) * 100}%`; - const runningWidth = `${(running / total) * 100}%`; - const waitingWidth = `${(waiting / total) * 100}%`; - const skippedWidth = `${(skipped / total) * 100}%`; - const cancelledWidth = `${(cancelled / total) * 100}%`; + if (entries.length === 0) { + return ; + } + + const total = entries.reduce((sum, [, count]) => sum + (count ?? 0), 0); - const hatched = cancelled > 0; + const hasCancelled = + (executionStatusStats.CANCELLED ?? 0) > 0 || + (executionStatusStats.CANCELLING ?? 0) > 0; + + // Sort entries by display order + const orderMap = new Map( + STATUS_DISPLAY_ORDER.map((s, i) => [s, i]), + ); + const sortedEntries = entries.sort(([a], [b]) => { + const aOrder = orderMap.get(a) ?? STATUS_DISPLAY_ORDER.length; + const bOrder = orderMap.get(b) ?? STATUS_DISPLAY_ORDER.length; + return aOrder - bOrder; + }); return ( - - {succeeded > 0 && ( - - )} - {failed > 0 && ( - - )} - {running > 0 && ( - - )} - {waiting > 0 && ( - - )} - {skipped > 0 && ( - - )} - {cancelled > 0 && ( - - )} - + + {sortedEntries.map(([status, count]) => ( + + ))} + ); }; diff --git a/src/components/shared/TaskDetails/Details.tsx b/src/components/shared/TaskDetails/Details.tsx index cee2ed3c3..6d681c964 100644 --- a/src/components/shared/TaskDetails/Details.tsx +++ b/src/components/shared/TaskDetails/Details.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from "react"; import { BlockStack } from "@/components/ui/layout"; import { useGuaranteedHydrateComponentReference } from "@/hooks/useHydrateComponentReference"; import type { ComponentReference } from "@/utils/componentSpec"; +import { getExecutionStatusLabel } from "@/utils/executionStatus"; import { ContentBlock } from "../ContextPanel/Blocks/ContentBlock"; import { TextBlock } from "../ContextPanel/Blocks/TextBlock"; @@ -61,12 +62,12 @@ const TaskDetailsInternal = ({ } = annotations; if ( - git_remote_url && - git_remote_branch && - git_relative_dir && - component_yaml_path + typeof git_remote_url === "string" && + typeof git_remote_branch === "string" && + typeof git_relative_dir === "string" && + typeof component_yaml_path === "string" ) { - reconstructedUrl = `https://github.com/${(git_remote_url as string) + reconstructedUrl = `https://github.com/${git_remote_url .replace(/^https:\/\/github\.com\//, "") .replace( /\.git$/, @@ -80,13 +81,17 @@ const TaskDetailsInternal = ({ return ( - + {taskId && ( + + )} - + {status && ( + + )} {executionId && ( ) { - return ; -} - -function HoverCardTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function HoverCardContent({ - className, - align = "center", - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ); -} - -export { HoverCard, HoverCardContent, HoverCardTrigger }; diff --git a/src/hooks/usePipelineRunData.ts b/src/hooks/usePipelineRunData.ts index 0bd11cec4..510c292ab 100644 --- a/src/hooks/usePipelineRunData.ts +++ b/src/hooks/usePipelineRunData.ts @@ -3,13 +3,14 @@ import { Query, useQuery } from "@tanstack/react-query"; import { HOURS } from "@/components/shared/ComponentEditor/constants"; import { useBackend } from "@/providers/BackendProvider"; import { - countTaskStatuses, fetchExecutionDetails, fetchExecutionState, fetchPipelineRun, - getRunStatus, - isStatusComplete, } from "@/services/executionService"; +import { + flattenExecutionStatusStats, + isExecutionComplete, +} from "@/utils/executionStatus"; const useRootExecutionId = (id: string) => { const { backendUrl } = useBackend(); @@ -74,14 +75,14 @@ export const usePipelineRunData = (id: string) => { }, refetchInterval: (data) => { if (data instanceof Query) { - const { details, state } = data.state.data || {}; - if (!details || !state) { + const { state } = data.state.data || {}; + if (!state) { return false; } - const statusCounts = countTaskStatuses(details, state); - const status = getRunStatus(statusCounts); - - return isStatusComplete(status) ? false : 5000; + const stats = flattenExecutionStatusStats( + state.child_execution_status_stats, + ); + return isExecutionComplete(stats) ? false : 5000; } return false; }, diff --git a/src/providers/ExecutionDataProvider.tsx b/src/providers/ExecutionDataProvider.tsx index 47642e145..e2b20a3e8 100644 --- a/src/providers/ExecutionDataProvider.tsx +++ b/src/providers/ExecutionDataProvider.tsx @@ -19,6 +19,7 @@ import { useFetchPipelineRunMetadata, } from "@/services/executionService"; import type { TaskStatusCounts } from "@/types/pipelineRun"; +import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus"; import { useComponentSpec } from "./ComponentSpecProvider"; @@ -40,6 +41,7 @@ interface ExecutionDataContextType { isLoading: boolean; error: Error | null; taskStatusCountsMap: Map; + taskExecutionStatusMap: Map; segments: BreadcrumbSegment[]; } @@ -78,6 +80,30 @@ const buildTaskStatusCountsMap = ( return taskStatusCountsMap; }; +const buildTaskExecutionStatusMap = ( + details?: GetExecutionInfoResponse, + state?: GetGraphExecutionStateResponse, +): Map => { + const taskExecutionStatusMap = new Map(); + + if (!details?.child_task_execution_ids) { + return taskExecutionStatusMap; + } + + Object.entries(details.child_task_execution_ids).forEach( + ([taskId, executionId]) => { + const statusStats = state?.child_execution_status_stats?.[executionId]; + const aggregated = getOverallExecutionStatusFromStats(statusStats); + + if (aggregated) { + taskExecutionStatusMap.set(taskId, aggregated); + } + }, + ); + + return taskExecutionStatusMap; +}; + const findExecutionIdAtPath = ( path: string[], rootExecutionId: string | undefined, @@ -276,6 +302,11 @@ export function ExecutionDataProvider({ [details, state], ); + const taskExecutionStatusMap = useMemo( + () => buildTaskExecutionStatusMap(details, state), + [details, state], + ); + const value = useMemo( () => ({ currentExecutionId, @@ -289,6 +320,7 @@ export function ExecutionDataProvider({ isLoading, error, taskStatusCountsMap, + taskExecutionStatusMap, segments, }), [ @@ -303,6 +335,7 @@ export function ExecutionDataProvider({ isLoading, error, taskStatusCountsMap, + taskExecutionStatusMap, segments, ], ); diff --git a/src/providers/TaskNodeProvider.tsx b/src/providers/TaskNodeProvider.tsx index 5b5c02a04..2cf5c9862 100644 --- a/src/providers/TaskNodeProvider.tsx +++ b/src/providers/TaskNodeProvider.tsx @@ -1,12 +1,10 @@ import { useReactFlow } from "@xyflow/react"; import { type ReactNode, useCallback, useMemo } from "react"; -import type { ContainerExecutionStatus } from "@/api/types.gen"; 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 +35,7 @@ type TaskNodeState = Readonly<{ readOnly: boolean; disabled: boolean; connectable: boolean; - status?: ContainerExecutionStatus | RunStatus; + status?: string; isCustomComponent: boolean; dimensions: TaskNodeDimensions; }>; @@ -55,7 +53,7 @@ type TaskNodeProviderProps = { children: ReactNode; data: TaskNodeData; selected: boolean; - status?: ContainerExecutionStatus | RunStatus; + status?: string; }; export type TaskNodeContextType = { diff --git a/src/routes/PipelineRun/PipelineRun.test.tsx b/src/routes/PipelineRun/PipelineRun.test.tsx index c7470fcb2..40476ea65 100644 --- a/src/routes/PipelineRun/PipelineRun.test.tsx +++ b/src/routes/PipelineRun/PipelineRun.test.tsx @@ -63,27 +63,19 @@ vi.mock("@/services/executionService", async (importOriginal) => { refetch: () => {}, enabled: false, }), - countTaskStatuses: vi.fn(), - getRunStatus: vi.fn(() => "RUNNING"), convertExecutionStatsToStatusCounts: vi.fn((stats) => ({ succeeded: stats?.SUCCEEDED || 0, failed: stats?.FAILED || 0, running: stats?.RUNNING || 0, - waiting: stats?.WAITING_FOR_UPSTREAM || stats?.WAITING || 0, + pending: stats?.PENDING || 0, + waiting: + stats?.WAITING_FOR_UPSTREAM || stats?.WAITING || stats?.QUEUED || 0, cancelled: stats?.CANCELLED || 0, total: Object.values(stats || {}).reduce( (a: number, b) => a + (b as number), 0, ), })), - STATUS: { - SUCCEEDED: "SUCCEEDED", - FAILED: "FAILED", - RUNNING: "RUNNING", - WAITING: "WAITING", - CANCELLED: "CANCELLED", - UNKNOWN: "UNKNOWN", - }, }; }); diff --git a/src/routes/PipelineRun/PipelineRun.tsx b/src/routes/PipelineRun/PipelineRun.tsx index 51253aa30..f17282e78 100644 --- a/src/routes/PipelineRun/PipelineRun.tsx +++ b/src/routes/PipelineRun/PipelineRun.tsx @@ -16,13 +16,12 @@ import { ExecutionDataProvider, useExecutionData, } from "@/providers/ExecutionDataProvider"; -import { - countTaskStatuses, - getRunStatus, - STATUS, -} from "@/services/executionService"; import { getBackendStatusString } from "@/utils/backend"; import type { ComponentSpec } from "@/utils/componentSpec"; +import { + flattenExecutionStatusStats, + getOverallExecutionStatusFromStats, +} from "@/utils/executionStatus"; const PipelineRunContent = () => { const { setComponentSpec, clearComponentSpec, componentSpec } = @@ -46,9 +45,12 @@ const PipelineRunContent = () => { return; } - const statusCounts = countTaskStatuses(details, state); - const pipelineStatus = getRunStatus(statusCounts); - const iconStatus = mapRunStatusToFavicon(pipelineStatus); + const executionStatusStats = flattenExecutionStatusStats( + state.child_execution_status_stats, + ); + const overallStatus = + getOverallExecutionStatusFromStats(executionStatusStats); + const iconStatus = mapExecutionStatusToFavicon(overallStatus); faviconManager.updateFavicon(iconStatus); return () => { @@ -152,21 +154,26 @@ const PipelineRun = () => { export default PipelineRun; -const mapRunStatusToFavicon = ( - runStatus: string, +const mapExecutionStatusToFavicon = ( + status: string | undefined, ): "success" | "failed" | "loading" | "paused" | "default" => { - switch (runStatus) { - case STATUS.SUCCEEDED: + switch (status) { + case "SUCCEEDED": return "success"; - case STATUS.FAILED: + case "FAILED": + case "SYSTEM_ERROR": + case "INVALID": return "failed"; - case STATUS.RUNNING: + case "RUNNING": + case "PENDING": + case "QUEUED": + case "WAITING_FOR_UPSTREAM": + case "CANCELLING": + case "UNINITIALIZED": return "loading"; - case STATUS.WAITING: - return "paused"; - case STATUS.CANCELLED: + case "CANCELLED": + case "SKIPPED": return "paused"; - case STATUS.UNKNOWN: default: return "default"; } diff --git a/src/services/executionService.test.ts b/src/services/executionService.test.ts index 5437fee5e..f344dbbef 100644 --- a/src/services/executionService.test.ts +++ b/src/services/executionService.test.ts @@ -1,175 +1,173 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, test } from "vitest"; -import type { TaskStatusCounts } from "@/types/pipelineRun"; +import { convertExecutionStatsToStatusCounts } from "./executionService"; -import { getRunStatus, STATUS } from "./executionService"; +describe("convertExecutionStatsToStatusCounts()", () => { + test("returns empty counts when stats is null", () => { + const result = convertExecutionStatsToStatusCounts(null); -describe("getRunStatus()", () => { - it("should return CANCELLED when there are cancelled tasks", () => { - const statusData: TaskStatusCounts = { - total: 5, - succeeded: 2, - failed: 1, - running: 1, + expect(result).toEqual({ + total: 0, + succeeded: 0, + failed: 0, + running: 0, + pending: 0, waiting: 0, skipped: 0, - cancelled: 1, - }; - - expect(getRunStatus(statusData)).toBe(STATUS.CANCELLED); + cancelled: 0, + }); }); - it("should return FAILED when there are failed tasks but no cancelled tasks", () => { - const statusData: TaskStatusCounts = { - total: 4, - succeeded: 1, - failed: 2, - running: 1, + test("returns empty counts when stats is undefined", () => { + const result = convertExecutionStatsToStatusCounts(undefined); + + expect(result).toEqual({ + total: 0, + succeeded: 0, + failed: 0, + running: 0, + pending: 0, waiting: 0, skipped: 0, cancelled: 0, - }; + }); + }); - expect(getRunStatus(statusData)).toBe(STATUS.FAILED); + test("maps SUCCEEDED to succeeded", () => { + const result = convertExecutionStatsToStatusCounts({ SUCCEEDED: 5 }); + + expect(result.succeeded).toBe(5); + expect(result.total).toBe(5); }); - it("should return RUNNING when there are running tasks but no cancelled or failed tasks", () => { - const statusData: TaskStatusCounts = { - total: 4, - succeeded: 1, - failed: 0, - running: 2, - waiting: 1, - skipped: 0, - cancelled: 0, - }; + test("maps FAILED to failed", () => { + const result = convertExecutionStatsToStatusCounts({ FAILED: 3 }); - expect(getRunStatus(statusData)).toBe(STATUS.RUNNING); + expect(result.failed).toBe(3); + expect(result.total).toBe(3); }); - it("should return WAITING when there are waiting tasks but no cancelled, failed, or running tasks", () => { - const statusData: TaskStatusCounts = { - total: 3, - succeeded: 1, - failed: 0, - running: 0, - waiting: 2, - skipped: 0, - cancelled: 0, - }; + test("maps SYSTEM_ERROR to failed", () => { + const result = convertExecutionStatsToStatusCounts({ SYSTEM_ERROR: 2 }); - expect(getRunStatus(statusData)).toBe(STATUS.WAITING); + expect(result.failed).toBe(2); + expect(result.total).toBe(2); }); - it("should return SUCCEEDED when there are succeeded tasks but no other active/problematic tasks", () => { - const statusData: TaskStatusCounts = { - total: 3, - succeeded: 3, - failed: 0, - running: 0, - waiting: 0, - skipped: 0, - cancelled: 0, - }; + test("maps INVALID to failed", () => { + const result = convertExecutionStatsToStatusCounts({ INVALID: 1 }); - expect(getRunStatus(statusData)).toBe(STATUS.SUCCEEDED); + expect(result.failed).toBe(1); + expect(result.total).toBe(1); }); - it("should return UNKNOWN when all task counts are zero", () => { - const statusData: TaskStatusCounts = { - total: 0, - succeeded: 0, - failed: 0, - running: 0, - waiting: 0, - skipped: 0, - cancelled: 0, - }; + test("maps RUNNING to running", () => { + const result = convertExecutionStatsToStatusCounts({ RUNNING: 4 }); - expect(getRunStatus(statusData)).toBe(STATUS.UNKNOWN); + expect(result.running).toBe(4); + expect(result.total).toBe(4); }); - it("should return SKIPPED when only skipped tasks exist", () => { - const statusData: TaskStatusCounts = { - total: 2, - succeeded: 0, - failed: 0, - running: 0, - waiting: 0, - skipped: 2, - cancelled: 0, - }; + test("maps STARTING to running", () => { + const result = convertExecutionStatsToStatusCounts({ STARTING: 2 }); - expect(getRunStatus(statusData)).toBe(STATUS.SKIPPED); + expect(result.running).toBe(2); + expect(result.total).toBe(2); }); - it("should prioritize CANCELLED over all other statuses", () => { - const statusData: TaskStatusCounts = { - total: 6, - succeeded: 1, - failed: 1, - running: 1, - waiting: 1, - skipped: 1, - cancelled: 1, - }; + test("maps PENDING to pending", () => { + const result = convertExecutionStatsToStatusCounts({ PENDING: 3 }); - expect(getRunStatus(statusData)).toBe(STATUS.CANCELLED); + expect(result.pending).toBe(3); + expect(result.total).toBe(3); }); - it("should prioritize FAILED over RUNNING, WAITING, and SUCCEEDED", () => { - const statusData: TaskStatusCounts = { - total: 5, - succeeded: 1, - failed: 1, - running: 1, - waiting: 1, - skipped: 1, - cancelled: 0, - }; + test("maps SKIPPED to skipped", () => { + const result = convertExecutionStatsToStatusCounts({ SKIPPED: 2 }); - expect(getRunStatus(statusData)).toBe(STATUS.FAILED); + expect(result.skipped).toBe(2); + expect(result.total).toBe(2); }); - it("should prioritize RUNNING over SKIPPED, WAITING, and SUCCEEDED", () => { - const statusData: TaskStatusCounts = { - total: 4, - succeeded: 1, - failed: 0, - running: 1, - waiting: 1, - skipped: 1, - cancelled: 0, - }; + test("maps UPSTREAM_FAILED_OR_SKIPPED to skipped", () => { + const result = convertExecutionStatsToStatusCounts({ + UPSTREAM_FAILED_OR_SKIPPED: 1, + }); - expect(getRunStatus(statusData)).toBe(STATUS.RUNNING); + expect(result.skipped).toBe(1); + expect(result.total).toBe(1); }); - it("should prioritize SKIPPED over WAITING and SUCCEEDED", () => { - const statusData: TaskStatusCounts = { - total: 3, - succeeded: 1, - failed: 0, - running: 0, - waiting: 1, - skipped: 1, - cancelled: 0, - }; + test("maps CANCELLED to cancelled", () => { + const result = convertExecutionStatsToStatusCounts({ CANCELLED: 2 }); - expect(getRunStatus(statusData)).toBe(STATUS.SKIPPED); + expect(result.cancelled).toBe(2); + expect(result.total).toBe(2); }); - it("should prioritize WAITING over SUCCEEDED when nothing else is active", () => { - const statusData: TaskStatusCounts = { - total: 3, - succeeded: 1, + test("maps CANCELLING to cancelled", () => { + const result = convertExecutionStatsToStatusCounts({ CANCELLING: 1 }); + + expect(result.cancelled).toBe(1); + expect(result.total).toBe(1); + }); + + test("maps unknown statuses to waiting", () => { + const result = convertExecutionStatsToStatusCounts({ + WAITING_FOR_UPSTREAM: 3, + QUEUED: 2, + UNINITIALIZED: 1, + }); + + expect(result.waiting).toBe(6); + expect(result.total).toBe(6); + }); + + test("aggregates multiple error statuses into failed", () => { + const result = convertExecutionStatsToStatusCounts({ + FAILED: 2, + SYSTEM_ERROR: 1, + INVALID: 1, + UPSTREAM_FAILED: 1, + }); + + expect(result.failed).toBe(5); + expect(result.total).toBe(5); + }); + + test("correctly totals all status types", () => { + const result = convertExecutionStatsToStatusCounts({ + SUCCEEDED: 10, + FAILED: 2, + RUNNING: 3, + PENDING: 1, + WAITING_FOR_UPSTREAM: 4, + SKIPPED: 1, + CANCELLED: 1, + }); + + expect(result.succeeded).toBe(10); + expect(result.failed).toBe(2); + expect(result.running).toBe(3); + expect(result.pending).toBe(1); + expect(result.waiting).toBe(4); + expect(result.skipped).toBe(1); + expect(result.cancelled).toBe(1); + expect(result.total).toBe(22); + }); + + test("handles empty stats object", () => { + const result = convertExecutionStatsToStatusCounts({}); + + expect(result).toEqual({ + total: 0, + succeeded: 0, failed: 0, running: 0, - waiting: 1, + pending: 0, + waiting: 0, skipped: 0, cancelled: 0, - }; - - expect(getRunStatus(statusData)).toBe(STATUS.WAITING); + }); }); }); diff --git a/src/services/executionService.ts b/src/services/executionService.ts index 0503ac42f..26a23e639 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -5,15 +5,18 @@ import type { GetArtifactsApiExecutionsIdArtifactsGetResponse, GetContainerExecutionStateResponse, GetExecutionInfoResponse, - GetGraphExecutionStateResponse, PipelineRunResponse, } from "@/api/types.gen"; import { useBackend } from "@/providers/BackendProvider"; -import type { RunStatus, TaskStatusCounts } from "@/types/pipelineRun"; +import type { TaskStatusCounts } from "@/types/pipelineRun"; import { DEFAULT_RATE_LIMIT_RPS, TWENTY_FOUR_HOURS_IN_MS, } from "@/utils/constants"; +import { + flattenExecutionStatusStats, + getOverallExecutionStatusFromStats, +} from "@/utils/executionStatus"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; import { rateLimit } from "@/utils/rateLimit"; @@ -77,28 +80,26 @@ export const useFetchContainerExecutionState = ( }; /** - * Experimental function to fetch execution status without fetching execution details. - * - * @param executionId - * @returns + * Lightweight function to fetch execution status without fetching full execution details. + * Returns the highest priority server status from the execution's child tasks. */ export const fetchExecutionStatusLight = rateLimit( - async (executionId: string): Promise => { + async (executionId: string): Promise => { try { - const defaultResponse = { child_execution_status_stats: {} }; - const result = await getGraphExecutionStateApiExecutionsIdStateGet({ path: { id: executionId, }, }); - const stateData = - result.response.status === 200 - ? (result.data ?? defaultResponse) - : defaultResponse; + if (result.response.status !== 200 || !result.data) { + return undefined; + } - return getRunStatus(countTaskStatusesLight(stateData)); + const stats = flattenExecutionStatusStats( + result.data.child_execution_status_stats, + ); + return getOverallExecutionStatusFromStats(stats); } catch (error) { console.error( `Error fetching task statuses for run ${executionId}:`, @@ -113,97 +114,6 @@ export const fetchExecutionStatusLight = rateLimit( }, ); -/** - * Experimental function to count task statuses without fetching execution details. - */ -const countTaskStatusesLight = ( - stateData: GetGraphExecutionStateResponse, -): TaskStatusCounts => { - const statusCounts = { - total: 0, - succeeded: 0, - failed: 0, - running: 0, - waiting: 0, - skipped: 0, - cancelled: 0, - }; - - if (stateData.child_execution_status_stats) { - Object.values(stateData.child_execution_status_stats).forEach( - (statusStats) => { - if (statusStats) { - const childStatusCounts = - convertExecutionStatsToStatusCounts(statusStats); - const aggregateStatus = getRunStatus(childStatusCounts); - const mappedStatus = mapStatus(aggregateStatus); - statusCounts[mappedStatus as keyof TaskStatusCounts]++; - } else { - // If no status stats, assume waiting, likely we may receive none at all - statusCounts.waiting++; - } - }, - ); - } - - const total = - statusCounts.succeeded + - statusCounts.failed + - statusCounts.running + - statusCounts.waiting + - statusCounts.skipped + - statusCounts.cancelled; - - return { ...statusCounts, total }; -}; - -/** - * Status constants for determining overall run status based on task statuses. - */ -export const STATUS: Record = { - FAILED: "FAILED", - RUNNING: "RUNNING", - SUCCEEDED: "SUCCEEDED", - WAITING: "WAITING", - CANCELLED: "CANCELLED", - SKIPPED: "SKIPPED", - UNKNOWN: "UNKNOWN", -} as const; - -export const getRunStatus = (statusData: TaskStatusCounts): RunStatus => { - if (statusData.cancelled > 0) { - return STATUS.CANCELLED; - } - if (statusData.failed > 0) { - return STATUS.FAILED; - } - if (statusData.running > 0) { - return STATUS.RUNNING; - } - if (statusData.skipped > 0) { - return STATUS.SKIPPED; - } - if (statusData.waiting > 0) { - return STATUS.WAITING; - } - if (statusData.total > 0 && statusData.succeeded === statusData.total) { - return STATUS.SUCCEEDED; - } - return STATUS.UNKNOWN; -}; - -export const isStatusInProgress = (status: string = "") => { - return status === STATUS.RUNNING || status === STATUS.WAITING; -}; - -export const isStatusComplete = (status: string = "") => { - return ( - status === STATUS.SUCCEEDED || - status === STATUS.FAILED || - status === STATUS.CANCELLED - ); -}; - const mapStatus = (status: string) => { switch (status) { case "SUCCEEDED": @@ -219,6 +129,8 @@ const mapStatus = (status: string) => { case "RUNNING": case "STARTING": return "running"; + case "PENDING": + return "pending"; case "CANCELLING": case "CANCELLED": return "cancelled"; @@ -227,58 +139,6 @@ const mapStatus = (status: string) => { } }; -/** - * Count task statuses from API response. - * - * For subgraphs with multiple task statuses, determines the aggregate status - * using priority: CANCELLED > FAILED > RUNNING > SKIPPED > WAITING > SUCCEEDED - */ -export const countTaskStatuses = ( - details: GetExecutionInfoResponse, - stateData: GetGraphExecutionStateResponse, -): TaskStatusCounts => { - const statusCounts = { - total: 0, - succeeded: 0, - failed: 0, - running: 0, - waiting: 0, - skipped: 0, - cancelled: 0, - }; - - if ( - details.child_task_execution_ids && - stateData.child_execution_status_stats - ) { - Object.values(details.child_task_execution_ids).forEach((executionId) => { - const executionIdStr = String(executionId); - const statusStats = - stateData.child_execution_status_stats[executionIdStr]; - - if (statusStats) { - const childStatusCounts = - convertExecutionStatsToStatusCounts(statusStats); - const aggregateStatus = getRunStatus(childStatusCounts); - const mappedStatus = mapStatus(aggregateStatus); - statusCounts[mappedStatus as keyof TaskStatusCounts]++; - } else { - statusCounts.waiting++; - } - }); - } - - const total = - statusCounts.succeeded + - statusCounts.failed + - statusCounts.running + - statusCounts.waiting + - statusCounts.skipped + - statusCounts.cancelled; - - return { ...statusCounts, total }; -}; - export const getExecutionArtifacts = async ( executionId: string, backendUrl: string, @@ -302,6 +162,7 @@ export const convertExecutionStatsToStatusCounts = ( succeeded: 0, failed: 0, running: 0, + pending: 0, waiting: 0, skipped: 0, cancelled: 0, @@ -320,6 +181,7 @@ export const convertExecutionStatsToStatusCounts = ( statusCounts.succeeded + statusCounts.failed + statusCounts.running + + statusCounts.pending + statusCounts.waiting + statusCounts.skipped + statusCounts.cancelled; diff --git a/src/types/pipelineRun.ts b/src/types/pipelineRun.ts index eedc69ce4..df2619e95 100644 --- a/src/types/pipelineRun.ts +++ b/src/types/pipelineRun.ts @@ -1,15 +1,3 @@ -/** - * Possible status values for a pipeline run, derived from aggregating task statuses - */ -export type RunStatus = - | "FAILED" - | "RUNNING" - | "SUCCEEDED" - | "WAITING" - | "CANCELLED" - | "SKIPPED" - | "UNKNOWN"; - export interface PipelineRun { id: number; root_execution_id: number; @@ -17,7 +5,7 @@ export interface PipelineRun { created_by: string; pipeline_name: string; pipeline_digest?: string; - status?: RunStatus; + status?: string; statusCounts?: TaskStatusCounts; } @@ -26,6 +14,7 @@ export interface TaskStatusCounts { succeeded: number; failed: number; running: number; + pending: number; waiting: number; skipped: number; cancelled: number; diff --git a/src/utils/executionStatus.test.ts b/src/utils/executionStatus.test.ts new file mode 100644 index 000000000..f4def9d8d --- /dev/null +++ b/src/utils/executionStatus.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; + +import type { GetGraphExecutionStateResponse } from "@/api/types.gen"; +import { + countInProgressFromStats, + flattenExecutionStatusStats, + getExecutionStatusLabel, + getOverallExecutionStatusFromStats, + isExecutionComplete, +} from "@/utils/executionStatus"; + +type ChildExecutionStatusStats = + GetGraphExecutionStateResponse["child_execution_status_stats"]; + +describe("getExecutionStatusLabel()", () => { + test("maps execution/task statuses to the desired display labels", () => { + expect(getExecutionStatusLabel("QUEUED")).toBe("Queued"); + expect(getExecutionStatusLabel("WAITING_FOR_UPSTREAM")).toBe( + "Waiting for upstream", + ); + expect(getExecutionStatusLabel("PENDING")).toBe("Pending"); + expect(getExecutionStatusLabel("RUNNING")).toBe("Running"); + expect(getExecutionStatusLabel("SUCCEEDED")).toBe("Succeeded"); + expect(getExecutionStatusLabel("FAILED")).toBe("Failed"); + expect(getExecutionStatusLabel("SYSTEM_ERROR")).toBe("System error"); + expect(getExecutionStatusLabel("SKIPPED")).toBe("Skipped"); + expect(getExecutionStatusLabel("CANCELLED")).toBe("Cancelled"); + expect(getExecutionStatusLabel("CANCELLING")).toBe("Cancelling"); + expect(getExecutionStatusLabel("INVALID")).toBe("Invalid"); + expect(getExecutionStatusLabel("UNINITIALIZED")).toBe("Uninitialized"); + }); + + test("passes through unknown statuses one-to-one", () => { + expect(getExecutionStatusLabel("SOME_NEW_STATUS")).toBe("SOME_NEW_STATUS"); + }); + + test("returns Unknown when status is undefined", () => { + expect(getExecutionStatusLabel(undefined)).toBe("Unknown"); + }); +}); + +describe("flattenExecutionStatusStats()", () => { + test("returns empty object for null/undefined input", () => { + expect(flattenExecutionStatusStats(null)).toEqual({}); + expect(flattenExecutionStatusStats(undefined)).toEqual({}); + }); + + test("aggregates stats from multiple child executions", () => { + const childStats = { + execution1: { SUCCEEDED: 2, RUNNING: 1 }, + execution2: { SUCCEEDED: 1, FAILED: 1 }, + execution3: { RUNNING: 2, PENDING: 3 }, + }; + + expect(flattenExecutionStatusStats(childStats)).toEqual({ + SUCCEEDED: 3, + RUNNING: 3, + FAILED: 1, + PENDING: 3, + }); + }); + + test("handles undefined entries in child stats", () => { + // Runtime data may have undefined entries even if the type doesn't expect it + const childStats = { + execution1: { SUCCEEDED: 1 }, + execution2: undefined, + execution3: { FAILED: 1 }, + } as unknown as ChildExecutionStatusStats; + + expect(flattenExecutionStatusStats(childStats)).toEqual({ + SUCCEEDED: 1, + FAILED: 1, + }); + }); + + test("skips zero counts", () => { + const childStats = { + execution1: { SUCCEEDED: 1, FAILED: 0 }, + }; + + expect(flattenExecutionStatusStats(childStats)).toEqual({ + SUCCEEDED: 1, + }); + }); +}); + +describe("getOverallExecutionStatusFromStats()", () => { + test("returns undefined for null/undefined/empty stats", () => { + expect(getOverallExecutionStatusFromStats(null)).toBeUndefined(); + expect(getOverallExecutionStatusFromStats(undefined)).toBeUndefined(); + expect(getOverallExecutionStatusFromStats({})).toBeUndefined(); + expect( + getOverallExecutionStatusFromStats({ RUNNING: 0, QUEUED: 0 }), + ).toBeUndefined(); + }); + + test("returns the single status when only one is present", () => { + expect(getOverallExecutionStatusFromStats({ QUEUED: 2 })).toBe("QUEUED"); + expect(getOverallExecutionStatusFromStats({ SUCCEEDED: 1 })).toBe( + "SUCCEEDED", + ); + }); + + test("returns the highest priority status present", () => { + expect( + getOverallExecutionStatusFromStats({ + SUCCEEDED: 10, + WAITING_FOR_UPSTREAM: 1, + SYSTEM_ERROR: 1, + }), + ).toBe("SYSTEM_ERROR"); + + expect( + getOverallExecutionStatusFromStats({ + FAILED: 1, + RUNNING: 5, + }), + ).toBe("FAILED"); + + expect( + getOverallExecutionStatusFromStats({ + RUNNING: 1, + PENDING: 9, + }), + ).toBe("RUNNING"); + }); + + test("returns raw status values (use getExecutionStatusLabel for display)", () => { + expect( + getOverallExecutionStatusFromStats({ + INVALID: 1, + SUCCEEDED: 5, + }), + ).toBe("INVALID"); + + expect( + getOverallExecutionStatusFromStats({ + UNINITIALIZED: 1, + SUCCEEDED: 5, + }), + ).toBe("UNINITIALIZED"); + }); +}); + +describe("countInProgressFromStats()", () => { + test("counts all in-progress statuses", () => { + expect( + countInProgressFromStats({ + RUNNING: 2, + PENDING: 1, + QUEUED: 3, + SUCCEEDED: 10, + }), + ).toBe(6); + }); + + test("returns 0 when no in-progress statuses", () => { + expect( + countInProgressFromStats({ + SUCCEEDED: 5, + FAILED: 2, + }), + ).toBe(0); + }); + + test("counts all in-progress status types", () => { + expect( + countInProgressFromStats({ + RUNNING: 1, + PENDING: 1, + QUEUED: 1, + WAITING_FOR_UPSTREAM: 1, + CANCELLING: 1, + UNINITIALIZED: 1, + }), + ).toBe(6); + }); +}); + +describe("isExecutionComplete()", () => { + test("returns true when all tasks are in terminal states", () => { + expect( + isExecutionComplete({ + SUCCEEDED: 5, + FAILED: 2, + }), + ).toBe(true); + }); + + test("returns false when any tasks are in progress", () => { + expect( + isExecutionComplete({ + SUCCEEDED: 5, + RUNNING: 1, + }), + ).toBe(false); + }); + + test("returns false for empty stats", () => { + expect(isExecutionComplete({})).toBe(false); + }); + + test("returns true for cancelled/skipped executions", () => { + expect( + isExecutionComplete({ + CANCELLED: 3, + SKIPPED: 2, + }), + ).toBe(true); + }); +}); diff --git a/src/utils/executionStatus.ts b/src/utils/executionStatus.ts new file mode 100644 index 000000000..f58c6b274 --- /dev/null +++ b/src/utils/executionStatus.ts @@ -0,0 +1,149 @@ +import type { + ContainerExecutionStatus, + GetGraphExecutionStateResponse, +} from "@/api/types.gen"; + +/** + * Server execution status → display label mapping. + * + * Note: The mapping is intentionally aligned to the status table from: + * https://github.com/TangleML/tangle-ui/issues/1540 + */ +const EXECUTION_STATUS_LABELS: Record = { + CANCELLED: "Cancelled", + CANCELLING: "Cancelling", + FAILED: "Failed", + INVALID: "Invalid", + PENDING: "Pending", + QUEUED: "Queued", + RUNNING: "Running", + SKIPPED: "Skipped", + SUCCEEDED: "Succeeded", + SYSTEM_ERROR: "System error", + UNINITIALIZED: "Uninitialized", + WAITING_FOR_UPSTREAM: "Waiting for upstream", +} as const satisfies Record; + +/** + * Centralized background color mapping for status bar segments. + */ +export const EXECUTION_STATUS_BG_COLORS: Record = { + SUCCEEDED: "bg-green-500", + FAILED: "bg-red-500", + SYSTEM_ERROR: "bg-red-700", + INVALID: "bg-red-600", + RUNNING: "bg-blue-500", + PENDING: "bg-yellow-500", + QUEUED: "bg-amber-500", + WAITING_FOR_UPSTREAM: "bg-slate-500", + SKIPPED: "bg-slate-400", + CANCELLED: "bg-gray-700", + CANCELLING: "bg-gray-500", + UNINITIALIZED: "bg-yellow-400", +}; + +/** + * Statuses considered "in progress" (not terminal). + */ +const IN_PROGRESS_STATUSES = new Set([ + "RUNNING", + "PENDING", + "QUEUED", + "WAITING_FOR_UPSTREAM", + "CANCELLING", + "UNINITIALIZED", +]); + +/** + * Priority order for determining overall/aggregate execution status. + * Higher priority statuses appear first — if any task has SYSTEM_ERROR, + * the overall status should reflect that before checking for FAILED, etc. + */ +const EXECUTION_STATUS_PRIORITY = [ + "SYSTEM_ERROR", + "FAILED", + "INVALID", + "CANCELLING", + "CANCELLED", + "RUNNING", + "PENDING", + "WAITING_FOR_UPSTREAM", + "QUEUED", + "UNINITIALIZED", + "SKIPPED", + "SUCCEEDED", +] as const; + +export type ExecutionStatusStats = Record; + +type ChildExecutionStatusStats = + GetGraphExecutionStateResponse["child_execution_status_stats"]; + +export function getExecutionStatusLabel(status: string | undefined): string { + if (!status) return "Unknown"; + return EXECUTION_STATUS_LABELS[status] ?? status; +} + +/** + * Flatten nested child_execution_status_stats into a single aggregated stats object. + */ +export function flattenExecutionStatusStats( + childStats: ChildExecutionStatusStats | null | undefined, +): ExecutionStatusStats { + if (!childStats) return {}; + + const result: ExecutionStatusStats = {}; + for (const stats of Object.values(childStats)) { + if (!stats) continue; + for (const [status, count] of Object.entries(stats)) { + if (count) { + result[status] = (result[status] ?? 0) + count; + } + } + } + return result; +} + +/** + * Find the first status in the priority list that has a non-zero count. + */ +const pickHighestPriorityStatus = ( + stats: Record, +): string | undefined => + EXECUTION_STATUS_PRIORITY.find((status) => (stats[status] ?? 0) > 0); + +/** + * Pick the highest priority status from a stats object. + * Returns the raw server status - use getExecutionStatusLabel() for display. + */ +export function getOverallExecutionStatusFromStats( + stats: Record | null | undefined, +): string | undefined { + if (!stats) return undefined; + + const picked = pickHighestPriorityStatus(stats); + if (picked) return picked; + + // Fallback: return any status with a non-zero count + const firstNonZero = Object.entries(stats).find(([, c]) => (c ?? 0) > 0); + return firstNonZero?.[0]; +} + +/** + * Count the number of in-progress tasks from execution stats. + */ +export function countInProgressFromStats(stats: ExecutionStatusStats): number { + let count = 0; + for (const status of IN_PROGRESS_STATUSES) { + count += stats[status] ?? 0; + } + return count; +} + +/** + * Check if execution is complete based on stats (no in-progress tasks and at least one task). + */ +export function isExecutionComplete(stats: ExecutionStatusStats): boolean { + const total = Object.values(stats).reduce((sum, c) => sum + (c ?? 0), 0); + return total > 0 && countInProgressFromStats(stats) === 0; +}