@runwisp/ui 1.7.0 → 1.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runwisp/ui",
3
3
  "description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@chromatic-com/storybook": "^5.2.1",
51
51
  "@eslint/js": "^10.0.1",
52
- "@runwisp/eslint-config": "1.7.0",
53
- "@runwisp/typescript-config": "1.7.0",
52
+ "@runwisp/eslint-config": "1.8.0",
53
+ "@runwisp/typescript-config": "1.8.0",
54
54
  "@storybook/addon-a11y": "^10.5.5",
55
55
  "@storybook/addon-docs": "^10.5.5",
56
56
  "@storybook/addon-svelte-csf": "^5.1.2",
@@ -92,7 +92,7 @@
92
92
  "@fontsource-variable/geist-mono": "^5.3.0",
93
93
  "@fontsource/tasa-orbiter": "^5.3.0",
94
94
  "@lucide/svelte": "^1.52.0",
95
- "@runwisp/common": "1.7.0",
95
+ "@runwisp/common": "1.8.0",
96
96
  "@tanstack/svelte-virtual": "^3.13.35",
97
97
  "ansi-to-html": "^0.7.2",
98
98
  "cron-parser": "^5.7.0",
@@ -53,6 +53,11 @@ export interface TrapFocusOptions {
53
53
  * that focuses its own search input).
54
54
  */
55
55
  autoFocus?: boolean;
56
+ /**
57
+ * When true, focusing elements inside the trap (and restoring focus upon
58
+ * teardown) will not cause the browser to scroll the viewport. Defaults to `false`.
59
+ */
60
+ preventScroll?: boolean;
56
61
  }
57
62
 
58
63
  /**
@@ -64,10 +69,10 @@ export interface TrapFocusOptions {
64
69
  * alone.
65
70
  */
66
71
  export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
67
- const autoFocus = options.autoFocus ?? true;
72
+ let currentOptions = options;
68
73
  const previouslyFocused = isHTMLElement(document.activeElement) ? document.activeElement : null;
69
74
 
70
- if (autoFocus) {
75
+ if (currentOptions.autoFocus ?? true) {
71
76
  // Deferred a tick: `node` may still be moved by a sibling `use:portal`
72
77
  // action (appendChild-ing it to <body>) after this action runs, and
73
78
  // relocating a focused element resets focus to <body>. Waiting a
@@ -78,7 +83,7 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
78
83
  if (target === node && !node.hasAttribute("tabindex")) {
79
84
  node.tabIndex = -1;
80
85
  }
81
- target.focus();
86
+ target.focus({ preventScroll: currentOptions.preventScroll ?? false });
82
87
  });
83
88
  }
84
89
 
@@ -87,13 +92,16 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
87
92
  const target = resolveTrapFocusTarget(e, getFocusableElements(node), active);
88
93
  if (target) {
89
94
  e.preventDefault();
90
- target.focus();
95
+ target.focus({ preventScroll: currentOptions.preventScroll ?? false });
91
96
  }
92
97
  }
93
98
 
94
99
  node.addEventListener("keydown", handleKeydown);
95
100
 
96
101
  return {
102
+ update(newOptions: TrapFocusOptions = {}) {
103
+ currentOptions = newOptions;
104
+ },
97
105
  destroy() {
98
106
  node.removeEventListener("keydown", handleKeydown);
99
107
  // By the time an action's destroy() runs, `node` (and whatever
@@ -108,7 +116,7 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
108
116
  const active = document.activeElement;
109
117
  const focusWasReset = !isHTMLElement(active) || active === document.body;
110
118
  if (focusWasReset && previouslyFocused && document.contains(previouslyFocused)) {
111
- previouslyFocused.focus();
119
+ previouslyFocused.focus({ preventScroll: currentOptions.preventScroll ?? false });
112
120
  }
113
121
  },
114
122
  };
@@ -229,7 +229,10 @@
229
229
  : ""}
230
230
  >
231
231
  {#if selectable}
232
- <TableCell header {density} class="w-12">
232
+ <!-- w-px: in an auto-layout table a width is only a minimum, so a
233
+ fixed width still soaks up spare space. 1px shrinks the
234
+ column to the checkbox; the next cell's padding is the gap. -->
235
+ <TableCell header {density} class="w-px pr-0">
233
236
  <Checkbox
234
237
  checked={allSelected}
235
238
  indeterminate={someSelected}
@@ -285,7 +288,9 @@
285
288
  </TableHead>
286
289
  {/if}
287
290
  <TableBody>
288
- {#if pagedData.length === 0 && !loading}
291
+ {#if pagedData.length === 0}
292
+ <!-- Kept while loading (blank) so the loading overlay has a body to
293
+ cover instead of squeezing into the header row. -->
289
294
  <TableRow border="none">
290
295
  <TableCell
291
296
  colspan={columns.length + (selectable ? 1 : 0) + (rowAction ? 1 : 0)}
@@ -293,7 +298,9 @@
293
298
  align="center"
294
299
  class="px-4 py-12 text-on-surface-muted"
295
300
  >
296
- {#if emptyState}
301
+ {#if loading}
302
+ &nbsp;
303
+ {:else if emptyState}
297
304
  {@render emptyState()}
298
305
  {:else}
299
306
  {emptyMessage}
@@ -319,7 +326,7 @@
319
326
  fire onRowClick (which usually navigates away). -->
320
327
  <TableCell
321
328
  {density}
322
- class="cursor-pointer {hoverable ? railHover : ''}"
329
+ class="cursor-pointer pr-0 {hoverable ? railHover : ''}"
323
330
  onclick={(e) => {
324
331
  e.stopPropagation();
325
332
  if (!(e.target instanceof HTMLInputElement)) toggleRow(row);
@@ -89,7 +89,7 @@
89
89
  {#if open}
90
90
  <div
91
91
  use:portal
92
- use:trapFocus
92
+ use:trapFocus={{ preventScroll: true }}
93
93
  use:floating={{
94
94
  reference: triggerEl,
95
95
  placement: align === "right" ? "bottom-end" : "bottom-start",
@@ -495,18 +495,25 @@
495
495
  {/if}
496
496
 
497
497
  {#if cache.finished && cache.totalLines > 0}
498
+ <!-- Pinned to the visible width, not the scroll surface: one very
499
+ long line would otherwise centre the marker far off-screen. -->
498
500
  <div
499
- class="sentinel absolute right-4 left-4 flex justify-center select-none"
501
+ class="sentinel absolute right-0 left-0 select-none"
500
502
  style="top: {lineTop(cache.totalLines)}px; height: {lineHeight * 2}px;"
501
503
  >
502
- <span
503
- class="mt-[14px] border-t border-dashed pt-3 text-center text-[11px] tracking-wide {endTone ===
504
- 'warn'
505
- ? 'sentinel--warn'
506
- : 'sentinel--muted'}"
504
+ <div
505
+ class="sticky left-0 flex justify-center px-4"
506
+ style:width={containerWidth ? `${String(containerWidth)}px` : "100%"}
507
507
  >
508
- ── {endLabel} ──
509
- </span>
508
+ <span
509
+ class="mt-[14px] border-t border-dashed pt-3 text-center text-[11px] tracking-wide {endTone ===
510
+ 'warn'
511
+ ? 'sentinel--warn'
512
+ : 'sentinel--muted'}"
513
+ >
514
+ ── {endLabel} ──
515
+ </span>
516
+ </div>
510
517
  </div>
511
518
  {/if}
512
519
 
@@ -538,8 +545,17 @@
538
545
  class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
539
546
  >
540
547
  <div class="text-center">
541
- <div class="mb-1 text-lg">No output yet</div>
542
- <div class="text-sm text-[var(--rw-con-gutter)]">Waiting for logs...</div>
548
+ {#if cache.finished}
549
+ <div class="mb-1 text-lg">No output</div>
550
+ <div class="text-sm text-[var(--rw-con-gutter)]">
551
+ The run ended without printing anything.
552
+ </div>
553
+ {:else}
554
+ <div class="mb-1 text-lg">No output yet</div>
555
+ <div class="text-sm text-[var(--rw-con-gutter)]">
556
+ Waiting for logs...
557
+ </div>
558
+ {/if}
543
559
  </div>
544
560
  </div>
545
561
  {/if}
@@ -12,6 +12,7 @@
12
12
  import { isTypingTarget } from "../../utils/typing-target.js";
13
13
  import { displayStatus, type Run } from "@runwisp/common";
14
14
  import { RUN_STATUS_CONFIG } from "./status-config.js";
15
+ import { humanizeStatus } from "./run-filters.js";
15
16
  import { runEndMarker } from "./run-helpers.js";
16
17
 
17
18
  let {
@@ -220,7 +221,7 @@
220
221
  {run.taskName}{suffix}
221
222
  </span>
222
223
  <span class="text-[var(--rw-con-gutter)]">·</span>
223
- <span class="font-semibold capitalize {config.color}">{status}</span>
224
+ <span class="font-semibold {config.color}">{humanizeStatus(status)}</span>
224
225
  </span>
225
226
  {:else}
226
227
  <span
@@ -4,15 +4,21 @@
4
4
  <script lang="ts">
5
5
  import { displayStatus, type Run } from "@runwisp/common";
6
6
  import { RUN_STATUS_CONFIG } from "./status-config.js";
7
+ import { humanizeStatus } from "./run-filters.js";
7
8
  import {
8
9
  formatTriggeredByLabel,
9
10
  runRetryLabel,
10
11
  runRowReadout,
11
12
  runUsageAmounts,
12
13
  runUsageLabel,
14
+ runDurationMs,
13
15
  usageLevel,
14
16
  highlightParts,
15
- type RunUsageAmounts,
17
+ formatLines,
18
+ outputUnusual,
19
+ runOutputLabel,
20
+ runOutputLines,
21
+ type OutputScale,
16
22
  type RunUsageScale,
17
23
  } from "./run-helpers.js";
18
24
  import type { RunOutputMatch } from "./types.js";
@@ -35,6 +41,8 @@
35
41
  bulkActions,
36
42
  selectionActive,
37
43
  usage,
44
+ output,
45
+ durationUsual,
38
46
  onselect,
39
47
  }: {
40
48
  run: Run;
@@ -52,6 +60,10 @@
52
60
  * no run has usage yet (rows keep the bars' room), undefined for none.
53
61
  */
54
62
  usage: RunUsageScale | null | undefined;
63
+ /** The task's output scale for the rail's third bar, undefined for none. */
64
+ output: OutputScale | undefined;
65
+ /** The task's usual run duration in ms, undefined for none. */
66
+ durationUsual: number | undefined;
55
67
  onselect: (runId: string) => void;
56
68
  } = $props();
57
69
 
@@ -61,6 +73,49 @@
61
73
  const startedAt = $derived(run.startedAt ?? run.createdAt);
62
74
  const retry = $derived(runRetryLabel(run));
63
75
  const amounts = $derived(usage ? runUsageAmounts(run) : undefined);
76
+ const lines = $derived(output ? runOutputLines(run) : undefined);
77
+ const barsTitle = $derived(
78
+ [
79
+ [runUsageLabel(run), runOutputLabel(run)].filter(Boolean).join(" · "),
80
+ [
81
+ usage &&
82
+ `CPU ${formatDuration(usage.usual.cpu)} · RAM ${formatBytes(usage.usual.ram)}`,
83
+ output?.band &&
84
+ `${formatLines(output.band.p10)} to ${formatLines(output.band.p90)}`,
85
+ ]
86
+ .filter(Boolean)
87
+ .join(" · "),
88
+ ]
89
+ .filter(Boolean)
90
+ .join(". Usual for this task: "),
91
+ );
92
+ // The readout shows the duration only for runs that didn't fail; a failed
93
+ // one shows its exit code, which stays plain.
94
+ const durationMs = $derived(
95
+ durationUsual === undefined || dstatus === "failed" || dstatus === "crashed"
96
+ ? undefined
97
+ : runDurationMs(run),
98
+ );
99
+ const durationLevel = $derived(
100
+ durationMs === undefined || durationUsual === undefined
101
+ ? 0
102
+ : usageLevel(durationMs, durationUsual, "duration"),
103
+ );
104
+ const readoutTitle = $derived(
105
+ [
106
+ retry,
107
+ durationLevel > 0 &&
108
+ durationUsual !== undefined &&
109
+ `Usual for this task: ${formatDuration(durationUsual)}`,
110
+ ]
111
+ .filter(Boolean)
112
+ .join(". ") || undefined,
113
+ );
114
+ const READOUT_COLOR = [
115
+ "text-on-surface-faint",
116
+ "text-warning-soft-text",
117
+ "text-danger-soft-text",
118
+ ];
64
119
  const tight = $derived(usage === undefined ? "" : "@max-[19rem]:hidden");
65
120
  const LEVEL_COLOR = ["bg-success-surface", "bg-warning-surface", "bg-danger-surface"];
66
121
  // With bulk actions on, the status dot fades out on row hover, or whenever a
@@ -91,9 +146,8 @@
91
146
  >{/if}
92
147
  </span>
93
148
  <span class="shrink-0 text-on-surface-faint">·</span>
94
- <span
95
- class="shrink-0 font-mono text-[12px] font-semibold capitalize {config.color}"
96
- >{dstatus}</span
149
+ <span class="shrink-0 font-mono text-[12px] font-semibold {config.color}"
150
+ >{humanizeStatus(dstatus)}</span
97
151
  >
98
152
  </span>
99
153
  <span
@@ -134,31 +188,51 @@
134
188
  <!-- With usage bars a tight rail drops the status word; the dot
135
189
  keeps its colour. -->
136
190
  <span class="text-on-surface-faint {tight}">·</span>
137
- <span
138
- class="font-mono text-[12.5px] font-semibold capitalize {config.color} {tight}"
139
- >{dstatus}</span
191
+ <span class="font-mono text-[12.5px] font-semibold {config.color} {tight}"
192
+ >{humanizeStatus(dstatus)}</span
140
193
  >
141
194
  {#if suffix}
142
195
  <span class="font-mono text-2xs text-on-surface-faint">{suffix}</span>
143
196
  {/if}
144
197
  </span>
145
- {#if usage && amounts}
198
+ {#if usage !== undefined && (amounts || lines !== undefined)}
199
+ <!-- Fixed slots, CPU over RAM over lines printed, so a run
200
+ missing one (a non-shell backend has no CPU/RAM) keeps the
201
+ others aligned with its neighbours. -->
146
202
  <span
147
203
  class="flex w-6 shrink-0 cursor-help flex-col gap-0.5"
148
- title="{runUsageLabel(run)}. Usual for this task: CPU {formatDuration(
149
- usage.usual.cpu,
150
- )} · RAM {formatBytes(usage.usual.ram)}"
204
+ title={barsTitle}
151
205
  data-testid="run-usage-bars"
152
206
  >
153
- {@render bar(amounts.cpu, usage.max.cpu, usage.usual.cpu, "cpu")}
154
- {@render bar(amounts.ram, usage.max.ram, usage.usual.ram, "ram")}
207
+ {#if usage && amounts}
208
+ {@render bar(
209
+ amounts.cpu,
210
+ usage.max.cpu,
211
+ usageLevel(amounts.cpu, usage.usual.cpu, "cpu"),
212
+ )}
213
+ {@render bar(
214
+ amounts.ram,
215
+ usage.max.ram,
216
+ usageLevel(amounts.ram, usage.usual.ram, "ram"),
217
+ )}
218
+ {:else}
219
+ <span class="block h-[3px]"></span>
220
+ <span class="block h-[3px]"></span>
221
+ {/if}
222
+ {#if output && lines !== undefined}
223
+ {@render bar(
224
+ lines,
225
+ output.max,
226
+ output.band && outputUnusual(lines, output.band) ? 1 : 0,
227
+ )}
228
+ {/if}
155
229
  </span>
156
230
  {:else if usage !== undefined}
157
231
  <span class="w-6 shrink-0" aria-hidden="true"></span>
158
232
  {/if}
159
233
  <span
160
- class="shrink-0 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
161
- title={retry ?? undefined}
234
+ class="shrink-0 font-mono text-[11.5px] {READOUT_COLOR[durationLevel]} tabular-nums"
235
+ title={readoutTitle}
162
236
  >
163
237
  {runRowReadout(run, dstatus)}
164
238
  </span>
@@ -183,10 +257,10 @@
183
257
  ></div>
184
258
  </button>
185
259
 
186
- {#snippet bar(value: number, max: number, usual: number, kind: keyof RunUsageAmounts)}
260
+ {#snippet bar(value: number, max: number, level: 0 | 1 | 2)}
187
261
  <span class="block h-[3px] overflow-hidden rounded-[1px] bg-outline-faint">
188
262
  <span
189
- class="block h-full {LEVEL_COLOR[usageLevel(value, usual, kind)]}"
263
+ class="block h-full {LEVEL_COLOR[level]}"
190
264
  style="width: {String(max > 0 ? Math.max(4, Math.round((100 * value) / max)) : 4)}%"
191
265
  ></span>
192
266
  </span>
@@ -24,8 +24,13 @@
24
24
  import RunRow from "./RunRow.svelte";
25
25
  import type { RunOutputMatch } from "./types.js";
26
26
  import type { Run, RunSelector } from "@runwisp/common";
27
- import { runFilterParams, type RunsListFilters } from "./run-filters.js";
28
- import { instanceSuffix, runUsageScale } from "./run-helpers.js";
27
+ import {
28
+ activeFilterCount,
29
+ emptyRunFilters,
30
+ runFilterParams,
31
+ type RunsListFilters,
32
+ } from "./run-filters.js";
33
+ import { instanceSuffix, runOutputScale, runUsageScale, usualDuration } from "./run-helpers.js";
29
34
  import { formatDateTime } from "../../utils/format.js";
30
35
 
31
36
  type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
@@ -75,7 +80,7 @@
75
80
  showTaskName?: boolean;
76
81
  taskNameFilter?: string;
77
82
  emptyText?: string;
78
- emptyDescription?: string;
83
+ emptyDescription?: string | undefined;
79
84
  bulkActions?: boolean;
80
85
  onBulkCancel?: BulkHandler;
81
86
  onBulkDelete?: BulkHandler;
@@ -99,8 +104,9 @@
99
104
  // True while a query is typed but its results aren't in yet (debounce
100
105
  // window or request in flight), the rail shows its searching shimmer.
101
106
  outputSearchPending?: boolean;
102
- // Task rail only: two small bars per finished run, CPU time over RAM,
103
- // scaled to the largest loaded run and colored against the usual one.
107
+ // Task rail only: three small bars per finished run, CPU time, RAM and
108
+ // lines printed, scaled to the largest loaded run and colored against
109
+ // the usual one. The duration readout is colored against the usual run too.
104
110
  usageBars?: boolean;
105
111
  // Folds the list to a thin strip that still counts the runs, giving the
106
112
  // run detail the width. Omitted where the screen keeps the list open.
@@ -109,6 +115,8 @@
109
115
  } = $props();
110
116
 
111
117
  const usageScale = $derived(usageBars ? runUsageScale(items) : undefined);
118
+ const outputScale = $derived(usageBars ? runOutputScale(items) : undefined);
119
+ const durationUsual = $derived(usageBars ? usualDuration(items) : undefined);
112
120
 
113
121
  // Task-rail rows are one dense line (44px); the cross-task /runs view adds
114
122
  // the task name on a second line (64px).
@@ -246,6 +254,12 @@
246
254
  };
247
255
  }
248
256
 
257
+ // An empty list under filters says so, rather than claiming no runs exist.
258
+ const filtered = $derived(activeFilterCount(filters) > 0 || filters.search !== "");
259
+ function clearFilters() {
260
+ filters = { ...emptyRunFilters(), sortDirection: filters.sortDirection };
261
+ }
262
+
249
263
  // The count stands in for a title: "12 runs", or matches while searching.
250
264
  // Empty until the first page lands, when the count isn't known yet.
251
265
  const countLabel = $derived.by(() => {
@@ -420,7 +434,21 @@
420
434
  {/if}
421
435
  {:else if items.length === 0 && loading}
422
436
  <RunListSkeleton label="Loading runs" {showTaskName} />
423
- {:else if items.length === 0}
437
+ {:else if items.length === 0 && filtered}
438
+ <EmptyState
439
+ title="No matching runs"
440
+ description="No run matches the current filters."
441
+ icon={Clock}
442
+ iconSize={32}
443
+ class="py-8"
444
+ >
445
+ {#snippet actions()}
446
+ <Button variant="secondary" size="sm" onclick={clearFilters}>
447
+ Clear filters
448
+ </Button>
449
+ {/snippet}
450
+ </EmptyState>
451
+ {:else if items.length === 0 && emptyText}
424
452
  <EmptyState
425
453
  title={emptyText}
426
454
  description={emptyDescription}
@@ -504,6 +532,8 @@
504
532
  {bulkActions}
505
533
  {selectionActive}
506
534
  usage={usageBars ? (usageScale ?? null) : undefined}
535
+ output={outputScale}
536
+ {durationUsual}
507
537
  onselect={selectRun}
508
538
  />
509
539
  {/snippet}
@@ -13,16 +13,16 @@ describe("humanizeCron", () => {
13
13
 
14
14
  it("humanizes a daily expression", () => {
15
15
  const result = humanizeCron("0 3 * * *");
16
- expect(result.humanized).toBe("At 03:00 AM");
16
+ expect(result.humanized).toBe("At 03:00");
17
17
  expect(result.isHumanized).toBe(true);
18
18
  });
19
19
 
20
20
  it("describes the nth and last weekday of the month", () => {
21
21
  expect(humanizeCron("0 3 * * FRI#3").humanized).toBe(
22
- "At 03:00 AM, on the third Friday of the month",
22
+ "At 03:00, on the third Friday of the month",
23
23
  );
24
24
  const last = humanizeCron("0 3 * * FRI#L");
25
- expect(last.humanized).toBe("At 03:00 AM, on the last Friday of the month");
25
+ expect(last.humanized).toBe("At 03:00, on the last Friday of the month");
26
26
  expect(last.raw).toBe("0 3 * * FRI#L");
27
27
  });
28
28
 
@@ -67,9 +67,10 @@ export function humanizeCron(cron: string): HumanizedCron {
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".
70
+ // "on the null Friday of the month". 24-hour, like every other clock in the UI.
71
71
  const humanized = cronstrue.toString(raw.replace(/#L/gi, "L"), {
72
72
  throwExceptionOnParseError: true,
73
+ use24HourTimeFormat: true,
73
74
  });
74
75
  return { humanized, raw, isHumanized: true };
75
76
  } catch {
@@ -15,6 +15,12 @@ import {
15
15
  runUsageLabel,
16
16
  runUsageScale,
17
17
  usageLevel,
18
+ usualDuration,
19
+ outputBand,
20
+ runOutputScale,
21
+ outputUnusual,
22
+ formatLines,
23
+ runOutputLabel,
18
24
  instanceSuffix,
19
25
  runLogDownloadUrl,
20
26
  } from "./run-helpers.js";
@@ -277,3 +283,66 @@ describe("usageLevel", () => {
277
283
  expect(usageLevel(8 * 1024 * 1024, 1024 * 1024, "ram")).toBe(0);
278
284
  });
279
285
  });
286
+
287
+ describe("usualDuration", () => {
288
+ const ran = (seconds: number, status: Run["status"] = "ended") => ({
289
+ status,
290
+ startedAt: "2026-01-01T00:00:00Z",
291
+ endedAt: new Date(Date.parse("2026-01-01T00:00:00Z") + seconds * 1000).toISOString(),
292
+ });
293
+
294
+ it("takes the median over finished runs only", () => {
295
+ expect(usualDuration([ran(20), ran(240), ran(3600, "running"), ran(18)])).toBe(20_000);
296
+ expect(usualDuration([{ status: "ended" }, ran(5, "running")])).toBeUndefined();
297
+ });
298
+
299
+ it("flags a run far slower than usual, with a 5s floor", () => {
300
+ expect(usageLevel(240_000, 20_000, "duration")).toBe(2);
301
+ expect(usageLevel(31_000, 20_000, "duration")).toBe(1);
302
+ expect(usageLevel(22_000, 20_000, "duration")).toBe(0);
303
+ // 3s against a usual 50ms is jitter, not an outlier.
304
+ expect(usageLevel(3_000, 50, "duration")).toBe(0);
305
+ });
306
+ });
307
+
308
+ describe("output band", () => {
309
+ const printed = (
310
+ stdoutLines: number,
311
+ stderrLines = 0,
312
+ ): Pick<Run, "status" | "stdoutLines" | "stderrLines"> => ({
313
+ status: "ended",
314
+ stdoutLines,
315
+ stderrLines,
316
+ });
317
+
318
+ it("takes the nearest-rank p10 and p90 once ten runs have counts", () => {
319
+ const runs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => printed(n));
320
+ expect(outputBand(runs)).toEqual({ p10: 1, p90: 9 });
321
+ expect(outputBand(runs.slice(1))).toBeUndefined();
322
+ });
323
+
324
+ it("scales the output bars to the chattiest run", () => {
325
+ expect(runOutputScale([printed(2), printed(40, 2)])).toEqual({ max: 42, band: undefined });
326
+ expect(runOutputScale([{ status: "ended" }])).toBeUndefined();
327
+ });
328
+
329
+ it("flags clear outliers only", () => {
330
+ expect(outputUnusual(3, { p10: 0, p90: 0 })).toBe(true);
331
+ expect(outputUnusual(0, { p10: 0, p90: 0 })).toBe(false);
332
+ expect(outputUnusual(3, { p10: 480, p90: 520 })).toBe(true);
333
+ expect(outputUnusual(5000, { p10: 480, p90: 520 })).toBe(true);
334
+ expect(outputUnusual(300, { p10: 480, p90: 520 })).toBe(false);
335
+ expect(outputUnusual(3, { p10: 0, p90: 1 })).toBe(false);
336
+ });
337
+
338
+ it("labels what a run printed", () => {
339
+ expect(formatLines(1)).toBe("1 line");
340
+ expect(formatLines(1234)).toBe("1.2K lines");
341
+ expect(runOutputLabel(printed(0))).toBe("no output");
342
+ expect(runOutputLabel(printed(9, 3))).toBe("12 lines · 3 on stderr");
343
+ expect(runOutputLabel(printed(0, 2))).toBe("2 lines · all on stderr");
344
+ expect(
345
+ runOutputLabel({ status: "running", stdoutLines: 1, stderrLines: 0 }),
346
+ ).toBeUndefined();
347
+ });
348
+ });
@@ -165,12 +165,30 @@ export function runUsageScale(
165
165
  };
166
166
  }
167
167
 
168
+ /** A finished run's wall time in ms, or undefined while it is still going. */
169
+ export function runDurationMs(
170
+ run: Pick<Run, "status" | "startedAt" | "endedAt">,
171
+ ): number | undefined {
172
+ if (run.status !== "ended" || !run.startedAt || !run.endedAt) return undefined;
173
+ return new Date(run.endedAt).getTime() - new Date(run.startedAt).getTime();
174
+ }
175
+
176
+ /** The median wall time in ms over the finished runs given, the baseline a slow run is judged against. */
177
+ export function usualDuration(
178
+ runs: readonly Pick<Run, "status" | "startedAt" | "endedAt">[],
179
+ ): number | undefined {
180
+ const durations = runs.flatMap((run) => runDurationMs(run) ?? []);
181
+ return durations.length > 0 ? median(durations) : undefined;
182
+ }
183
+
168
184
  /**
169
185
  * The least a usual run counts as using, so a tiny or zero baseline (a mostly
170
186
  * 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.
187
+ * CPU or memory, nor flag 3ms against 1ms. CPU time and duration in ms, memory
188
+ * in bytes. Duration's floor is higher since wall time also carries network
189
+ * and scheduling jitter. Mirrors the TUI's uikit.UsageFloor*.
172
190
  */
173
- const USAGE_FLOOR: RunUsageAmounts = { cpu: 1000, ram: 16 * 1024 * 1024 };
191
+ const USAGE_FLOOR = { cpu: 1000, ram: 16 * 1024 * 1024, duration: 5000 };
174
192
 
175
193
  /**
176
194
  * How unusual a run's amount is for its task: 0 about as usual, 1 at 1.5×
@@ -178,7 +196,11 @@ const USAGE_FLOOR: RunUsageAmounts = { cpu: 1000, ram: 16 * 1024 * 1024 };
178
196
  * amount stays at 0, however large that amount is. The usual run counts as
179
197
  * at least USAGE_FLOOR for its kind.
180
198
  */
181
- export function usageLevel(value: number, usual: number, kind: keyof RunUsageAmounts): 0 | 1 | 2 {
199
+ export function usageLevel(
200
+ value: number,
201
+ usual: number,
202
+ kind: keyof typeof USAGE_FLOOR,
203
+ ): 0 | 1 | 2 {
182
204
  const baseline = Math.max(usual, USAGE_FLOOR[kind]);
183
205
  if (baseline <= 0) return 0;
184
206
  const ratio = value / baseline;
@@ -187,6 +209,102 @@ export function usageLevel(value: number, usual: number, kind: keyof RunUsageAmo
187
209
  return 0;
188
210
  }
189
211
 
212
+ /** Lines a finished run printed on stdout and stderr together, or undefined without counts. */
213
+ export function runOutputLines(
214
+ run: Pick<Run, "status" | "stdoutLines" | "stderrLines">,
215
+ ): number | undefined {
216
+ if (run.status !== "ended") return undefined;
217
+ if (run.stdoutLines === undefined || run.stderrLines === undefined) return undefined;
218
+ return run.stdoutLines + run.stderrLines;
219
+ }
220
+
221
+ /** The range of output a task's runs usually print, in lines: its 10th and 90th percentile. */
222
+ export interface OutputBand {
223
+ p10: number;
224
+ p90: number;
225
+ }
226
+
227
+ /** How many finished runs a task needs before a run is judged against its band. */
228
+ const OUTPUT_BAND_MIN_RUNS = 10;
229
+ /** The fewest lines a run must be past the band's edge, so 3 against a usual 1 isn't flagged. */
230
+ const OUTPUT_BAND_SLACK = 5;
231
+
232
+ /** The p-th percentile of sorted values, by the nearest-rank method. */
233
+ function nearestRank(sorted: number[], p: number): number {
234
+ const rank = Math.max(Math.ceil((p * sorted.length) / 100), 1);
235
+ return sorted[rank - 1] ?? 0;
236
+ }
237
+
238
+ /**
239
+ * The output band over the finished runs given, or undefined while fewer than
240
+ * OUTPUT_BAND_MIN_RUNS have line counts. Mirrors the TUI's uikit.UsualOutput.
241
+ */
242
+ export function outputBand(
243
+ runs: readonly Pick<Run, "status" | "stdoutLines" | "stderrLines">[],
244
+ ): OutputBand | undefined {
245
+ const lines = runs.flatMap((run) => runOutputLines(run) ?? []);
246
+ if (lines.length < OUTPUT_BAND_MIN_RUNS) return undefined;
247
+ const sorted = lines.toSorted((a, b) => a - b);
248
+ return { p10: nearestRank(sorted, 10), p90: nearestRank(sorted, 90) };
249
+ }
250
+
251
+ /**
252
+ * Whether a line count is clearly outside the band: more than twice its 90th
253
+ * percentile or under half its 10th, and at least OUTPUT_BAND_SLACK lines past
254
+ * it. A task that is silent nearly every time is flagged by any output at all.
255
+ */
256
+ export function outputUnusual(lines: number, band: OutputBand): boolean {
257
+ if (band.p90 === 0) return lines > 0;
258
+ const high = lines > 2 * band.p90 && lines - band.p90 >= OUTPUT_BAND_SLACK;
259
+ const low = 2 * lines < band.p10 && band.p10 - lines >= OUTPUT_BAND_SLACK;
260
+ return high || low;
261
+ }
262
+
263
+ /**
264
+ * How a task's runs compare on output: `max` is its chattiest run (the full
265
+ * length of an output bar), `band` its usual range, undefined until enough
266
+ * runs have counts. Undefined when no run has counts yet.
267
+ */
268
+ export interface OutputScale {
269
+ max: number;
270
+ band: OutputBand | undefined;
271
+ }
272
+
273
+ /** The scale for a task's output bars, over the finished runs given. */
274
+ export function runOutputScale(
275
+ runs: readonly Pick<Run, "status" | "stdoutLines" | "stderrLines">[],
276
+ ): OutputScale | undefined {
277
+ const lines = runs.flatMap((run) => runOutputLines(run) ?? []);
278
+ if (lines.length === 0) return undefined;
279
+ return { max: Math.max(...lines), band: outputBand(runs) };
280
+ }
281
+
282
+ const compactNumber = new Intl.NumberFormat("en", {
283
+ notation: "compact",
284
+ maximumFractionDigits: 1,
285
+ });
286
+
287
+ /** "1 line", "37 lines", "1.2K lines". */
288
+ export function formatLines(lines: number): string {
289
+ return lines === 1 ? "1 line" : `${compactNumber.format(lines)} lines`;
290
+ }
291
+
292
+ /**
293
+ * How much a run printed: "no output", "12 lines", "12 lines · 3 on stderr",
294
+ * or undefined for a run without counts.
295
+ */
296
+ export function runOutputLabel(
297
+ run: Pick<Run, "status" | "stdoutLines" | "stderrLines">,
298
+ ): string | undefined {
299
+ const lines = runOutputLines(run);
300
+ if (lines === undefined) return undefined;
301
+ if (lines === 0) return "no output";
302
+ const stderr = run.stderrLines ?? 0;
303
+ if (stderr === 0) return formatLines(lines);
304
+ if (stderr === lines) return `${formatLines(lines)} · all on stderr`;
305
+ return `${formatLines(lines)} · ${String(stderr)} on stderr`;
306
+ }
307
+
190
308
  /**
191
309
  * Monotonic position of a run's status in its lifecycle. Used to reject stale
192
310
  * updates (e.g. a `pending` HTTP response arriving after an SSE already
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { LogCache } from "./LogCache.svelte";
6
+
7
+ describe("LogCache", () => {
8
+ it("merges a slice, skipping bad keys and reporting what changed", () => {
9
+ const cache = new LogCache();
10
+ expect(cache.mergeSlice({ 2: "two", 5: "five", [-1]: "neg" })).toEqual({
11
+ touched: true,
12
+ min: 2,
13
+ max: 5,
14
+ });
15
+ expect(cache.size).toBe(2);
16
+ expect(cache.mergeSlice({ 2: "two" }).touched).toBe(false);
17
+ });
18
+
19
+ it("keeps the widest line width even after that line is gone", () => {
20
+ const cache = new LogCache();
21
+ cache.mergeSlice({ 0: "x".repeat(40) });
22
+ cache.mergeSlice({ 0: "short" });
23
+ expect(cache.maxLineColumns).toBe(40);
24
+ });
25
+
26
+ it("only grows totals and the first available line from events", () => {
27
+ const cache = new LogCache();
28
+ cache.applyEvent({
29
+ lines: {},
30
+ sizeLines: 10,
31
+ sizeBytes: 300,
32
+ finished: false,
33
+ firstAvailableLine: 4,
34
+ });
35
+ cache.applyEvent({
36
+ lines: {},
37
+ sizeLines: 8,
38
+ sizeBytes: 100,
39
+ finished: false,
40
+ firstAvailableLine: 2,
41
+ });
42
+ expect([cache.totalLines, cache.totalBytes, cache.firstAvailableLine]).toEqual([
43
+ 10, 300, 4,
44
+ ]);
45
+ });
46
+
47
+ it("records frame history only for lines that have some", () => {
48
+ const cache = new LogCache();
49
+ cache.applyEvent({
50
+ lines: { 3: "100%" },
51
+ sizeLines: 4,
52
+ finished: false,
53
+ frameCounts: { 3: 12, 1: 0 },
54
+ });
55
+ expect([...cache.frameCounts]).toEqual([[3, 12]]);
56
+ });
57
+
58
+ it("orders the live overlay stdout, stderr, then other streams by name", () => {
59
+ const cache = new LogCache();
60
+ cache.applyRegion({ stream: "zeta", epoch: 1, rows: ["z"] });
61
+ cache.applyRegion({ stream: "stderr", epoch: 1, rows: ["e"] });
62
+ cache.applyRegion({ stream: "alpha", epoch: 1, rows: ["a"] });
63
+ cache.applyRegion({ stream: "stdout", epoch: 1, rows: ["o1", "o2"] });
64
+ expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a", "z"]);
65
+
66
+ cache.applyRegion({ stream: "zeta", epoch: 2, rows: [] });
67
+ expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a"]);
68
+ });
69
+
70
+ it("keeps region frames out of the committed lines and drops them once finished", () => {
71
+ const cache = new LogCache();
72
+ const res = cache.applyEvent({
73
+ lines: {},
74
+ sizeLines: 0,
75
+ finished: false,
76
+ region: { stream: "stdout", epoch: 1, rows: ["[##..] 50%"] },
77
+ });
78
+ expect(res.touched).toBe(false);
79
+ expect(cache.size).toBe(0);
80
+ expect(cache.overlayRows).toEqual(["[##..] 50%"]);
81
+
82
+ cache.applyEvent({ lines: { 0: "done" }, sizeLines: 1, finished: true });
83
+ expect(cache.overlayRows).toEqual([]);
84
+ expect(cache.finished).toBe(true);
85
+ });
86
+
87
+ it("prunes lines far from the viewport once the cache is full", () => {
88
+ const cache = new LogCache();
89
+ const slice: Record<number, string> = {};
90
+ for (let n = 0; n < cache.MAX_CACHE_SIZE; n++) slice[n] = "l";
91
+ cache.mergeSlice(slice);
92
+
93
+ cache.prune(40_000, 40_100);
94
+ expect(cache.lines.has(40_050)).toBe(true);
95
+ expect(cache.lines.has(0)).toBe(false);
96
+ expect(cache.size).toBeLessThan(cache.MAX_CACHE_SIZE);
97
+ });
98
+
99
+ it("leaves a cache under the limit alone", () => {
100
+ const cache = new LogCache();
101
+ cache.mergeSlice({ 0: "a", 99_999: "b" });
102
+ cache.prune(50_000, 50_010);
103
+ expect(cache.size).toBe(2);
104
+ });
105
+
106
+ it("judges a range complete within what the server still holds", () => {
107
+ const cache = new LogCache();
108
+ cache.applyEvent({
109
+ lines: { 5: "a", 6: "b" },
110
+ sizeLines: 7,
111
+ finished: false,
112
+ firstAvailableLine: 5,
113
+ });
114
+ expect(cache.isRangeComplete(0, 100)).toBe(true);
115
+ cache.applyEvent({ lines: {}, sizeLines: 8, finished: false });
116
+ expect(cache.isRangeComplete(0, 100)).toBe(false);
117
+ });
118
+
119
+ it("resets to empty", () => {
120
+ const cache = new LogCache();
121
+ cache.applyEvent({
122
+ lines: { 0: "a" },
123
+ sizeLines: 1,
124
+ finished: true,
125
+ firstAvailableLine: 1,
126
+ });
127
+ cache.reset();
128
+ expect([cache.size, cache.totalLines, cache.finished, cache.firstAvailableLine]).toEqual([
129
+ 0,
130
+ 0,
131
+ false,
132
+ 0,
133
+ ]);
134
+ });
135
+ });
@@ -63,7 +63,7 @@
63
63
  {/if}
64
64
  </div>
65
65
  <div class="flex items-center gap-4">
66
- <span>{totalLines.toLocaleString()} lines</span>
66
+ <span>{totalLines.toLocaleString()} {totalLines === 1 ? "line" : "lines"}</span>
67
67
  {#if totalBytes > 0}
68
68
  <span>{formatBytes(totalBytes)}</span>
69
69
  {/if}
@@ -0,0 +1,159 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { LogCache } from "./LogCache.svelte";
6
+ import { LogFetcher } from "./LogFetcher.svelte";
7
+ import type { LogEvent, LogSlice } from "./types";
8
+
9
+ const CHUNK = 4096;
10
+
11
+ /** A cache that knows the log is `total` lines long but holds none of them. */
12
+ function emptyCache(total: number): LogCache {
13
+ const cache = new LogCache();
14
+ cache.applyEvent({ lines: {}, sizeLines: total, finished: false });
15
+ return cache;
16
+ }
17
+
18
+ /** A fetch whose calls stay pending until the test settles them. */
19
+ function deferredFetch() {
20
+ const pending: { from: number; to: number; settle: (v: LogSlice | LogEvent) => void }[] = [];
21
+ const fn = vi.fn(
22
+ (from: number, to: number) =>
23
+ new Promise<LogSlice | LogEvent>((resolve) => {
24
+ pending.push({ from, to, settle: resolve });
25
+ }),
26
+ );
27
+ return { fn, pending };
28
+ }
29
+
30
+ const live: LogFetcher[] = [];
31
+
32
+ function start(...args: ConstructorParameters<typeof LogFetcher>): LogFetcher {
33
+ const fetcher = new LogFetcher(...args);
34
+ live.push(fetcher);
35
+ return fetcher;
36
+ }
37
+
38
+ beforeEach(() => {
39
+ vi.useFakeTimers();
40
+ });
41
+
42
+ afterEach(() => {
43
+ for (const fetcher of live.splice(0)) fetcher.destroy();
44
+ vi.useRealTimers();
45
+ });
46
+
47
+ describe("LogFetcher", () => {
48
+ it("fetches a missing chunk once, merges it, and reports the loaded range", async () => {
49
+ const cache = emptyCache(10);
50
+ const onLoaded = vi.fn();
51
+ const fetchLogs = vi.fn(() => ({ 0: "a", 9: "j" }));
52
+ const fetcher = start(cache, fetchLogs, onLoaded);
53
+
54
+ fetcher.enqueue(0, 9);
55
+ fetcher.enqueue(2, 5); // same chunk, already queued
56
+ await vi.runAllTimersAsync();
57
+
58
+ expect(fetchLogs).toHaveBeenCalledExactlyOnceWith(0, 9);
59
+ expect(cache.lines.get(9)).toBe("j");
60
+ expect(onLoaded).toHaveBeenCalledWith(0, 9);
61
+ expect(fetcher.isFetching).toBe(false);
62
+ });
63
+
64
+ it("applies an event response, not just a slice", async () => {
65
+ const cache = emptyCache(1);
66
+ const fetcher = start(cache, () => ({ lines: { 0: "x" }, sizeLines: 3, finished: true }));
67
+ fetcher.enqueue(0, 0);
68
+ await vi.runAllTimersAsync();
69
+ expect([cache.totalLines, cache.finished]).toEqual([3, true]);
70
+ });
71
+
72
+ it("skips a range the cache already holds", async () => {
73
+ const cache = emptyCache(2);
74
+ cache.mergeSlice({ 0: "a", 1: "b" });
75
+ const fetchLogs = vi.fn(() => ({}));
76
+ const fetcher = start(cache, fetchLogs);
77
+ fetcher.enqueue(0, 1);
78
+ await vi.runAllTimersAsync();
79
+ expect(fetchLogs).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("never asks for lines rotated away", async () => {
83
+ const cache = emptyCache(CHUNK * 2);
84
+ cache.applyEvent({
85
+ lines: {},
86
+ sizeLines: CHUNK * 2,
87
+ finished: false,
88
+ firstAvailableLine: CHUNK + 5,
89
+ });
90
+ const fetchLogs = vi.fn(() => ({}));
91
+ const fetcher = start(cache, fetchLogs);
92
+ fetcher.enqueue(0, CHUNK * 2 - 1);
93
+ await vi.runAllTimersAsync();
94
+ expect(fetchLogs.mock.calls).toEqual([[CHUNK, CHUNK * 2 - 1]]);
95
+ });
96
+
97
+ it("keeps at most two chunks in flight", async () => {
98
+ const { fn, pending } = deferredFetch();
99
+ const fetcher = start(emptyCache(CHUNK * 3), fn);
100
+
101
+ fetcher.enqueue(0, 0);
102
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS);
103
+ fetcher.enqueue(CHUNK, CHUNK * 3 - 1);
104
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
105
+ expect(fn).toHaveBeenCalledTimes(2);
106
+ expect(fetcher.isFetching).toBe(true);
107
+
108
+ pending[0]?.settle({});
109
+ await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
110
+ expect(fn).toHaveBeenCalledTimes(3);
111
+ });
112
+
113
+ it("drops queued chunks the viewport has left behind", async () => {
114
+ const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
115
+ const fetcher = start(emptyCache(CHUNK * 10), fetchLogs);
116
+ fetcher.enqueue(0, CHUNK * 10 - 1);
117
+ fetcher.pruneQueue(CHUNK * 9, CHUNK * 10 - 1);
118
+ await vi.runAllTimersAsync();
119
+ expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([7, 8, 9]);
120
+ });
121
+
122
+ it("recovers from a failed fetch so the chunk can be asked for again", async () => {
123
+ const fetchLogs = vi
124
+ .fn()
125
+ .mockRejectedValueOnce(new Error("503"))
126
+ .mockResolvedValue({ 0: "a" });
127
+ const cache = emptyCache(1);
128
+ const fetcher = start(cache, fetchLogs);
129
+ fetcher.enqueue(0, 0);
130
+ await vi.runAllTimersAsync();
131
+ expect(fetcher.isFetching).toBe(false);
132
+ fetcher.enqueue(0, 0);
133
+ await vi.runAllTimersAsync();
134
+ expect(cache.lines.get(0)).toBe("a");
135
+ });
136
+
137
+ it("requests only the incomplete chunks in view, up to the cap", async () => {
138
+ const cache = emptyCache(CHUNK * 4);
139
+ const filled: Record<number, string> = {};
140
+ for (let n = CHUNK; n < CHUNK * 2; n++) filled[n] = "x";
141
+ cache.mergeSlice(filled);
142
+ const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
143
+ const fetcher = start(cache, fetchLogs);
144
+
145
+ fetcher.maybeRequestMissing(0, CHUNK * 4 - 1, 3);
146
+ await vi.runAllTimersAsync();
147
+ expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([0, 2]);
148
+ });
149
+
150
+ it("does nothing without a fetch function or after destroy", async () => {
151
+ const fetchLogs = vi.fn(() => ({}));
152
+ new LogFetcher(emptyCache(5), undefined).enqueue(0, 4);
153
+ const fetcher = start(emptyCache(5), fetchLogs);
154
+ fetcher.enqueue(0, 4);
155
+ fetcher.destroy();
156
+ await vi.runAllTimersAsync();
157
+ expect(fetchLogs).not.toHaveBeenCalled();
158
+ });
159
+ });
@@ -103,6 +103,24 @@ describe("formatRelativeTimeWithAbsolute", () => {
103
103
  expect(r).toMatch(/\([^)]*\d[^)]*\)$/);
104
104
  expect(r).not.toMatch(/\(\d{1,2}:\d{2}\)$/);
105
105
  });
106
+ it("adds the year when the date falls in another year", () => {
107
+ const now = new Date(2026, 9, 20, 12);
108
+ expect(formatRelativeTimeWithAbsolute(new Date(2028, 1, 29), now)).toMatch(/2028\)$/);
109
+ expect(formatRelativeTimeWithAbsolute(new Date(2026, 11, 1), now)).not.toMatch(/2026/);
110
+ });
111
+ it("shows the clock time in the given zone, not the browser's", () => {
112
+ // Kathmandu is UTC+05:45, so no browser zone renders 02:30Z as 08:15.
113
+ const now = new Date("2026-10-21T00:00:00Z");
114
+ expect(
115
+ formatRelativeTimeWithAbsolute("2026-10-21T02:30:00Z", now, "Asia/Kathmandu"),
116
+ ).toMatch(/\(0?8:15\)$/);
117
+ });
118
+ it("falls back to the browser zone for a zone Intl doesn't know", () => {
119
+ const now = new Date("2026-10-21T00:00:00Z");
120
+ expect(formatRelativeTimeWithAbsolute("2026-10-21T02:30:00Z", now, "Mars/Olympus")).toBe(
121
+ formatRelativeTimeWithAbsolute("2026-10-21T02:30:00Z", now),
122
+ );
123
+ });
106
124
  it("computes the relative part against the supplied now", () => {
107
125
  const date = new Date("2024-01-01T12:00:00Z");
108
126
  const twoHoursBefore = formatRelativeTimeWithAbsolute(
@@ -63,12 +63,14 @@ function dateFormatter(options: Intl.DateTimeFormatOptions): (date: string | Dat
63
63
  return (date) => format.format(new Date(date));
64
64
  }
65
65
 
66
+ /** Date and time, 24-hour like every other clock in the UI, e.g. "Oct 20, 2026, 21:05". */
66
67
  export const formatDateTime = dateFormatter({
67
68
  year: "numeric",
68
69
  month: "short",
69
70
  day: "numeric",
70
- hour: "numeric",
71
+ hour: "2-digit",
71
72
  minute: "2-digit",
73
+ hour12: false,
72
74
  });
73
75
 
74
76
  /** Wall-clock time of day, seconds included, 24-hour, e.g. "17:15:02". */
@@ -102,16 +104,38 @@ export const formatFullDateTime = dateFormatter({
102
104
  hour12: false,
103
105
  });
104
106
 
105
- const formatShortTime = dateFormatter({ hour: "numeric", minute: "2-digit", hour12: false });
107
+ /** Formats date with options in timeZone; the browser's zone when unset or unknown to Intl. */
108
+ function formatInZone(date: Date, options: Intl.DateTimeFormatOptions, timeZone?: string): string {
109
+ if (timeZone) {
110
+ try {
111
+ return new Intl.DateTimeFormat(undefined, { ...options, timeZone }).format(date);
112
+ } catch {
113
+ // A zone the daemon knows but this browser's Intl doesn't.
114
+ }
115
+ }
116
+ return new Intl.DateTimeFormat(undefined, options).format(date);
117
+ }
106
118
 
119
+ /**
120
+ * "in 7 hours (02:30)". Pass timeZone for a scheduled time, so the clock time
121
+ * agrees with the cron description ("At 02:30") written in the schedule's zone.
122
+ */
107
123
  export function formatRelativeTimeWithAbsolute(
108
124
  dateStr: string | Date,
109
125
  now: Date = new Date(),
126
+ timeZone?: string,
110
127
  ): string {
111
128
  const date = new Date(dateStr);
112
129
  const diffDays = Math.abs(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24);
113
- const absolute = diffDays < 1 ? formatShortTime(date) : formatDayMonth(date);
114
- return `${formatRelativeTime(date, now)} (${absolute})`;
130
+ const year = (d: Date): string => formatInZone(d, { year: "numeric" }, timeZone);
131
+ // The year only when it differs, so "in 1 year (Feb 29)" can't hide which Feb 29.
132
+ const options: Intl.DateTimeFormatOptions =
133
+ diffDays < 1
134
+ ? { hour: "numeric", minute: "2-digit", hour12: false }
135
+ : year(date) === year(now)
136
+ ? { month: "short", day: "numeric" }
137
+ : { year: "numeric", month: "short", day: "numeric" };
138
+ return `${formatRelativeTime(date, now)} (${formatInZone(date, options, timeZone)})`;
115
139
  }
116
140
 
117
141
  export function formatBytes(bytes: number): string {
@@ -0,0 +1,46 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { toast } from "./toast.svelte";
6
+
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ for (const t of toast.items) toast.remove(t.id);
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ describe("toast", () => {
17
+ it("expires after its duration", () => {
18
+ toast.success("Saved", { duration: 1000 });
19
+ expect(toast.items.map((t) => [t.type, t.message])).toEqual([["success", "Saved"]]);
20
+ vi.advanceTimersByTime(999);
21
+ expect(toast.items).toHaveLength(1);
22
+ vi.advanceTimersByTime(1);
23
+ expect(toast.items).toHaveLength(0);
24
+ });
25
+
26
+ it("folds a repeat into the shown toast and restarts its clock", () => {
27
+ const first = toast.error("Run failed", { duration: 1000 });
28
+ vi.advanceTimersByTime(800);
29
+ expect(toast.error("Run failed", { duration: 1000 })).toBe(first);
30
+ vi.advanceTimersByTime(800);
31
+ expect(toast.items).toHaveLength(1);
32
+ vi.advanceTimersByTime(200);
33
+ expect(toast.items).toHaveLength(0);
34
+ });
35
+
36
+ it("keeps a zero-duration toast until it is removed", () => {
37
+ const onClick = vi.fn();
38
+ const id = toast.info("Deleted run", { duration: 0, action: { label: "Undo", onClick } });
39
+ toast.warning("Disk almost full");
40
+ vi.advanceTimersByTime(60_000);
41
+ expect(toast.items.map((t) => t.id)).toEqual([id]);
42
+ expect(toast.items[0]?.action?.label).toBe("Undo");
43
+ toast.remove(id);
44
+ expect(toast.items).toHaveLength(0);
45
+ });
46
+ });