@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.
Files changed (53) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  3. package/src/lib/actions/fold-to-fit.ts +89 -0
  4. package/src/lib/actions/modal-dialog.test.ts +47 -0
  5. package/src/lib/actions/modal-dialog.ts +48 -0
  6. package/src/lib/components/AccordionItem.svelte +16 -5
  7. package/src/lib/components/Badge.svelte +3 -24
  8. package/src/lib/components/Button.svelte +2 -9
  9. package/src/lib/components/CodeBlock.svelte +7 -6
  10. package/src/lib/components/Drawer.svelte +22 -30
  11. package/src/lib/components/DurationInput.svelte +6 -25
  12. package/src/lib/components/FilterBar.svelte +2 -6
  13. package/src/lib/components/Link.svelte +9 -4
  14. package/src/lib/components/LogConsole.svelte +95 -418
  15. package/src/lib/components/Modal.svelte +7 -30
  16. package/src/lib/components/Spinner.svelte +10 -5
  17. package/src/lib/components/StatusIndicator.svelte +37 -104
  18. package/src/lib/components/Tabs.svelte +15 -1
  19. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  20. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  21. package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
  22. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  23. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  24. package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
  25. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  26. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  27. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  28. package/src/lib/components/dashboard/RunsList.svelte +265 -543
  29. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  30. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  31. package/src/lib/components/dashboard/cron-format.test.ts +9 -0
  32. package/src/lib/components/dashboard/cron-format.ts +5 -1
  33. package/src/lib/components/dashboard/run-filters.test.ts +60 -0
  34. package/src/lib/components/dashboard/run-filters.ts +78 -6
  35. package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
  36. package/src/lib/components/dashboard/run-helpers.ts +122 -6
  37. package/src/lib/components/dashboard/status-config.ts +18 -0
  38. package/src/lib/components/dashboard/types.ts +6 -0
  39. package/src/lib/index.ts +13 -2
  40. package/src/lib/log-console/AnsiLine.svelte +12 -0
  41. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  42. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  43. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  44. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  45. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  46. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  47. package/src/lib/utils/format.test.ts +122 -0
  48. package/src/lib/utils/format.ts +97 -4
  49. package/src/lib/utils/typing-target.ts +10 -0
  50. package/src/stories/Accordion.stories.svelte +60 -1
  51. package/src/stories/CodeBlock.stories.svelte +13 -0
  52. package/src/stories/Link.stories.svelte +25 -1
  53. 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
- const humanized = cronstrue.toString(raw, { throwExceptionOnParseError: true });
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. `dot` mirrors the group's color in RUN_STATUS_CONFIG.
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
- { key: "running", label: "Running", dot: "bg-info-surface", statuses: ["pending", "running"] },
56
- { key: "succeeded", label: "Succeeded", dot: "bg-success-surface", statuses: ["succeeded"] },
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: "bg-danger-surface",
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: "bg-on-surface-faint",
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: "bg-warning-surface",
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
- runVerdict,
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("runVerdict", () => {
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(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
27
- expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
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(runVerdict("missed").timed).toBe(false);
34
- expect(runVerdict("skipped").timed).toBe(false);
35
- expect(runVerdict("dst_skipped").timed).toBe(false);
36
- expect(runVerdict("queue_full").timed).toBe(false);
37
- expect(runVerdict("pending").timed).toBe(false);
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 peak memory and CPU time", () => {
128
- expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 1500 })).toBe(
129
- "peak 48 MB · CPU 1s",
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 function runVerdict(status: RunStatus): RunVerdict {
35
- return RUN_VERDICTS[status];
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
- * "peak 48 MB · CPU 1s" for a run whose resources were measured (shell runs
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
+ }