Skip to content

Commit c482c2a

Browse files
das7padtimja
andauthored
Refine stage progress details (#1033)
Co-authored-by: Tim Jacomb <[email protected]>
1 parent 8564427 commit c482c2a

27 files changed

+731
-241
lines changed

src/main/frontend/common/RestClient.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ export interface StepInfo {
3131
name: string;
3232
title: string;
3333
state: Result;
34-
completePercent: number;
3534
inputStep?: InputStep;
3635
id: string;
3736
type: string;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/** * @vitest-environment jsdom */
2+
3+
import { act, renderHook } from "@testing-library/react";
4+
import { vi } from "vitest";
5+
6+
import {
7+
Result,
8+
StageInfo,
9+
} from "../../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx";
10+
import { useStageProgress } from "./status-icon.tsx";
11+
12+
describe("StatusIcon", () => {
13+
describe("useStageProgress", function () {
14+
const now = Date.now();
15+
beforeEach(() => {
16+
vi.useFakeTimers().setSystemTime(now);
17+
});
18+
afterEach(() => {
19+
vi.useRealTimers();
20+
});
21+
describe("running state", function () {
22+
it("should increment the progress every second", async () => {
23+
const { result, unmount } = renderHook(() => {
24+
return useStageProgress({
25+
...mockStage,
26+
state: Result.running,
27+
startTimeMillis: now - 2_000,
28+
previousTotalDurationMillis: 20_000,
29+
});
30+
});
31+
expect(result.current).to.equal(10);
32+
await act(() => vi.advanceTimersByTime(1_000));
33+
expect(result.current).to.equal(15);
34+
await act(() => vi.advanceTimersByTime(1_000));
35+
expect(result.current).to.equal(20);
36+
await act(() => vi.advanceTimersByTime(6_000));
37+
expect(result.current).to.equal(50);
38+
unmount();
39+
});
40+
it("should stop at 99%", async () => {
41+
const { result, unmount } = renderHook(() => {
42+
return useStageProgress({
43+
...mockStage,
44+
state: Result.running,
45+
startTimeMillis: now - 2_000,
46+
previousTotalDurationMillis: 20_000,
47+
});
48+
});
49+
expect(result.current).to.equal(10);
50+
await act(() => vi.advanceTimersByTime(16_000));
51+
expect(result.current).to.equal(90);
52+
await act(() => vi.advanceTimersByTime(1_000));
53+
expect(result.current).to.equal(95);
54+
await act(() => vi.advanceTimersByTime(1_000));
55+
expect(result.current).to.equal(99);
56+
await act(() => vi.advanceTimersByTime(1_000));
57+
expect(result.current).to.equal(99);
58+
unmount();
59+
});
60+
it("should default to 10s previous duration", async () => {
61+
const { result, unmount } = renderHook(() => {
62+
return useStageProgress({
63+
...mockStage,
64+
state: Result.running,
65+
startTimeMillis: now - 1_000,
66+
});
67+
});
68+
expect(result.current).to.equal(10);
69+
await act(() => vi.advanceTimersByTime(1_000));
70+
expect(result.current).to.equal(20);
71+
await act(() => vi.advanceTimersByTime(1_000));
72+
expect(result.current).to.equal(30);
73+
unmount();
74+
});
75+
it("should handle cycling of stage object", async () => {
76+
const stage = {
77+
...mockStage,
78+
state: Result.running,
79+
startTimeMillis: now - 1_000,
80+
};
81+
const { result, unmount, rerender } = renderHook<number, StageInfo>(
82+
(stage) => {
83+
return useStageProgress(stage);
84+
},
85+
{ initialProps: stage },
86+
);
87+
expect(result.current).to.equal(10);
88+
// Trigger re-render half way through the interval.
89+
await act(() => vi.advanceTimersByTime(500));
90+
expect(result.current).to.equal(10);
91+
await act(() => {
92+
rerender({ ...stage });
93+
});
94+
expect(result.current).to.equal(10);
95+
// Only updates after the original interval passed
96+
await act(() => vi.advanceTimersByTime(499));
97+
expect(result.current).to.equal(10);
98+
await act(() => vi.advanceTimersByTime(1));
99+
expect(result.current).to.equal(20);
100+
// And then again when the next one passed.
101+
await act(() => vi.advanceTimersByTime(999));
102+
expect(result.current).to.equal(20);
103+
await act(() => vi.advanceTimersByTime(1));
104+
expect(result.current).to.equal(30);
105+
unmount();
106+
});
107+
it("should transition to 0", async () => {
108+
const stage = {
109+
...mockStage,
110+
state: Result.running,
111+
startTimeMillis: now - 1_000,
112+
};
113+
const { result, unmount, rerender } = renderHook<number, StageInfo>(
114+
(stage) => {
115+
return useStageProgress(stage);
116+
},
117+
{ initialProps: stage },
118+
);
119+
expect(result.current).to.equal(10);
120+
await act(() => vi.advanceTimersByTime(1_000));
121+
expect(result.current).to.equal(20);
122+
await act(() => vi.advanceTimersByTime(10_000));
123+
expect(result.current).to.equal(99);
124+
await act(() => {
125+
rerender({ ...stage, state: Result.success });
126+
});
127+
expect(result.current).to.equal(0);
128+
unmount();
129+
});
130+
});
131+
describe("other states", function () {
132+
for (const state in Result) {
133+
if (state === Result.running) continue;
134+
it(`should return 0 for ${state}`, async () => {
135+
const { result, unmount } = renderHook(() => {
136+
return useStageProgress({
137+
...mockStage,
138+
state: state as Result,
139+
startTimeMillis: now - 2_000,
140+
previousTotalDurationMillis: 20_000,
141+
});
142+
});
143+
expect(result.current).to.equal(0);
144+
await act(() => vi.advanceTimersByTime(1_000));
145+
expect(result.current).to.equal(0);
146+
unmount();
147+
});
148+
}
149+
});
150+
});
151+
});
152+
153+
const mockStage: StageInfo = {
154+
name: "Build",
155+
state: Result.success,
156+
skeleton: false,
157+
id: 1,
158+
title: "Build",
159+
type: "STAGE",
160+
agent: "agent-1",
161+
children: [],
162+
pauseDurationMillis: 5000,
163+
startTimeMillis: 1713440000000,
164+
url: "",
165+
};

src/main/frontend/common/components/status-icon.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,47 @@
11
import "./status-icon.scss";
22

3-
import { ReactNode } from "react";
3+
import { ReactNode, useEffect, useState } from "react";
44

5-
import { Result } from "../../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx";
5+
import {
6+
Result,
7+
StageInfo,
8+
} from "../../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx";
9+
10+
export function useStageProgress(stage: StageInfo) {
11+
const [percentage, setPercentage] = useState(0);
12+
useEffect(() => {
13+
if (stage.state !== Result.running) {
14+
// percentage is only needed for the running icon.
15+
setPercentage(0);
16+
return;
17+
}
18+
const update = () => {
19+
const currentTiming =
20+
stage.totalDurationMillis ?? Date.now() - stage.startTimeMillis;
21+
const previousTiming = stage.previousTotalDurationMillis ?? 10_000;
22+
setPercentage(Math.min(99, (currentTiming / previousTiming) * 100));
23+
};
24+
update();
25+
const inter = setInterval(update, 1_000);
26+
return () => clearInterval(inter);
27+
}, [
28+
stage.state,
29+
stage.startTimeMillis,
30+
stage.totalDurationMillis,
31+
stage.previousTotalDurationMillis,
32+
]);
33+
return percentage;
34+
}
35+
36+
export function StageStatusIcon({ stage }: { stage: StageInfo }) {
37+
return (
38+
<StatusIcon
39+
status={stage.state}
40+
percentage={useStageProgress(stage)}
41+
skeleton={stage.skeleton}
42+
/>
43+
);
44+
}
645

746
/**
847
* Visual representation of a job or build status

src/main/frontend/common/tree-api.spec.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
/** * @vitest-environment jsdom */
2+
13
import { renderHook, waitFor } from "@testing-library/react";
2-
import { expect, Mock, vi } from "vitest";
4+
import { Mock, vi } from "vitest";
35

46
import {
57
Result,
@@ -21,9 +23,7 @@ describe("useRunPoller", function () {
2123

2224
it("should merge stages when not complete", async () => {
2325
const previous = [stage("Build"), stage("Test")];
24-
const current = [
25-
stage("Build", { state: Result.running, completePercent: 50, id: 42 }),
26-
];
26+
const current = [stage("Build", { state: Result.running, id: 42 })];
2727

2828
const merged = mergeStageInfos(previous, current);
2929
(restClient.getRunStatusFromPath as Mock).mockImplementation(
@@ -95,9 +95,7 @@ describe("useRunPoller", function () {
9595
});
9696

9797
it("should ignore fetch error for previous", async () => {
98-
const current = [
99-
stage("Build", { state: Result.running, completePercent: 50, id: 42 }),
100-
];
98+
const current = [stage("Build", { state: Result.running, id: 42 })];
10199

102100
(restClient.getRunStatusFromPath as Mock).mockImplementation(
103101
async (path) => {
@@ -217,7 +215,6 @@ const stage = (
217215
state: Result.success,
218216
type: "STAGE",
219217
children: [],
220-
completePercent: 0,
221218
id: name.length, // simple unique-ish id
222219
pauseDurationMillis: 0,
223220
startTimeMillis: 0,
Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,16 @@
1-
import { useEffect, useState } from "react";
2-
3-
import { Total } from "./timings.tsx";
1+
import { Since, Total } from "./timings.tsx";
42

53
export default function LiveTotal({
64
total,
75
start,
6+
paused,
87
}: {
98
total: number | undefined;
109
start: number;
10+
paused?: boolean;
1111
}) {
12-
const [duration, setDuration] = useState<number>(total ?? Date.now() - start);
13-
useEffect(() => {
14-
if (total == null) {
15-
const interval = setInterval(() => {
16-
setDuration(Date.now() - start);
17-
}, 3001); // to match step polling interval
18-
return () => clearInterval(interval);
19-
} else {
20-
setDuration(total);
21-
}
22-
}, [start, total]);
23-
24-
return <Total ms={duration} />;
12+
if (typeof total === "number") {
13+
return <Total ms={total} />;
14+
}
15+
return <Since live since={start} paused={paused} />;
2516
}

0 commit comments

Comments
 (0)