diff --git a/src/main/frontend/common/components/status-icon.spec.tsx b/src/main/frontend/common/components/status-icon.spec.tsx index 9bdcb0f29..3f3b851e7 100644 --- a/src/main/frontend/common/components/status-icon.spec.tsx +++ b/src/main/frontend/common/components/status-icon.spec.tsx @@ -127,6 +127,23 @@ describe("StatusIcon", () => { expect(result.current).to.equal(0); unmount(); }); + it("should continue progress when waitingForInput is true", async () => { + const { result, unmount } = renderHook(() => { + return useStageProgress({ + ...mockStage, + state: Result.running, + waitingForInput: true, + startTimeMillis: now - 2_000, + previousTotalDurationMillis: 20_000, + }); + }); + expect(result.current).to.equal(10); + await act(() => vi.advanceTimersByTime(1_000)); + expect(result.current).to.equal(15); + await act(() => vi.advanceTimersByTime(1_000)); + expect(result.current).to.equal(20); + unmount(); + }); }); describe("other states", function () { for (const state in Result) { diff --git a/src/main/frontend/common/components/status-icon.tsx b/src/main/frontend/common/components/status-icon.tsx index c7b7aeb9c..079607000 100644 --- a/src/main/frontend/common/components/status-icon.tsx +++ b/src/main/frontend/common/components/status-icon.tsx @@ -10,11 +10,13 @@ import { export function useStageProgress(stage: StageInfo) { const [percentage, setPercentage] = useState(0); useEffect(() => { + // If no longer running, immediately reset progress. if (stage.state !== Result.running) { - // percentage is only needed for the running icon. setPercentage(0); return; } + // Keep updating progress while waitingForInput: the glyph changes to paused but + // the ring should continue to reflect underlying running progress. const update = () => { const currentTiming = stage.totalDurationMillis ?? Date.now() - stage.startTimeMillis; @@ -37,6 +39,7 @@ export function StageStatusIcon({ stage }: { stage: StageInfo }) { return ( @@ -48,11 +51,15 @@ export function StageStatusIcon({ stage }: { stage: StageInfo }) { */ export default function StatusIcon({ status, + waitingForInput, percentage, skeleton, }: StatusIconProps) { const viewBoxSize = 512; - const strokeWidth = status === "running" ? 50 : 0; + const iconStatus = + waitingForInput && status === Result.running ? Result.paused : status; + // Keep ring when underlying status is running (even if visually paused) + const strokeWidth = status === Result.running ? 50 : 0; const radius = (viewBoxSize - strokeWidth) / 2.2; const circumference = 2 * Math.PI * radius; const offset = circumference - ((percentage ?? 100) / 100) * circumference; @@ -63,7 +70,7 @@ export default function StatusIcon({ className={"pgv-status-icon " + resultToColor(status, skeleton)} opacity={skeleton ? 0.5 : 1} role={"img"} - aria-label={status} + aria-label={iconStatus} > - + - + - + - + - + - + - + - + - + { startTimeMillis: 123, totalDurationMillis: undefined, pauseLiveTotal: false, + waitingForInput: false, }, ]); expect(stages).to.not.equal(originalStages); @@ -36,6 +37,7 @@ it("should handle skeleton stage with started children", () => { startTimeMillis: 123, totalDurationMillis: undefined, pauseLiveTotal: false, + waitingForInput: false, children: [ { ...mockStage, @@ -44,6 +46,7 @@ it("should handle skeleton stage with started children", () => { startTimeMillis: 123, totalDurationMillis: undefined, pauseLiveTotal: false, + waitingForInput: false, }, ], }, @@ -84,6 +87,7 @@ it("should handle finished not_built stage", () => { skeleton: false, totalDurationMillis: undefined, pauseLiveTotal: true, + waitingForInput: false, }, ]); expect(stages).to.not.equal(originalStages); @@ -111,6 +115,7 @@ it("should handle finished running stage", () => { skeleton: false, totalDurationMillis: undefined, pauseLiveTotal: true, + waitingForInput: false, }, ]); expect(stages).to.not.equal(originalStages); @@ -149,6 +154,7 @@ it("should handle finished stage with children", () => { totalDurationMillis: undefined, skeleton: false, pauseLiveTotal: true, + waitingForInput: false, children: [ { ...mockStage, @@ -158,6 +164,7 @@ it("should handle finished stage with children", () => { totalDurationMillis: undefined, skeleton: false, pauseLiveTotal: true, + waitingForInput: false, }, ], }, @@ -168,6 +175,60 @@ it("should handle finished stage with children", () => { expect(stages[0].children[0]).to.not.equal(originalChildren[0]); }); +it("should mark running stage waitingForInput when input step present", () => { + const originalStages: StageInfo[] = [ + { + ...mockStage, + state: Result.running, + startTimeMillis: 42, + skeleton: false, + }, + ]; + const stages = refreshStagesFromSteps(originalStages, [ + { + ...mockStep, + stageId: "1", + state: Result.running, + inputStep: { + message: "msg", + cancel: "Cancel", + id: "x", + ok: "OK", + parameters: false, + }, + }, + ]); + expect(stages[0].state).to.equal(Result.running); + expect(stages[0].waitingForInput).to.equal(true); +}); + +it("should bubble waitingForInput if child branch paused", () => { + const originalStages: StageInfo[] = [ + { + ...mockStage, + state: Result.running, + startTimeMillis: 42, + skeleton: false, + children: [ + { + ...mockStage, + id: 2, + state: Result.paused, + startTimeMillis: 50, + skeleton: false, + totalDurationMillis: undefined, + children: [], + pauseDurationMillis: 0, + url: "", + }, + ], + }, + ]; + const stages = refreshStagesFromSteps(originalStages, []); + expect(stages[0].state).to.equal(Result.running); + expect(stages[0].waitingForInput).to.equal(true); +}); + const mockStage: StageInfo = { name: "Build", state: Result.not_built, diff --git a/src/main/frontend/common/utils/refresh-stages-from-steps.ts b/src/main/frontend/common/utils/refresh-stages-from-steps.ts index 8541e24a8..8fcbe81aa 100644 --- a/src/main/frontend/common/utils/refresh-stages-from-steps.ts +++ b/src/main/frontend/common/utils/refresh-stages-from-steps.ts @@ -29,6 +29,11 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) { totalDurationMillis = undefined; } } + // Derived front-end flag: if any step has an inputStep or any child is paused/waiting, mark waitingForInput. + const waitingForInput = + state === Result.running && + (stageSteps.some((s) => Boolean(s.inputStep)) || + children.some((c) => c.waitingForInput || c.state === Result.paused)); // Best effort: Pause timer when a stage is expected to have finished to avoid having to decrement the total duration when done (as confirmed by the next run polling). pauseLiveTotal = startTimeMillis > 0 && @@ -40,7 +45,8 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) { Boolean(stage.pauseLiveTotal) !== pauseLiveTotal || stage.startTimeMillis !== startTimeMillis || stage.state !== state || - stage.totalDurationMillis !== totalDurationMillis + stage.totalDurationMillis !== totalDurationMillis || + Boolean(stage.waitingForInput) !== waitingForInput ) { // Update in-place to avoid frequent re-render, then trigger re-render. stage.children = children; @@ -48,6 +54,7 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) { stage.startTimeMillis = startTimeMillis; stage.state = state; stage.totalDurationMillis = totalDurationMillis; + stage.waitingForInput = waitingForInput; if (!changed) stages = stages.slice(); changed = true; stages[idx] = { ...stage }; diff --git a/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx b/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx index c3a437505..3bc12fa9f 100644 --- a/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx +++ b/src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx @@ -57,6 +57,11 @@ export interface StageInfo { skeleton?: boolean; pauseLiveTotal?: boolean; + /** + * Front-end derived flag indicating a running stage is awaiting input. + * Used to override the icon to the paused glyph while retaining the running state for filters & color. + */ + waitingForInput?: boolean; } interface BaseNodeInfo { diff --git a/src/main/frontend/setupTests.ts b/src/main/frontend/setupTests.ts index 43b851665..2a5132539 100644 --- a/src/main/frontend/setupTests.ts +++ b/src/main/frontend/setupTests.ts @@ -2,6 +2,29 @@ import "@testing-library/jest-dom/vitest"; import { vi } from "vitest"; +// Ensure a functional localStorage implementation for tests that persist user preferences. +if ( + !("localStorage" in window) || + typeof window.localStorage?.getItem !== "function" || + typeof window.localStorage?.setItem !== "function" +) { + const store: Record = {}; + const localStoragePolyfill = { + getItem: (key: string) => (key in store ? store[key] : null), + setItem: (key: string, value: string) => { + store[key] = String(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + Object.keys(store).forEach((k) => delete store[k]); + }, + }; + // @ts-expect-error override for test environment + window.localStorage = localStoragePolyfill; +} + const IntersectionObserverMock = vi.fn(() => ({ disconnect: vi.fn(), observe: vi.fn(),