Skip to content

Commit da20b9b

Browse files
timjajanfaraciklewisbirks
authored
Improve stage view (#632)
Co-authored-by: Jan Faracik <[email protected]> Co-authored-by: lewisbirks <[email protected]>
1 parent 63dbbc7 commit da20b9b

File tree

30 files changed

+543
-141
lines changed

30 files changed

+543
-141
lines changed

pom.xml

+17
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,23 @@
104104
<groupId>org.jenkinsci.plugins</groupId>
105105
<artifactId>pipeline-model-definition</artifactId>
106106
</dependency>
107+
<dependency>
108+
<groupId>com.microsoft.playwright</groupId>
109+
<artifactId>playwright</artifactId>
110+
<version>1.51.0</version>
111+
<scope>test</scope>
112+
</dependency>
113+
<dependency>
114+
<groupId>org.awaitility</groupId>
115+
<artifactId>awaitility</artifactId>
116+
<version>4.3.0</version>
117+
<scope>test</scope>
118+
</dependency>
119+
<dependency>
120+
<groupId>org.hamcrest</groupId>
121+
<artifactId>hamcrest</artifactId>
122+
<scope>test</scope>
123+
</dependency>
107124
<dependency>
108125
<groupId>org.jenkins-ci.plugins</groupId>
109126
<artifactId>pipeline-build-step</artifactId>

src/main/frontend/common/i18n/i18n-provider.tsx

+6-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import React, {
99
import {
1010
defaultTranslations,
1111
getTranslations,
12+
ResourceBundleName,
1213
Translations,
1314
} from "./translations";
1415

@@ -18,19 +19,22 @@ export const I18NContext: Context<Translations> = createContext(
1819

1920
interface I18NProviderProps {
2021
children: ReactNode;
22+
bundles: ResourceBundleName[];
23+
locale: string;
2124
}
2225

2326
export const I18NProvider: FunctionComponent<I18NProviderProps> = ({
2427
children,
28+
bundles,
29+
locale,
2530
}) => {
26-
const locale = document.getElementById("root")?.dataset.userLocale ?? "en";
2731
const [translations, setTranslations] = useState<Translations>(
2832
defaultTranslations(locale),
2933
);
3034

3135
useEffect(() => {
3236
const fetchTranslations = async () => {
33-
const translations = await getTranslations(locale);
37+
const translations = await getTranslations(locale, bundles);
3438
setTranslations(translations);
3539
};
3640
fetchTranslations();

src/main/frontend/common/i18n/translations.spec.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ jest.mock("../RestClient", () => ({
33
}));
44

55
import { getResourceBundle } from "../RestClient";
6-
import { getTranslations, messageFormat, Translations } from "./translations";
6+
import {
7+
getTranslations,
8+
messageFormat,
9+
ResourceBundleName,
10+
Translations,
11+
} from "./translations";
712

813
describe("Translations", () => {
914
describe("Get translation", () => {
@@ -33,7 +38,10 @@ describe("Translations", () => {
3338
"Another.property": "with another value",
3439
"One.more.property": "with {one} more value",
3540
});
36-
const translations = await getTranslations("en");
41+
const translations = await getTranslations("en", [
42+
ResourceBundleName.run,
43+
ResourceBundleName.messages,
44+
]);
3745

3846
expect(getResourceBundle).toHaveBeenCalledWith("hudson.Messages");
3947
expect(getResourceBundle).toHaveBeenCalledWith("hudson.model.Run.index");
@@ -49,7 +57,9 @@ describe("Translations", () => {
4957
it("should use the default messages if undefined returned", async () => {
5058
(getResourceBundle as jest.Mock).mockResolvedValue(undefined);
5159

52-
const translations = await getTranslations("en");
60+
const translations = await getTranslations("en", [
61+
ResourceBundleName.run,
62+
]);
5363

5464
expect(translations.get("Util.second")({ 0: 5 })).toEqual("5 sec");
5565
expect(translations.get("Util.day")({ 0: 1 })).toEqual("1 day");

src/main/frontend/common/i18n/translations.ts

+16-10
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,23 @@ export function messageFormat(locale: string) {
3737
});
3838
}
3939

40-
export async function getTranslations(locale: string): Promise<Translations> {
41-
let [timingMessages, runMessages] = await Promise.all([
42-
getResourceBundle("hudson.Messages"),
43-
getResourceBundle("hudson.model.Run.index"),
44-
]);
40+
export enum ResourceBundleName {
41+
messages = "hudson.Messages",
42+
run = "hudson.model.Run.index",
43+
}
44+
45+
export async function getTranslations(
46+
locale: string,
47+
bundleNames: ResourceBundleName[],
48+
): Promise<Translations> {
49+
const bundles = await Promise.all(
50+
bundleNames.map((name) => getResourceBundle(name).then((r) => r ?? {})),
51+
);
4552

46-
const messages = {
47-
...DEFAULT_MESSAGES,
48-
...timingMessages,
49-
...runMessages,
50-
};
53+
const messages = bundles.reduce(
54+
(acc, bundle) => ({ ...acc, ...bundle }),
55+
DEFAULT_MESSAGES,
56+
);
5157

5258
const fmt = messageFormat(locale);
5359

src/main/frontend/common/utils/timings.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export function Paused({ since }: { since: number }) {
120120

121121
export function Started({ since }: { since: number }) {
122122
const translations = useContext(I18NContext);
123-
if (since == 0) {
123+
if (since === 0) {
124124
return <></>;
125125
}
126126

@@ -133,6 +133,15 @@ export function Started({ since }: { since: number }) {
133133
);
134134
}
135135

136+
export function time(since: number): string {
137+
return since === 0
138+
? ""
139+
: new Date(since).toLocaleTimeString("en-GB", {
140+
hour: "2-digit",
141+
minute: "2-digit",
142+
});
143+
}
144+
136145
export function exact(since: number): string {
137146
if (since === 0) return "";
138147

Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
11
.app-page-body--one-column {
22
max-width: 95vw;
33
}
4-
5-
.pgw-user-specified-text {
6-
overflow-wrap: break-word;
7-
}

src/main/frontend/multi-pipeline-graph-view/app.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@ import { MultiPipelineGraph } from "./multi-pipeline-graph/main";
55

66
import "./app.scss";
77
import "./multi-pipeline-graph/styles/main.scss";
8+
import { I18NProvider } from "../common/i18n/i18n-provider";
9+
import { ResourceBundleName } from "../common/i18n/translations";
810

911
const App: FunctionComponent = () => {
12+
const locale =
13+
document.getElementById("multiple-pipeline-root")?.dataset.userLocale ??
14+
"en";
1015
return (
1116
<div>
12-
<MultiPipelineGraph />
17+
<I18NProvider bundles={[ResourceBundleName.messages]} locale={locale}>
18+
<MultiPipelineGraph />
19+
</I18NProvider>
1320
</div>
1421
);
1522
};

src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/MultiPipelineGraph.tsx

+27-19
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,35 @@ export const MultiPipelineGraph = () => {
1818
});
1919
}
2020
}, [runs, poll]);
21+
22+
const groupedRuns: Record<string, RunInfo[]> = runs.reduce(
23+
(acc: Record<string, RunInfo[]>, run) => {
24+
const date = new Date(run.timestamp).toLocaleDateString("en-US", {
25+
year: "numeric",
26+
month: "long",
27+
day: "numeric",
28+
});
29+
30+
if (!acc[date]) {
31+
acc[date] = [];
32+
}
33+
acc[date].push(run);
34+
35+
return acc;
36+
},
37+
{},
38+
);
39+
2140
return (
2241
<>
23-
{runs.length > 0 && (
24-
<table className="jenkins-table sortable">
25-
<thead>
26-
<tr>
27-
<th className="jenkins-table__cell--tight">id</th>
28-
<th data-sort-disable="true">pipeline</th>
29-
</tr>
30-
</thead>
31-
<tbody>
32-
{runs.map((run) => (
33-
<SingleRun
34-
key={run.id}
35-
run={run}
36-
currentJobPath={currentJobPath}
37-
/>
38-
))}
39-
</tbody>
40-
</table>
41-
)}
42+
{Object.entries(groupedRuns).map(([date, runsOnDate]) => (
43+
<div className={"pgv-stages__group"} key={date}>
44+
<p className="pgv-stages__heading">{date}</p>
45+
{runsOnDate.map((run) => (
46+
<SingleRun key={run.id} run={run} currentJobPath={currentJobPath} />
47+
))}
48+
</div>
49+
))}
4250
</>
4351
);
4452
};
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import { Result } from "../../../pipeline-graph-view/pipeline-graph/main";
2+
13
export interface RunInfo {
24
id: string;
35
displayName: string;
6+
timestamp: number;
7+
duration: number;
8+
result: Result;
49
}

src/main/frontend/multi-pipeline-graph-view/multi-pipeline-graph/main/SingleRun.tsx

+26-16
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,42 @@
11
import React, { useState } from "react";
22
import { RunInfo } from "./MultiPipelineGraphModel";
33
import {
4+
LayoutInfo,
45
PipelineGraph,
56
StageInfo,
67
} from "../../../pipeline-graph-view/pipeline-graph/main";
8+
import { defaultLayout } from "../../../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel";
9+
import { time, Total } from "../../../common/utils/timings";
10+
import "./single-run.scss";
11+
import StatusIcon from "../../../common/components/status-icon";
712

813
export default function SingleRun({ run, currentJobPath }: SingleRunProps) {
914
const [stages, setStages] = useState<Array<StageInfo>>([]);
1015

16+
const layout: LayoutInfo = {
17+
...defaultLayout,
18+
nodeSpacingH: 45,
19+
};
20+
1121
return (
12-
<tr>
13-
<td>
14-
<a
15-
href={currentJobPath + run.id}
16-
className="jenkins-table__link pgw-user-specified-text"
17-
>
22+
<div className="pgv-single-run">
23+
<div>
24+
<a href={currentJobPath + run.id} className="pgw-user-specified-text">
25+
<StatusIcon status={run.result} />
1826
{run.displayName}
27+
<span>
28+
{time(run.timestamp)} - <Total ms={run.duration} />
29+
</span>
1930
</a>
20-
</td>
21-
<td>
22-
<PipelineGraph
23-
stages={stages}
24-
setStages={setStages}
25-
currentRunPath={currentJobPath + run.id + "/"}
26-
collapsed={true}
27-
/>
28-
</td>
29-
</tr>
31+
</div>
32+
<PipelineGraph
33+
stages={stages}
34+
setStages={setStages}
35+
currentRunPath={currentJobPath + run.id + "/"}
36+
layout={layout}
37+
collapsed={true}
38+
/>
39+
</div>
3040
);
3141
}
3242

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
.pgv-single-run {
2+
display: grid;
3+
grid-template-columns: 200px 1fr;
4+
align-items: center;
5+
6+
// This is hacky - the pipeline graph is too tall and isn't centered
7+
.PWGx-PipelineGraph-container {
8+
height: 36px;
9+
10+
& > div {
11+
margin-top: -36px;
12+
}
13+
}
14+
15+
&:last-of-type {
16+
border-bottom: none;
17+
}
18+
19+
.pgw-user-specified-text {
20+
display: grid;
21+
grid-template-columns: auto 1fr;
22+
align-items: start;
23+
gap: 0.125rem 0.625rem;
24+
color: var(--text-color);
25+
text-decoration: none;
26+
transition: opacity var(--standard-transition);
27+
word-break: normal;
28+
overflow-wrap: anywhere;
29+
30+
span {
31+
grid-area: 2 / 2;
32+
color: var(--text-color-secondary);
33+
font-size: var(--font-size-xs);
34+
}
35+
36+
&:hover {
37+
opacity: 0.75;
38+
}
39+
40+
&:active {
41+
opacity: 0.5;
42+
}
43+
44+
svg {
45+
width: 1.375rem;
46+
height: 1.375rem;
47+
}
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
.pgv-stages__group {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 0.5rem;
5+
margin-top: 1rem;
6+
7+
.pgv-stages__heading {
8+
display: flex;
9+
font-size: var(--font-size-xs);
10+
color: var(--text-color-secondary);
11+
font-weight: var(--font-bold-weight);
12+
margin-bottom: 0.25rem;
13+
margin-top: 0;
14+
}
15+
16+
&:first-of-type {
17+
margin-top: 0;
18+
}
19+
}

src/main/frontend/pipeline-console-view/app.tsx

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import React from "react";
2-
import { lazy } from "react";
1+
import React, { lazy } from "react";
32

43
import { I18NProvider } from "../common/i18n/i18n-provider";
4+
import { ResourceBundleName } from "../common/i18n/translations";
55

66
const PipelineConsole = lazy(
77
() => import("./pipeline-console/main/PipelineConsole"),
88
);
99

1010
export default function App() {
11+
const locale =
12+
document.getElementById("console-pipeline-root")?.dataset.userLocale ??
13+
"en";
1114
return (
12-
<I18NProvider>
15+
<I18NProvider
16+
bundles={[ResourceBundleName.messages, ResourceBundleName.run]}
17+
locale={locale}
18+
>
1319
<PipelineConsole />
1420
</I18NProvider>
1521
);

0 commit comments

Comments
 (0)