@runwisp/ui 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -7
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +3 -24
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +2 -6
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +95 -418
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +265 -543
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +9 -0
- package/src/lib/components/dashboard/cron-format.ts +5 -1
- package/src/lib/components/dashboard/run-filters.test.ts +60 -0
- package/src/lib/components/dashboard/run-filters.ts +78 -6
- package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
- package/src/lib/components/dashboard/run-helpers.ts +122 -6
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/utils/format.test.ts +122 -0
- package/src/lib/utils/format.ts +97 -4
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/Tabs.stories.svelte +17 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { BulkSelection } from "./bulk-selection.svelte.js";
|
|
6
|
+
|
|
7
|
+
describe("BulkSelection", () => {
|
|
8
|
+
it("tracks explicitly picked rows", () => {
|
|
9
|
+
const sel = new BulkSelection();
|
|
10
|
+
sel.toggle("a");
|
|
11
|
+
sel.toggle("b");
|
|
12
|
+
sel.toggle("a");
|
|
13
|
+
expect(sel.isSelected("a")).toBe(false);
|
|
14
|
+
expect(sel.isSelected("b")).toBe(true);
|
|
15
|
+
expect(sel.allSelected).toBe(false);
|
|
16
|
+
expect(sel.selector({}, ["b"])).toEqual({ matchAll: false, ids: ["b"] });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("selects everything except opt-outs in all mode", () => {
|
|
20
|
+
const sel = new BulkSelection();
|
|
21
|
+
sel.toggle("x");
|
|
22
|
+
sel.selectAll();
|
|
23
|
+
expect(sel.isSelected("x")).toBe(true);
|
|
24
|
+
expect(sel.allSelected).toBe(true);
|
|
25
|
+
sel.toggle("x");
|
|
26
|
+
expect(sel.isSelected("x")).toBe(false);
|
|
27
|
+
expect(sel.allSelected).toBe(false);
|
|
28
|
+
expect(sel.selector({ taskName: "t" }, ["ignored"])).toEqual({
|
|
29
|
+
matchAll: true,
|
|
30
|
+
filter: { taskName: "t" },
|
|
31
|
+
exceptIds: ["x"],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("clears back to nothing selected", () => {
|
|
36
|
+
const sel = new BulkSelection();
|
|
37
|
+
sel.selectAll();
|
|
38
|
+
sel.clear();
|
|
39
|
+
expect(sel.isSelected("a")).toBe(false);
|
|
40
|
+
expect(sel.allSelected).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
5
|
+
import type { RunSelector } from "@runwisp/common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which runs are ticked in the list. Two modes:
|
|
9
|
+
* 1. explicit: the operator picked specific rows; `#explicit` holds them.
|
|
10
|
+
* 2. all: "everything matching the current filter"; `#except` holds opt-outs.
|
|
11
|
+
*/
|
|
12
|
+
export class BulkSelection {
|
|
13
|
+
#all = $state(false);
|
|
14
|
+
readonly #explicit = new SvelteSet<string>();
|
|
15
|
+
readonly #except = new SvelteSet<string>();
|
|
16
|
+
|
|
17
|
+
isSelected(id: string): boolean {
|
|
18
|
+
return this.#all ? !this.#except.has(id) : this.#explicit.has(id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
toggle(id: string): void {
|
|
22
|
+
const ids = this.#all ? this.#except : this.#explicit;
|
|
23
|
+
if (!ids.delete(id)) ids.add(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
clear(): void {
|
|
27
|
+
this.#all = false;
|
|
28
|
+
this.#explicit.clear();
|
|
29
|
+
this.#except.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
selectAll(): void {
|
|
33
|
+
this.clear();
|
|
34
|
+
this.#all = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every matching run is selected, with no opt-outs. */
|
|
38
|
+
get allSelected(): boolean {
|
|
39
|
+
return this.#all && this.#except.size === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The server-side selector for a bulk operation. In "all" mode it targets
|
|
44
|
+
* everything matching `filter`; in explicit mode just `affectedIds`.
|
|
45
|
+
*/
|
|
46
|
+
selector(filter: NonNullable<RunSelector["filter"]>, affectedIds: string[]): RunSelector {
|
|
47
|
+
if (!this.#all) return { matchAll: false, ids: affectedIds };
|
|
48
|
+
return { matchAll: true, filter, exceptIds: [...this.#except] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -17,6 +17,15 @@ describe("humanizeCron", () => {
|
|
|
17
17
|
expect(result.isHumanized).toBe(true);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
it("describes the nth and last weekday of the month", () => {
|
|
21
|
+
expect(humanizeCron("0 3 * * FRI#3").humanized).toBe(
|
|
22
|
+
"At 03:00 AM, on the third Friday of the month",
|
|
23
|
+
);
|
|
24
|
+
const last = humanizeCron("0 3 * * FRI#L");
|
|
25
|
+
expect(last.humanized).toBe("At 03:00 AM, on the last Friday of the month");
|
|
26
|
+
expect(last.raw).toBe("0 3 * * FRI#L");
|
|
27
|
+
});
|
|
28
|
+
|
|
20
29
|
it("humanizes @hourly via cronstrue", () => {
|
|
21
30
|
const result = humanizeCron("@hourly");
|
|
22
31
|
expect(result.humanized).toBe("Every hour");
|
|
@@ -66,7 +66,11 @@ export function humanizeCron(cron: string): HumanizedCron {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
|
-
|
|
69
|
+
// cronstrue reads a last weekday as Quartz's "5L"; "5#L" would come out as
|
|
70
|
+
// "on the null Friday of the month".
|
|
71
|
+
const humanized = cronstrue.toString(raw.replace(/#L/gi, "L"), {
|
|
72
|
+
throwExceptionOnParseError: true,
|
|
73
|
+
});
|
|
70
74
|
return { humanized, raw, isHumanized: true };
|
|
71
75
|
} catch {
|
|
72
76
|
return { humanized: raw, raw, isHumanized: false };
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
exitCodeRangeActive,
|
|
27
27
|
isExitCodeExprValid,
|
|
28
28
|
exitCodeChipLabel,
|
|
29
|
+
runFilterParams,
|
|
30
|
+
filterChipLabel,
|
|
29
31
|
type StatusBucket,
|
|
30
32
|
type RunsListFilters,
|
|
31
33
|
} from "./run-filters.js";
|
|
@@ -292,3 +294,61 @@ describe("exit-code expression", () => {
|
|
|
292
294
|
expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
|
|
293
295
|
});
|
|
294
296
|
});
|
|
297
|
+
|
|
298
|
+
describe("runFilterParams", () => {
|
|
299
|
+
it("omits every inactive dimension", () => {
|
|
300
|
+
expect(runFilterParams(emptyRunFilters())).toEqual({});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("maps each active dimension to its server field", () => {
|
|
304
|
+
expect(
|
|
305
|
+
runFilterParams({
|
|
306
|
+
search: " nightly ",
|
|
307
|
+
statuses: ["failed", "timeout"],
|
|
308
|
+
sortDirection: "asc",
|
|
309
|
+
taskName: "backup",
|
|
310
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
311
|
+
createdBefore: "2026-01-02T00:00:00Z",
|
|
312
|
+
triggeredBy: "cron",
|
|
313
|
+
exitCode: ">100 <150",
|
|
314
|
+
retriesOnly: true,
|
|
315
|
+
}),
|
|
316
|
+
).toEqual({
|
|
317
|
+
taskName: "backup",
|
|
318
|
+
search: "nightly",
|
|
319
|
+
status: "failed,timeout",
|
|
320
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
321
|
+
createdBefore: "2026-01-02T00:00:00Z",
|
|
322
|
+
triggeredBy: "cron",
|
|
323
|
+
exitCodeMin: 101,
|
|
324
|
+
exitCodeMax: 149,
|
|
325
|
+
retriesOnly: true,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("ignores an unparseable exit-code expression", () => {
|
|
330
|
+
expect(runFilterParams({ ...emptyRunFilters(), exitCode: "12a" })).toEqual({});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("filterChipLabel", () => {
|
|
335
|
+
it("labels each dimension", () => {
|
|
336
|
+
const f = base({
|
|
337
|
+
statuses: ["failed"],
|
|
338
|
+
taskName: "backup",
|
|
339
|
+
triggeredBy: "cron",
|
|
340
|
+
exitCode: ">100",
|
|
341
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
342
|
+
});
|
|
343
|
+
expect(filterChipLabel(f, "status")).toBe(statusChipLabel(["failed"]));
|
|
344
|
+
expect(filterChipLabel(f, "task")).toBe("backup");
|
|
345
|
+
expect(filterChipLabel(f, "triggeredBy")).toBe("Trigger: " + triggerDescription("cron"));
|
|
346
|
+
expect(filterChipLabel(f, "exitCode")).toBe("Exit >100");
|
|
347
|
+
expect(filterChipLabel(f, "retries")).toBe("Retries only");
|
|
348
|
+
expect(filterChipLabel(f, "time")).toMatch(/^Since /);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("has no time label without bounds", () => {
|
|
352
|
+
expect(filterChipLabel(base(), "time")).toBe("");
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { TRIGGERS, type Trigger } from "@runwisp/common";
|
|
5
|
+
import { formatCalendarDate, formatDateTime } from "../../utils/format.js";
|
|
5
6
|
import { TRIGGER_LABELS } from "./run-helpers.js";
|
|
7
|
+
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The filter shared by the runs list, the filter popover, the SSE-merge source
|
|
@@ -42,7 +44,7 @@ export const FAILURE_STATUS_TOKEN = "failure";
|
|
|
42
44
|
/**
|
|
43
45
|
* Outcome buckets: a UI grouping of the individual run statuses into five
|
|
44
46
|
* plain-language picks. The popover's "Advanced" section still exposes the
|
|
45
|
-
* individual statuses.
|
|
47
|
+
* individual statuses.
|
|
46
48
|
*/
|
|
47
49
|
export interface StatusBucket {
|
|
48
50
|
key: string;
|
|
@@ -52,24 +54,34 @@ export interface StatusBucket {
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export const STATUS_BUCKETS: readonly StatusBucket[] = [
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
+
{
|
|
58
|
+
key: "running",
|
|
59
|
+
label: "Running",
|
|
60
|
+
dot: RUN_STATUS_CONFIG.running.solidDot,
|
|
61
|
+
statuses: ["pending", "running"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: "succeeded",
|
|
65
|
+
label: "Succeeded",
|
|
66
|
+
dot: RUN_STATUS_CONFIG.succeeded.solidDot,
|
|
67
|
+
statuses: ["succeeded"],
|
|
68
|
+
},
|
|
57
69
|
{
|
|
58
70
|
key: "failed",
|
|
59
71
|
label: "Failed",
|
|
60
|
-
dot:
|
|
72
|
+
dot: RUN_STATUS_CONFIG.failed.solidDot,
|
|
61
73
|
statuses: [FAILURE_STATUS_TOKEN],
|
|
62
74
|
},
|
|
63
75
|
{
|
|
64
76
|
key: "skipped",
|
|
65
77
|
label: "Skipped",
|
|
66
|
-
dot:
|
|
78
|
+
dot: RUN_STATUS_CONFIG.skipped.solidDot,
|
|
67
79
|
statuses: ["skipped", "dst_skipped", "queue_full"],
|
|
68
80
|
},
|
|
69
81
|
{
|
|
70
82
|
key: "stopped",
|
|
71
83
|
label: "Stopped",
|
|
72
|
-
dot:
|
|
84
|
+
dot: RUN_STATUS_CONFIG.stopped.solidDot,
|
|
73
85
|
statuses: ["stopped", "daemon_stopped"],
|
|
74
86
|
},
|
|
75
87
|
];
|
|
@@ -297,7 +309,67 @@ export function isExitCodeExprValid(expr: string): boolean {
|
|
|
297
309
|
return parseExitCodeRange(expr).valid;
|
|
298
310
|
}
|
|
299
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The server-side filter fields for a filter state. The list query and the
|
|
314
|
+
* bulk selector both build on it, so "select all matching" targets exactly the
|
|
315
|
+
* rows on screen. Dimensions that are off are omitted.
|
|
316
|
+
*/
|
|
317
|
+
export interface RunFilterParams {
|
|
318
|
+
taskName?: string;
|
|
319
|
+
search?: string;
|
|
320
|
+
status?: string;
|
|
321
|
+
createdAfter?: string;
|
|
322
|
+
createdBefore?: string;
|
|
323
|
+
triggeredBy?: string;
|
|
324
|
+
exitCodeMin?: number;
|
|
325
|
+
exitCodeMax?: number;
|
|
326
|
+
retriesOnly?: true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function runFilterParams(f: RunsListFilters): RunFilterParams {
|
|
330
|
+
const params: RunFilterParams = {};
|
|
331
|
+
if (f.taskName) params.taskName = f.taskName;
|
|
332
|
+
const search = f.search.trim();
|
|
333
|
+
if (search) params.search = search;
|
|
334
|
+
if (f.statuses.length > 0) params.status = f.statuses.join(",");
|
|
335
|
+
if (f.createdAfter) params.createdAfter = f.createdAfter;
|
|
336
|
+
if (f.createdBefore) params.createdBefore = f.createdBefore;
|
|
337
|
+
if (f.triggeredBy) params.triggeredBy = f.triggeredBy;
|
|
338
|
+
const exit = exitCodeRange(f.exitCode);
|
|
339
|
+
if (exit.min !== undefined) params.exitCodeMin = exit.min;
|
|
340
|
+
if (exit.max !== undefined) params.exitCodeMax = exit.max;
|
|
341
|
+
if (f.retriesOnly === true) params.retriesOnly = true;
|
|
342
|
+
return params;
|
|
343
|
+
}
|
|
344
|
+
|
|
300
345
|
/** Chip label for an active exit-code filter, e.g. `Exit >100 <150`. */
|
|
301
346
|
export function exitCodeChipLabel(expr: string | undefined): string {
|
|
302
347
|
return `Exit ${(expr ?? "").trim()}`;
|
|
303
348
|
}
|
|
349
|
+
|
|
350
|
+
function timeChipLabel(f: RunsListFilters): string {
|
|
351
|
+
const { createdAfter: after, createdBefore: before } = f;
|
|
352
|
+
if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
|
|
353
|
+
if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
|
|
354
|
+
if (after) return `Since ${formatDateTime(after)}`;
|
|
355
|
+
if (before) return `Before ${formatDateTime(before)}`;
|
|
356
|
+
return "";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Label of the chip shown for an active filter dimension. */
|
|
360
|
+
export function filterChipLabel(f: RunsListFilters, dim: FilterDimension): string {
|
|
361
|
+
switch (dim) {
|
|
362
|
+
case "status":
|
|
363
|
+
return statusChipLabel(f.statuses);
|
|
364
|
+
case "time":
|
|
365
|
+
return timeChipLabel(f);
|
|
366
|
+
case "task":
|
|
367
|
+
return f.taskName ?? "";
|
|
368
|
+
case "triggeredBy":
|
|
369
|
+
return "Trigger: " + triggerDescription(f.triggeredBy ?? "");
|
|
370
|
+
case "exitCode":
|
|
371
|
+
return exitCodeChipLabel(f.exitCode);
|
|
372
|
+
case "retries":
|
|
373
|
+
return "Retries only";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { Run } from "@runwisp/common";
|
|
5
6
|
import {
|
|
6
7
|
runDuration,
|
|
7
8
|
runStartDelay,
|
|
8
|
-
|
|
9
|
+
RUN_VERDICTS,
|
|
10
|
+
runEndMarker,
|
|
11
|
+
runRowReadout,
|
|
12
|
+
highlightParts,
|
|
9
13
|
formatTriggeredByLabel,
|
|
10
14
|
runRetryLabel,
|
|
11
15
|
runUsageLabel,
|
|
16
|
+
runUsageScale,
|
|
17
|
+
usageLevel,
|
|
12
18
|
instanceSuffix,
|
|
13
19
|
runLogDownloadUrl,
|
|
14
20
|
} from "./run-helpers.js";
|
|
@@ -21,20 +27,32 @@ describe("runLogDownloadUrl", () => {
|
|
|
21
27
|
});
|
|
22
28
|
});
|
|
23
29
|
|
|
24
|
-
describe("
|
|
30
|
+
describe("runEndMarker", () => {
|
|
31
|
+
it("warns only for ends the operator or daemon caused", () => {
|
|
32
|
+
expect(runEndMarker("stopped")).toEqual({
|
|
33
|
+
label: "run stopped by operator",
|
|
34
|
+
tone: "warn",
|
|
35
|
+
});
|
|
36
|
+
expect(runEndMarker("daemon_stopped").tone).toBe("warn");
|
|
37
|
+
expect(runEndMarker("timeout").label).toBe("run timed out");
|
|
38
|
+
expect(runEndMarker("failed")).toEqual({ label: "end of output", tone: "muted" });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("RUN_VERDICTS", () => {
|
|
25
43
|
it("phrases a timed outcome so a duration reads after it", () => {
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
44
|
+
expect(RUN_VERDICTS.succeeded).toEqual({ verb: "succeeded in", timed: true });
|
|
45
|
+
expect(RUN_VERDICTS.failed).toEqual({ verb: "failed after", timed: true });
|
|
28
46
|
});
|
|
29
47
|
|
|
30
48
|
it("marks statuses that never produced a duration as untimed", () => {
|
|
31
49
|
// These end without ever running, so the caller renders the verb alone
|
|
32
50
|
// rather than "skipped after —".
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
35
|
-
expect(
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
51
|
+
expect(RUN_VERDICTS.missed.timed).toBe(false);
|
|
52
|
+
expect(RUN_VERDICTS.skipped.timed).toBe(false);
|
|
53
|
+
expect(RUN_VERDICTS.dst_skipped.timed).toBe(false);
|
|
54
|
+
expect(RUN_VERDICTS.queue_full.timed).toBe(false);
|
|
55
|
+
expect(RUN_VERDICTS.pending.timed).toBe(false);
|
|
38
56
|
});
|
|
39
57
|
});
|
|
40
58
|
|
|
@@ -124,9 +142,9 @@ describe("runUsageLabel", () => {
|
|
|
124
142
|
expect(runUsageLabel({})).toBeUndefined();
|
|
125
143
|
});
|
|
126
144
|
|
|
127
|
-
it("joins
|
|
128
|
-
expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs:
|
|
129
|
-
"
|
|
145
|
+
it("joins CPU time and memory, never saying peak", () => {
|
|
146
|
+
expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 112_000 })).toBe(
|
|
147
|
+
"CPU 1m 52s · RAM 48 MB",
|
|
130
148
|
);
|
|
131
149
|
});
|
|
132
150
|
|
|
@@ -167,3 +185,95 @@ describe("instanceSuffix", () => {
|
|
|
167
185
|
expect(instanceSuffix(2, 3)).toBe("#3");
|
|
168
186
|
});
|
|
169
187
|
});
|
|
188
|
+
|
|
189
|
+
describe("runRowReadout", () => {
|
|
190
|
+
const ended = { startedAt: "2026-01-01T00:00:00Z", endedAt: "2026-01-01T00:00:02Z" };
|
|
191
|
+
|
|
192
|
+
it("shows live, queued and the exit code of a failure", () => {
|
|
193
|
+
expect(runRowReadout({ ...ended, status: "running", exitCode: 0 }, "running")).toBe("live");
|
|
194
|
+
expect(runRowReadout({ ...ended, status: "pending", exitCode: 0 }, "pending")).toBe(
|
|
195
|
+
"queued",
|
|
196
|
+
);
|
|
197
|
+
expect(runRowReadout({ ...ended, status: "ended", exitCode: 3 }, "failed")).toBe("exit 3");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("falls back to the duration, or a dash when it never started", () => {
|
|
201
|
+
expect(runRowReadout({ ...ended, status: "ended", exitCode: 0 }, "succeeded")).toBe("2s");
|
|
202
|
+
expect(runRowReadout({ status: "ended", exitCode: 0 }, "missed")).toBe("—");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("highlightParts", () => {
|
|
207
|
+
it("returns the whole line when nothing matches", () => {
|
|
208
|
+
expect(highlightParts("hello", "zzz")).toEqual({ before: "hello", match: "", after: "" });
|
|
209
|
+
expect(highlightParts("hello", " ")).toEqual({ before: "hello", match: "", after: "" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("splits around a case-insensitive match", () => {
|
|
213
|
+
expect(highlightParts("an ERROR here", "error")).toEqual({
|
|
214
|
+
before: "an ",
|
|
215
|
+
match: "ERROR",
|
|
216
|
+
after: " here",
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("windows a long lead-in with an ellipsis", () => {
|
|
221
|
+
const parts = highlightParts("x".repeat(40) + "needle", "needle");
|
|
222
|
+
expect(parts.before).toBe("…" + "x".repeat(14));
|
|
223
|
+
expect(parts.match).toBe("needle");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("runUsageScale", () => {
|
|
228
|
+
const run = (
|
|
229
|
+
cpuTimeMs?: number,
|
|
230
|
+
peakMemoryBytes?: number,
|
|
231
|
+
status: Run["status"] = "ended",
|
|
232
|
+
) => ({
|
|
233
|
+
status,
|
|
234
|
+
...(cpuTimeMs === undefined ? {} : { cpuTimeMs }),
|
|
235
|
+
...(peakMemoryBytes === undefined ? {} : { peakMemoryBytes }),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("is undefined when no finished run was measured", () => {
|
|
239
|
+
expect(runUsageScale([])).toBeUndefined();
|
|
240
|
+
expect(runUsageScale([run(5, 10, "running"), run(undefined, 10)])).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("scales to the largest run and takes the median as usual", () => {
|
|
244
|
+
expect(runUsageScale([run(100, 1400), run(110, 2900), run(30, 640), run(1, 1)])).toEqual({
|
|
245
|
+
max: { cpu: 110, ram: 2900 },
|
|
246
|
+
usual: { cpu: 100, ram: 1400 },
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("leaves out live and unmeasured runs", () => {
|
|
251
|
+
expect(runUsageScale([run(10, 10), run(999, 999, "running"), run(undefined, 999)])).toEqual(
|
|
252
|
+
{
|
|
253
|
+
max: { cpu: 10, ram: 10 },
|
|
254
|
+
usual: { cpu: 10, ram: 10 },
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("usageLevel", () => {
|
|
261
|
+
it("is 0 as usual, 1 from 1.5x, 2 from twice the usual run", () => {
|
|
262
|
+
expect(usageLevel(1400, 1400, "cpu")).toBe(0);
|
|
263
|
+
expect(usageLevel(2099, 1400, "cpu")).toBe(0);
|
|
264
|
+
expect(usageLevel(2100, 1400, "cpu")).toBe(1);
|
|
265
|
+
expect(usageLevel(2799, 1400, "cpu")).toBe(1);
|
|
266
|
+
expect(usageLevel(2800, 1400, "cpu")).toBe(2);
|
|
267
|
+
expect(usageLevel(10, 1400, "cpu")).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("judges a zero or tiny usual run against a floor", () => {
|
|
271
|
+
// A mostly idle task (median CPU 0) that burned 30s of CPU.
|
|
272
|
+
expect(usageLevel(30_000, 0, "cpu")).toBe(2);
|
|
273
|
+
expect(usageLevel(1, 0, "cpu")).toBe(0);
|
|
274
|
+
expect(usageLevel(3, 1, "cpu")).toBe(0);
|
|
275
|
+
// 64 MiB against an idle task's 1 MiB stands out; 8 MiB doesn't.
|
|
276
|
+
expect(usageLevel(64 * 1024 * 1024, 1024 * 1024, "ram")).toBe(2);
|
|
277
|
+
expect(usageLevel(8 * 1024 * 1024, 1024 * 1024, "ram")).toBe(0);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -12,7 +12,7 @@ export interface RunVerdict {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
// The run's outcome as a verb phrase, read as one sentence: "succeeded in 933ms".
|
|
15
|
-
const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
15
|
+
export const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
16
16
|
succeeded: { verb: "succeeded in", timed: true },
|
|
17
17
|
failed: { verb: "failed after", timed: true },
|
|
18
18
|
crashed: { verb: "crashed after", timed: true },
|
|
@@ -31,8 +31,21 @@ const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
|
31
31
|
queue_full: { verb: "skipped", timed: false },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
export
|
|
35
|
-
|
|
34
|
+
export interface RunEndMarker {
|
|
35
|
+
label: string;
|
|
36
|
+
tone: "muted" | "warn";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The line closing a run's console output; only abnormal ends are worth a warning tone.
|
|
40
|
+
const RUN_END_MARKERS: Partial<Record<RunStatus, RunEndMarker>> = {
|
|
41
|
+
stopped: { label: "run stopped by operator", tone: "warn" },
|
|
42
|
+
daemon_stopped: { label: "daemon stopped mid-run", tone: "warn" },
|
|
43
|
+
timeout: { label: "run timed out", tone: "warn" },
|
|
44
|
+
};
|
|
45
|
+
const DEFAULT_END_MARKER: RunEndMarker = { label: "end of output", tone: "muted" };
|
|
46
|
+
|
|
47
|
+
export function runEndMarker(status: RunStatus): RunEndMarker {
|
|
48
|
+
return RUN_END_MARKERS[status] ?? DEFAULT_END_MARKER;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
export function runDuration(
|
|
@@ -98,16 +111,82 @@ export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">):
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
/**
|
|
101
|
-
* "
|
|
102
|
-
* only), else undefined.
|
|
114
|
+
* "CPU 1m 52s · RAM 48 MB" for a run whose resources were measured (shell
|
|
115
|
+
* runs only), else undefined. CPU is the CPU time the run used, RAM the most
|
|
116
|
+
* memory it held.
|
|
103
117
|
*/
|
|
104
118
|
export function runUsageLabel(run: Pick<Run, "peakMemoryBytes" | "cpuTimeMs">): string | undefined {
|
|
105
119
|
const parts: string[] = [];
|
|
106
|
-
if (run.peakMemoryBytes !== undefined) parts.push("peak " + formatBytes(run.peakMemoryBytes));
|
|
107
120
|
if (run.cpuTimeMs !== undefined) parts.push("CPU " + formatDuration(run.cpuTimeMs));
|
|
121
|
+
if (run.peakMemoryBytes !== undefined) parts.push("RAM " + formatBytes(run.peakMemoryBytes));
|
|
108
122
|
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
109
123
|
}
|
|
110
124
|
|
|
125
|
+
/** CPU time (ms) and peak memory (bytes) of one run, or of a task's typical run. */
|
|
126
|
+
export interface RunUsageAmounts {
|
|
127
|
+
cpu: number;
|
|
128
|
+
ram: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* How a task's runs compare: `max` is its largest run (the full length of a
|
|
133
|
+
* usage bar), `usual` the median run (what "normal for this task" means).
|
|
134
|
+
*/
|
|
135
|
+
export interface RunUsageScale {
|
|
136
|
+
max: RunUsageAmounts;
|
|
137
|
+
usual: RunUsageAmounts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A finished run's measured usage, or undefined when it wasn't measured. */
|
|
141
|
+
export function runUsageAmounts(
|
|
142
|
+
run: Pick<Run, "status" | "cpuTimeMs" | "peakMemoryBytes">,
|
|
143
|
+
): RunUsageAmounts | undefined {
|
|
144
|
+
if (run.status !== "ended") return undefined;
|
|
145
|
+
if (run.cpuTimeMs === undefined || run.peakMemoryBytes === undefined) return undefined;
|
|
146
|
+
return { cpu: run.cpuTimeMs, ram: run.peakMemoryBytes };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function median(values: number[]): number {
|
|
150
|
+
const sorted = values.toSorted((a, b) => a - b);
|
|
151
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The scale for a task's usage bars, over the measured runs given. */
|
|
155
|
+
export function runUsageScale(
|
|
156
|
+
runs: readonly Pick<Run, "status" | "cpuTimeMs" | "peakMemoryBytes">[],
|
|
157
|
+
): RunUsageScale | undefined {
|
|
158
|
+
const amounts = runs.flatMap((run) => runUsageAmounts(run) ?? []);
|
|
159
|
+
if (amounts.length === 0) return undefined;
|
|
160
|
+
const cpu = amounts.map((a) => a.cpu);
|
|
161
|
+
const ram = amounts.map((a) => a.ram);
|
|
162
|
+
return {
|
|
163
|
+
max: { cpu: Math.max(...cpu), ram: Math.max(...ram) },
|
|
164
|
+
usual: { cpu: median(cpu), ram: median(ram) },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The least a usual run counts as using, so a tiny or zero baseline (a mostly
|
|
170
|
+
* idle task's median CPU time is often 0) doesn't hide a run that burns real
|
|
171
|
+
* CPU or memory, nor flag 3ms against 1ms. CPU time in ms, memory in bytes.
|
|
172
|
+
*/
|
|
173
|
+
const USAGE_FLOOR: RunUsageAmounts = { cpu: 1000, ram: 16 * 1024 * 1024 };
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* How unusual a run's amount is for its task: 0 about as usual, 1 at 1.5×
|
|
177
|
+
* the usual run, 2 at twice it or more. A task that always uses the same
|
|
178
|
+
* amount stays at 0, however large that amount is. The usual run counts as
|
|
179
|
+
* at least USAGE_FLOOR for its kind.
|
|
180
|
+
*/
|
|
181
|
+
export function usageLevel(value: number, usual: number, kind: keyof RunUsageAmounts): 0 | 1 | 2 {
|
|
182
|
+
const baseline = Math.max(usual, USAGE_FLOOR[kind]);
|
|
183
|
+
if (baseline <= 0) return 0;
|
|
184
|
+
const ratio = value / baseline;
|
|
185
|
+
if (ratio >= 2) return 2;
|
|
186
|
+
if (ratio >= 1.5) return 1;
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
111
190
|
/**
|
|
112
191
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
113
192
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
|
@@ -118,3 +197,40 @@ export function runPhaseOrder(status: string): number {
|
|
|
118
197
|
if (status === "running") return 1;
|
|
119
198
|
return 2;
|
|
120
199
|
}
|
|
200
|
+
|
|
201
|
+
/** Right-hand mono readout of a run row: live / queued / exit N / duration. */
|
|
202
|
+
export function runRowReadout(
|
|
203
|
+
run: Pick<Run, "status" | "exitCode" | "startedAt" | "endedAt">,
|
|
204
|
+
displayed: RunStatus,
|
|
205
|
+
): string {
|
|
206
|
+
if (run.status === "running") return "live";
|
|
207
|
+
if (run.status === "pending") return "queued";
|
|
208
|
+
if (displayed === "failed" || displayed === "crashed") return "exit " + String(run.exitCode);
|
|
209
|
+
return runDuration(run) ?? "—";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface HighlightParts {
|
|
213
|
+
before: string;
|
|
214
|
+
match: string;
|
|
215
|
+
after: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Split an output line into [before, match, after] around the first
|
|
220
|
+
* occurrence of the query, windowed to keep the match in view. Plain strings,
|
|
221
|
+
* so rendering them as text never lets untrusted HTML reach the DOM.
|
|
222
|
+
*/
|
|
223
|
+
export function highlightParts(text: string, query: string): HighlightParts {
|
|
224
|
+
const q = query.trim();
|
|
225
|
+
const idx = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
|
|
226
|
+
if (idx === -1) return { before: text, match: "", after: "" };
|
|
227
|
+
const start = Math.max(0, idx - 14);
|
|
228
|
+
const lead = start > 0 ? "…" : "";
|
|
229
|
+
const windowed = text.slice(start);
|
|
230
|
+
const fi = windowed.toLowerCase().indexOf(q.toLowerCase());
|
|
231
|
+
return {
|
|
232
|
+
before: lead + windowed.slice(0, fi),
|
|
233
|
+
match: windowed.slice(fi, fi + q.length),
|
|
234
|
+
after: windowed.slice(fi + q.length),
|
|
235
|
+
};
|
|
236
|
+
}
|