@runwisp/ui 1.4.0 → 1.5.1

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 (87) hide show
  1. package/package.json +4 -5
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/focusTrap.ts +3 -3
  4. package/src/lib/actions/portal.ts +5 -1
  5. package/src/lib/components/Accordion.svelte +6 -2
  6. package/src/lib/components/AccordionItem.svelte +80 -23
  7. package/src/lib/components/Avatar.svelte +0 -1
  8. package/src/lib/components/Badge.svelte +1 -1
  9. package/src/lib/components/CodeBlock.svelte +84 -35
  10. package/src/lib/components/CronInput.svelte +5 -2
  11. package/src/lib/components/DataGrid.svelte +100 -138
  12. package/src/lib/components/Drawer.svelte +2 -6
  13. package/src/lib/components/Dropdown.svelte +19 -57
  14. package/src/lib/components/EmptyState.svelte +2 -1
  15. package/src/lib/components/ErrorState.svelte +2 -1
  16. package/src/lib/components/FilterBar.svelte +1 -1
  17. package/src/lib/components/FilterPill.svelte +2 -2
  18. package/src/lib/components/FormField.svelte +1 -1
  19. package/src/lib/components/Heading.svelte +19 -12
  20. package/src/lib/components/Kbd.svelte +25 -10
  21. package/src/lib/components/Link.svelte +7 -1
  22. package/src/lib/components/LogConsole.svelte +40 -42
  23. package/src/lib/components/Modal.svelte +44 -33
  24. package/src/lib/components/NotificationBell.svelte +2 -1
  25. package/src/lib/components/PageContainer.svelte +1 -10
  26. package/src/lib/components/PageHeader.svelte +8 -4
  27. package/src/lib/components/Popover.svelte +9 -51
  28. package/src/lib/components/Progress.svelte +19 -6
  29. package/src/lib/components/Radio.svelte +16 -4
  30. package/src/lib/components/RadioGroup.svelte +15 -2
  31. package/src/lib/components/SearchInput.svelte +1 -1
  32. package/src/lib/components/Select.svelte +16 -47
  33. package/src/lib/components/Sparkline.svelte +2 -2
  34. package/src/lib/components/StatusDot.svelte +1 -1
  35. package/src/lib/components/Table.svelte +13 -4
  36. package/src/lib/components/TableBody.svelte +2 -1
  37. package/src/lib/components/TableCell.svelte +38 -8
  38. package/src/lib/components/TableHead.svelte +12 -4
  39. package/src/lib/components/TableRow.svelte +23 -3
  40. package/src/lib/components/TaskCard.svelte +1 -1
  41. package/src/lib/components/TimezoneSelect.svelte +2 -2
  42. package/src/lib/components/Tooltip.svelte +1 -1
  43. package/src/lib/components/accordion.svelte.ts +19 -0
  44. package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
  45. package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
  46. package/src/lib/components/dashboard/RunsList.svelte +17 -26
  47. package/src/lib/components/dashboard/cron-format.test.ts +1 -1
  48. package/src/lib/components/dashboard/cron-format.ts +3 -3
  49. package/src/lib/components/dashboard/run-filters.test.ts +4 -60
  50. package/src/lib/components/dashboard/run-filters.ts +29 -144
  51. package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
  52. package/src/lib/components/dashboard/run-helpers.ts +40 -39
  53. package/src/lib/components/dashboard/status-config.ts +38 -83
  54. package/src/lib/components/dashboard/types.ts +0 -26
  55. package/src/lib/components/data-grid-selection.ts +2 -9
  56. package/src/lib/components/filter-spec.ts +3 -3
  57. package/src/lib/components/radio-group.ts +11 -0
  58. package/src/lib/index.ts +8 -43
  59. package/src/lib/log-console/LogCache.svelte.ts +1 -1
  60. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  61. package/src/lib/log-console/ansi.ts +1 -1
  62. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  63. package/src/lib/log-console/highlight-scroll.ts +41 -0
  64. package/src/lib/theme-tokens.css +21 -21
  65. package/src/lib/theme.css +22 -16
  66. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  67. package/src/lib/utils/clipboard.svelte.ts +73 -0
  68. package/src/lib/utils/format.test.ts +3 -3
  69. package/src/lib/utils/format.ts +65 -94
  70. package/src/lib/utils/id.test.ts +3 -7
  71. package/src/lib/utils/id.ts +3 -2
  72. package/src/lib/utils/theme.svelte.test.ts +0 -6
  73. package/src/lib/utils/theme.svelte.ts +3 -5
  74. package/src/lib/utils/toast.svelte.ts +5 -14
  75. package/src/stories/Accordion.stories.svelte +61 -0
  76. package/src/stories/CodeBlock.stories.svelte +55 -0
  77. package/src/stories/DataGrid.stories.svelte +0 -5
  78. package/src/stories/Heading.stories.svelte +31 -0
  79. package/src/stories/Kbd.stories.svelte +36 -0
  80. package/src/stories/Link.stories.svelte +38 -0
  81. package/src/stories/LogConsole.stories.svelte +2 -2
  82. package/src/stories/PageHeader.stories.svelte +31 -0
  83. package/src/stories/Progress.stories.svelte +26 -0
  84. package/src/stories/RadioGroup.stories.svelte +43 -0
  85. package/src/stories/Table.stories.svelte +69 -0
  86. package/src/svelte-shims.d.ts +0 -5
  87. package/src/lib/components/dashboard/status-config.test.ts +0 -18
@@ -19,9 +19,8 @@
19
19
  import type { RunMotion } from "../../utils/run-motion.js";
20
20
  import EmptyState from "../EmptyState.svelte";
21
21
  import RunFilterPopover from "./RunFilterPopover.svelte";
22
- import type { Run } from "./types.js";
23
- import type { RunSelector, RunStatus } from "@runwisp/common";
24
- import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
22
+ import { displayStatus, type Run, type RunSelector, type RunStatus } from "@runwisp/common";
23
+ import { RUN_STATUS_CONFIG } from "./status-config.js";
25
24
  import {
26
25
  activeDimensions,
27
26
  clearDimension,
@@ -71,7 +70,6 @@
71
70
  onBulkRerun,
72
71
  getInstanceCount = () => 1,
73
72
  motion,
74
- flush = false,
75
73
  outputSearch = false,
76
74
  outputQuery = "",
77
75
  outputMatches = null,
@@ -105,11 +103,6 @@
105
103
  // Which runs arrived or were removed live moments ago: their rows animate
106
104
  // in or out. Page loads, scrolling and filtering never do.
107
105
  motion?: RunMotion;
108
- // Flush rail mode (task detail page): render as a borderless rail that
109
- // fills its column and is divided from the detail panel by a single right
110
- // border — no card chrome of its own. Default renders the standalone card
111
- // the cross-task /runs grid expects.
112
- flush?: boolean;
113
106
  // Output search (history rail): filters runs by what they printed. The
114
107
  // search box lives in the app header now; the parent owns the box, the
115
108
  // query, and the async log search. This component just renders the
@@ -121,7 +114,7 @@
121
114
  // null = no active search; the full list shows.
122
115
  outputMatches?: Map<string, RunOutputMatch> | null;
123
116
  // True while a query is typed but its results aren't in yet (debounce
124
- // window or request in flight) — the rail shows its searching shimmer.
117
+ // window or request in flight), the rail shows its searching shimmer.
125
118
  outputSearchPending?: boolean;
126
119
  } = $props();
127
120
 
@@ -131,7 +124,7 @@
131
124
  const OVERSCAN = 8;
132
125
  const LOAD_AHEAD = 10;
133
126
 
134
- // Selection model: two modes —
127
+ // Selection model: two modes,
135
128
  // 1. explicit: user picked specific rows. explicitIds holds them.
136
129
  // 2. selectAll: "all matching the current filter". exceptIds holds opt-outs.
137
130
  let selectAllMode = $state(false);
@@ -146,13 +139,13 @@
146
139
 
147
140
  // Loaded runs that matched the output search, in list order. A match in a
148
141
  // not-yet-loaded run can't be shown until the rail scrolls far enough to
149
- // load it — the count reflects what's loaded, not the whole history.
142
+ // load it, the count reflects what's loaded, not the whole history.
150
143
  const matchedRuns = $derived(
151
144
  outputMatches ? items.filter((r: Run) => outputMatches.has(r.id)) : [],
152
145
  );
153
146
 
154
147
  // Split the matching line into [before, match, after] around the first
155
- // occurrence of the query, windowed to keep the match in view — mirrors the
148
+ // occurrence of the query, windowed to keep the match in view, mirrors the
156
149
  // artifact's snippet. Rendered as plain text spans (Svelte auto-escapes), so
157
150
  // no untrusted HTML ever reaches the DOM.
158
151
  interface HighlightParts {
@@ -392,17 +385,15 @@
392
385
  });
393
386
  </script>
394
387
 
388
+ <!-- A borderless rail that fills its column, divided from the detail panel by
389
+ a single right border. -->
395
390
  <div
396
- class={flush
397
- ? "flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
398
- : "flex flex-col overflow-hidden rounded-[4px] border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
391
+ class="flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
399
392
  >
400
393
  <div
401
394
  class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
402
395
  ? 'border-outline-faint bg-primary-soft/40'
403
- : flush
404
- ? 'border-transparent bg-surface'
405
- : 'border-outline-faint bg-surface-sunken'}"
396
+ : 'border-transparent bg-surface'}"
406
397
  >
407
398
  {#if bulkActions}
408
399
  <label
@@ -483,7 +474,7 @@
483
474
  </span>
484
475
  <div class="ml-auto flex items-center gap-1">
485
476
  {#if showFilters}
486
- <RunFilterPopover bind:filters {showTask} {tasks} compact={flush} />
477
+ <RunFilterPopover bind:filters {showTask} {tasks} />
487
478
  {/if}
488
479
  <Button
489
480
  variant="ghost"
@@ -609,8 +600,8 @@
609
600
  </div>
610
601
  </div>
611
602
 
612
- <!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
613
- selection exists — so the row checkbox can take its place over it. -->
603
+ <!-- Status dot. With bulk actions on it fades out, on row hover, or whenever a
604
+ selection exists, so the row checkbox can take its place over it. -->
614
605
  <!-- Placeholders shaped like this list's run rows (same padding, dot, text
615
606
  and right readout), while a search or the first page is in flight. -->
616
607
  {#snippet skeletonRows(label: string)}
@@ -688,8 +679,8 @@
688
679
  {/snippet}
689
680
 
690
681
  {#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
691
- {@const dstatus = runDisplayStatus(run)}
692
- {@const config = getRunStatusConfig(dstatus)}
682
+ {@const dstatus = displayStatus(run.status, run.endReason)}
683
+ {@const config = RUN_STATUS_CONFIG[dstatus]}
693
684
  {@const running = run.status === "running"}
694
685
  {@const spine = config.dot.replace(" animate-pulse", "")}
695
686
  {@const startedAt = run.startedAt ?? run.createdAt}
@@ -706,7 +697,7 @@
706
697
  >
707
698
  {#if showTaskName}
708
699
  <!-- Cross-task /runs variant: the same readout language as the task
709
- rail — status dot, status-colored outcome, mono right readout —
700
+ rail, status dot, status-colored outcome, mono right readout,
710
701
  with the task name carried as the primary. -->
711
702
  <div class="flex items-center gap-2.5">
712
703
  {@render statusDot(config.color, running)}
@@ -744,7 +735,7 @@
744
735
  </span>
745
736
  </div>
746
737
  {:else}
747
- <!-- Task-rail variant (artifact ".run"): a single dense line —
738
+ <!-- Task-rail variant (artifact ".run"): a single dense line,
748
739
  time · date · outcome, with a mono exit/duration readout.
749
740
  leading-tight matches the artifact's ~1.2 line-height so the
750
741
  (descender-less) text optically centers instead of riding high
@@ -52,7 +52,7 @@ describe("humanizeCron", () => {
52
52
  expect(result.isHumanized).toBe(false);
53
53
  });
54
54
 
55
- it("falls back to raw for unparseable expressions — never 'Invalid'", () => {
55
+ it("falls back to raw for unparseable expressions, never 'Invalid'", () => {
56
56
  const result = humanizeCron("not a cron");
57
57
  expect(result.humanized).toBe("not a cron");
58
58
  expect(result.isHumanized).toBe(false);
@@ -2,8 +2,8 @@
2
2
 
3
3
  import cronstrue from "cronstrue";
4
4
 
5
- export interface HumanizedCron {
6
- /** Display text — humanized when possible, otherwise the raw expression. */
5
+ interface HumanizedCron {
6
+ /** Display text, humanized when possible, otherwise the raw expression. */
7
7
  humanized: string;
8
8
  /** The original cron expression, for tooltips. */
9
9
  raw: string;
@@ -51,7 +51,7 @@ function humanizeEvery(duration: string): string | null {
51
51
  /**
52
52
  * humanizeCron renders a cron expression as plain English ("Every 5 minutes").
53
53
  * Handles robfig's `@every <duration>` extension (cronstrue throws on it) and
54
- * falls back to the raw expression on anything unparseable — never "Invalid".
54
+ * falls back to the raw expression on anything unparseable, never "Invalid".
55
55
  */
56
56
  export function humanizeCron(cron: string): HumanizedCron {
57
57
  const raw = cron.trim();
@@ -6,13 +6,10 @@ import { END_REASONS } from "@runwisp/common";
6
6
  import {
7
7
  emptyRunFilters,
8
8
  FAILURE_STATUS_TOKEN,
9
- NEEDS_ATTENTION_STATUSES,
10
- isNeedsAttention,
11
9
  dimensionActive,
12
10
  activeDimensions,
13
11
  activeFilterCount,
14
12
  clearDimension,
15
- clearPopoverFilters,
16
13
  humanizeStatus,
17
14
  triggerDescription,
18
15
  FILTERABLE_TRIGGERS,
@@ -29,7 +26,6 @@ import {
29
26
  exitCodeRangeActive,
30
27
  isExitCodeExprValid,
31
28
  exitCodeChipLabel,
32
- exitCodePropChanged,
33
29
  type StatusBucket,
34
30
  type RunsListFilters,
35
31
  } from "./run-filters.js";
@@ -46,7 +42,7 @@ const base = (overrides: Partial<RunsListFilters> = {}): RunsListFilters => ({
46
42
  });
47
43
 
48
44
  describe("emptyRunFilters", () => {
49
- it("is a fully-open filter — no dimension active, newest-first", () => {
45
+ it("is a fully-open filter, no dimension active, newest-first", () => {
50
46
  const f = emptyRunFilters();
51
47
  expect(f.statuses).toEqual([]);
52
48
  expect(f.sortDirection).toBe("desc");
@@ -54,23 +50,6 @@ describe("emptyRunFilters", () => {
54
50
  });
55
51
  });
56
52
 
57
- describe("isNeedsAttention", () => {
58
- it("is true for exactly the attention set, order-insensitive", () => {
59
- expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES])).toBe(true);
60
- expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES].reverse())).toBe(true);
61
- });
62
-
63
- it("is exactly the failure sentinel (server resolves it to is_failure)", () => {
64
- expect(NEEDS_ATTENTION_STATUSES).toEqual([FAILURE_STATUS_TOKEN]);
65
- });
66
-
67
- it("is false for a raw end reason or a different set", () => {
68
- expect(isNeedsAttention(["failed"])).toBe(false);
69
- expect(isNeedsAttention(["succeeded"])).toBe(false);
70
- expect(isNeedsAttention([])).toBe(false);
71
- });
72
- });
73
-
74
53
  describe("dimensionActive / activeDimensions", () => {
75
54
  it("detects each dimension independently", () => {
76
55
  expect(dimensionActive(base({ statuses: ["failed"] }), "status")).toBe(true);
@@ -125,25 +104,6 @@ describe("clearDimension", () => {
125
104
  });
126
105
  });
127
106
 
128
- describe("clearPopoverFilters", () => {
129
- it("clears every popover dimension but keeps search and sort", () => {
130
- const f = base({
131
- search: "nightly",
132
- sortDirection: "asc",
133
- statuses: ["failed"],
134
- taskName: "backup",
135
- triggeredBy: "cron",
136
- exitCode: "1",
137
- retriesOnly: true,
138
- createdAfter: "2026-01-01T00:00:00Z",
139
- });
140
- const cleared = clearPopoverFilters(f);
141
- expect(activeFilterCount(cleared)).toBe(0);
142
- expect(cleared.search).toBe("nightly");
143
- expect(cleared.sortDirection).toBe("asc");
144
- });
145
- });
146
-
147
107
  describe("humanizeStatus", () => {
148
108
  it("title-cases and de-snakes a status token", () => {
149
109
  expect(humanizeStatus("log_overflow")).toBe("Log overflow");
@@ -154,8 +114,8 @@ describe("humanizeStatus", () => {
154
114
 
155
115
  describe("statusChipLabel", () => {
156
116
  it("names a bucket when the selection matches one exactly", () => {
157
- // The Failed bucket is the needs-attention set.
158
- expect(statusChipLabel([...NEEDS_ATTENTION_STATUSES])).toBe("Failed");
117
+ // The Failed bucket is the failure sentinel.
118
+ expect(statusChipLabel([FAILURE_STATUS_TOKEN])).toBe("Failed");
159
119
  expect(statusChipLabel(["pending", "running"])).toBe("Running");
160
120
  expect(statusChipLabel(["succeeded"])).toBe("Succeeded");
161
121
  });
@@ -172,7 +132,7 @@ describe("statusChipLabel", () => {
172
132
  describe("STATUS_BUCKETS", () => {
173
133
  // The Failed bucket is the failure sentinel (resolved server-side to the
174
134
  // per-task is_failure classification), so the execution-failure reasons are
175
- // not bucketed — they stay reachable via the popover's Advanced expander.
135
+ // not bucketed, they stay reachable via the popover's Advanced expander.
176
136
  const FAILURE_REASONS = [
177
137
  "failed",
178
138
  "crashed",
@@ -332,19 +292,3 @@ describe("exit-code expression", () => {
332
292
  expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
333
293
  });
334
294
  });
335
-
336
- describe("exitCodePropChanged", () => {
337
- it("is false when the committed value is unchanged (unrelated filters update)", () => {
338
- expect(exitCodePropChanged("137", "137")).toBe(false);
339
- expect(exitCodePropChanged(undefined, undefined)).toBe(false);
340
- });
341
-
342
- it("is true when the exit-code filter was cleared externally (chip removed)", () => {
343
- expect(exitCodePropChanged("137", undefined)).toBe(true);
344
- });
345
-
346
- it("is true when the exit-code filter was set to a new committed value", () => {
347
- expect(exitCodePropChanged(undefined, "200")).toBe(true);
348
- expect(exitCodePropChanged("137", "200")).toBe(true);
349
- });
350
- });
@@ -2,86 +2,51 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { TRIGGERS, type Trigger } from "@runwisp/common";
5
-
6
- export type RunsListSortDirection = "asc" | "desc" | "";
5
+ import { TRIGGER_LABELS } from "./run-helpers.js";
7
6
 
8
7
  /**
9
- * The single filter shape shared by the runs list, the filter popover, the
10
- * SSE-merge source, and the bulk selector. Every field beyond `search` and
11
- * `sortDirection` is an optional dimension applied server-side; the zero
12
- * value (`emptyRunFilters`) matches every run.
13
- *
14
- * `statuses` is a multi-select set joined to a comma-separated string only at
15
- * the two wire boundaries (the list query and the bulk selector). Time bounds
16
- * are absolute RFC3339 instants — presets resolve to `now − delta` at apply
17
- * time so a live SSE row created after the boundary keeps matching.
8
+ * The filter shared by the runs list, the filter popover, the SSE-merge source
9
+ * and the bulk selector. Time bounds are absolute RFC3339 instants, so a live
10
+ * SSE row created after a preset was applied keeps matching.
18
11
  */
19
12
  export interface RunsListFilters {
20
13
  search: string;
21
14
  statuses: string[];
22
- sortDirection: RunsListSortDirection;
15
+ sortDirection: "asc" | "desc" | "";
23
16
  // Optional dimensions explicitly admit `undefined` so a dimension can be
24
17
  // cleared by reassignment (`{ ...f, x: undefined }`) under the project's
25
- // exactOptionalPropertyTypes — a fresh object reference is what re-triggers
18
+ // exactOptionalPropertyTypes, a fresh object reference is what re-triggers
26
19
  // the parent's fetch effect through several levels of `bind:`.
27
20
  taskName?: string | undefined;
28
21
  createdAfter?: string | undefined;
29
22
  createdBefore?: string | undefined;
30
23
  triggeredBy?: string | undefined;
31
- // A human exit-code expression — a bare code (`137`), a comparison (`>100`,
32
- // `<=150`), or a space-separated combination (`>100 <150`). Normalized to an
33
- // inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
34
- // server only ever sees `exitCodeMin` / `exitCodeMax`.
24
+ // An exit-code expression such as `137`, `>100` or `>100 <150`; see
25
+ // parseExitCodeRange.
35
26
  exitCode?: string | undefined;
36
27
  retriesOnly?: boolean | undefined;
37
28
  }
38
29
 
39
- /** A fresh default filter — no dimensions active, newest-first. */
30
+ /** A fresh default filter, no dimensions active, newest-first. */
40
31
  export function emptyRunFilters(): RunsListFilters {
41
32
  return { search: "", statuses: [], sortDirection: "desc" };
42
33
  }
43
34
 
44
35
  /**
45
- * Reserved `status` token the "Failed" browse filter sends instead of a fixed
46
- * end-reason list. The server (splitFailureToken in api_types.go) strips it and
47
- * matches on each run's persisted `isFailure` bit, so the browse filter agrees
48
- * with the attention badges, the failed stat, and notifications — all of which
49
- * follow the per-task `failures` policy. It is not a real status/end-reason, so
50
- * it OR-combines with the other buckets. Must match `failureStatusToken` in the
51
- * daemon.
36
+ * Reserved `status` token for the "Failed" filter. The daemon matches it on each
37
+ * run's `isFailure` bit (per-task `failures` policy) instead of a fixed
38
+ * end-reason list. Must match `failureStatusToken` in the daemon.
52
39
  */
53
40
  export const FAILURE_STATUS_TOKEN = "failure";
54
41
 
55
42
  /**
56
- * The status set backing the "Failed" browse filter and the one-click preset
57
- * that serves Prime Directive #1 — nothing silently fails. Just the failure
58
- * sentinel: the server resolves it to the per-task `isFailure` classification
59
- * rather than a fixed end-reason list.
60
- */
61
- export const NEEDS_ATTENTION_STATUSES: readonly string[] = [FAILURE_STATUS_TOKEN];
62
-
63
- /** True when `statuses` is exactly the needs-attention set (order-insensitive). */
64
- export function isNeedsAttention(statuses: string[]): boolean {
65
- if (statuses.length !== NEEDS_ATTENTION_STATUSES.length) return false;
66
- const set = new Set(statuses);
67
- return NEEDS_ATTENTION_STATUSES.every((s) => set.has(s));
68
- }
69
-
70
- /**
71
- * Outcome buckets: the 14 individual run statuses collapsed into five
72
- * plain-language groups, so the common case is a five-item pick instead of a
73
- * flat checklist. Each bucket is purely a UI grouping over `statuses` — toggling
74
- * one adds/removes its members, and the popover's "Advanced" section still
75
- * exposes the individual statuses for surgical filters (e.g. only `timeout`).
76
- *
77
- * Every status appears in exactly one bucket; `Failed` is the needs-attention
78
- * set (Prime Directive #1). `dot` mirrors that group's color in
79
- * RUN_STATUS_CONFIG so the bucket reads at a glance.
43
+ * Outcome buckets: a UI grouping of the individual run statuses into five
44
+ * plain-language picks. The popover's "Advanced" section still exposes the
45
+ * individual statuses. `dot` mirrors the group's color in RUN_STATUS_CONFIG.
80
46
  */
81
47
  export interface StatusBucket {
82
48
  key: string;
83
49
  label: string;
84
- /** Tailwind background class for the bucket's status dot. */
85
50
  dot: string;
86
51
  statuses: readonly string[];
87
52
  }
@@ -93,7 +58,7 @@ export const STATUS_BUCKETS: readonly StatusBucket[] = [
93
58
  key: "failed",
94
59
  label: "Failed",
95
60
  dot: "bg-danger-surface",
96
- statuses: NEEDS_ATTENTION_STATUSES,
61
+ statuses: [FAILURE_STATUS_TOKEN],
97
62
  },
98
63
  {
99
64
  key: "skipped",
@@ -111,7 +76,6 @@ export const STATUS_BUCKETS: readonly StatusBucket[] = [
111
76
 
112
77
  export type BucketState = "on" | "partial" | "off";
113
78
 
114
- /** Whether none, some, or all of a bucket's statuses are currently selected. */
115
79
  export function bucketState(statuses: string[], bucket: StatusBucket): BucketState {
116
80
  const set = new Set(statuses);
117
81
  const present = bucket.statuses.filter((s) => set.has(s)).length;
@@ -138,7 +102,7 @@ function exactBucket(statuses: string[]): StatusBucket | undefined {
138
102
 
139
103
  /**
140
104
  * The popover-managed dimensions, in display (most→least useful) order.
141
- * `task` only ever applies on the cross-task /runs view — on a single task's
105
+ * `task` only ever applies on the cross-task /runs view, on a single task's
142
106
  * page the task name is the page scope (injected at fetch time), never a
143
107
  * popover-set filter, so it stays absent from those filters and is not counted.
144
108
  */
@@ -153,7 +117,6 @@ const DIMENSION_ORDER: FilterDimension[] = [
153
117
  "retries",
154
118
  ];
155
119
 
156
- /** Whether a single dimension is currently constraining the result set. */
157
120
  export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boolean {
158
121
  switch (dim) {
159
122
  case "status":
@@ -171,17 +134,16 @@ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boole
171
134
  }
172
135
  }
173
136
 
174
- /** The active dimensions, in display order — drives the chip row. */
137
+ /** The active dimensions, in display order, drives the chip row. */
175
138
  export function activeDimensions(f: RunsListFilters): FilterDimension[] {
176
139
  return DIMENSION_ORDER.filter((dim) => dimensionActive(f, dim));
177
140
  }
178
141
 
179
- /** How many dimensions are active — the count badge on the Filter button. */
142
+ /** How many dimensions are active, the count badge on the Filter button. */
180
143
  export function activeFilterCount(f: RunsListFilters): number {
181
144
  return activeDimensions(f).length;
182
145
  }
183
146
 
184
- /** Reset one dimension, leaving the others (and search/sort) untouched. */
185
147
  export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsListFilters {
186
148
  switch (dim) {
187
149
  case "status":
@@ -199,23 +161,6 @@ export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsLi
199
161
  }
200
162
  }
201
163
 
202
- /**
203
- * Clear every popover dimension at once, preserving the header search and the
204
- * sort direction (those are separate toolbar controls, not popover state).
205
- */
206
- export function clearPopoverFilters(f: RunsListFilters): RunsListFilters {
207
- return {
208
- ...f,
209
- statuses: [],
210
- createdAfter: undefined,
211
- createdBefore: undefined,
212
- taskName: undefined,
213
- triggeredBy: undefined,
214
- exitCode: undefined,
215
- retriesOnly: undefined,
216
- };
217
- }
218
-
219
164
  /** Human label for a status value, e.g. `log_overflow` → "Log overflow". */
220
165
  export function humanizeStatus(status: string): string {
221
166
  if (!status) return status;
@@ -223,47 +168,20 @@ export function humanizeStatus(status: string): string {
223
168
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
224
169
  }
225
170
 
226
- /**
227
- * Descriptive label for a run's trigger source — fuller than the one-word row
228
- * badge (`formatTriggeredByLabel`) for the "cron"/"station"/"service"/"startup"
229
- * sources, where the extra words disambiguate (e.g. "REST API" vs a bare
230
- * "API"). "ui", "cli" and "hook" already read fine as their bare badge word,
231
- * so those three match `formatTriggeredByLabel` exactly.
232
- *
233
- * Note `cron` covers both on-time schedule firings and catch-up for missed
234
- * runs, and `startup` is specifically `run_on_start` (not catch-up).
235
- */
171
+ /** Descriptive label for a run's trigger source (see {@link TRIGGER_LABELS}). */
236
172
  export function triggerDescription(trigger: string): string {
237
- switch (trigger) {
238
- case "cron":
239
- return "Scheduled (cron)";
240
- case "api":
241
- return "REST API";
242
- case "ui":
243
- return "UI";
244
- case "cli":
245
- return "CLI";
246
- case "station":
247
- return "Control plane";
248
- case "service":
249
- return "Service auto-start";
250
- case "startup":
251
- return "On daemon start";
252
- case "hook":
253
- return "Hook";
254
- default:
255
- return humanizeStatus(trigger);
256
- }
173
+ const known = TRIGGERS.find((t) => t === trigger);
174
+ return known ? TRIGGER_LABELS[known].long : humanizeStatus(trigger);
257
175
  }
258
176
 
259
177
  /**
260
178
  * The triggers offered in the filter dropdown. `station` is intentionally
261
- * excluded for now — control-plane runs still carry the `station` trigger, but
179
+ * excluded for now, control-plane runs still carry the `station` trigger, but
262
180
  * it isn't a selectable filter dimension.
263
181
  */
264
182
  export const FILTERABLE_TRIGGERS: readonly Trigger[] = TRIGGERS.filter((t) => t !== "station");
265
183
 
266
- /** Chip label for the status dimension — names a whole bucket when it matches. */
184
+ /** Chip label for the status dimension, names a whole bucket when it matches. */
267
185
  export function statusChipLabel(statuses: string[]): string {
268
186
  const bucket = exactBucket(statuses);
269
187
  if (bucket) return bucket.label;
@@ -272,15 +190,6 @@ export function statusChipLabel(statuses: string[]): string {
272
190
  return `${String(statuses.length)} statuses`;
273
191
  }
274
192
 
275
- // --- Time helpers --------------------------------------------------------
276
- //
277
- // A bare calendar day (`YYYY-MM-DD` from a native date input) maps to a local
278
- // instant: the "from" edge is that day's 00:00, the "to" edge is its end
279
- // (23:59:59.999) so picking the same day for both bounds — or the one-click
280
- // "on day" shortcut — captures the whole day and round-trips back to the same
281
- // date in the inputs. Bounds are stored as absolute RFC3339 (UTC) instants, the
282
- // shape the list query and SSE-merge filter already expect.
283
-
284
193
  /** Parse `YYYY-MM-DD` into the local Date for that day, or null if malformed. */
285
194
  function localDay(dateStr: string): Date | null {
286
195
  const parts = dateStr.split("-");
@@ -328,14 +237,6 @@ export function isWholeDay(after?: string, before?: string): boolean {
328
237
  );
329
238
  }
330
239
 
331
- // --- Exit-code expression ------------------------------------------------
332
- //
333
- // The popover takes a free-form exit-code expression and normalizes it to an
334
- // inclusive integer [min, max] range — the shape the server gates on. Because
335
- // exit codes are integers, a strict comparison collapses to an inclusive
336
- // bound: `>100` is min 101, `<150` is max 149. Tokens combine (`>100 <150`),
337
- // and a bare number is an exact match (min = max = n).
338
-
339
240
  export interface ExitCodeRange {
340
241
  min?: number;
341
242
  max?: number;
@@ -344,10 +245,10 @@ export interface ExitCodeRange {
344
245
  const EXIT_CODE_TOKEN = /^(>=|<=|>|<)?(-?\d+)$/;
345
246
 
346
247
  /**
347
- * Parse an exit-code expression into an inclusive [min, max] range. Returns
348
- * `valid: false` if any token is unparseable, so the UI can flag a typo
349
- * instead of silently applying half of it. An empty expression is valid with
350
- * no bounds (no constraint).
248
+ * Parse an exit-code expression into the inclusive integer range the server
249
+ * filters on: `>100` is min 101, `<150` is max 149, a bare number is an exact
250
+ * match, and tokens combine. Any unparseable token makes the whole expression
251
+ * invalid so the UI can flag the typo. An empty expression is valid and unbounded.
351
252
  */
352
253
  export function parseExitCodeRange(expr: string): { range: ExitCodeRange; valid: boolean } {
353
254
  const range: ExitCodeRange = {};
@@ -386,13 +287,12 @@ export function exitCodeRange(expr: string | undefined): ExitCodeRange {
386
287
  return valid ? range : {};
387
288
  }
388
289
 
389
- /** True when the expression resolves to at least one bound (a real filter). */
390
290
  export function exitCodeRangeActive(expr: string | undefined): boolean {
391
291
  const { min, max } = exitCodeRange(expr);
392
292
  return min !== undefined || max !== undefined;
393
293
  }
394
294
 
395
- /** True when the expression is empty or fully parseable — drives input validation. */
295
+ /** True when the expression is empty or fully parseable, drives input validation. */
396
296
  export function isExitCodeExprValid(expr: string): boolean {
397
297
  return parseExitCodeRange(expr).valid;
398
298
  }
@@ -401,18 +301,3 @@ export function isExitCodeExprValid(expr: string): boolean {
401
301
  export function exitCodeChipLabel(expr: string | undefined): string {
402
302
  return `Exit ${(expr ?? "").trim()}`;
403
303
  }
404
-
405
- /**
406
- * Whether the popover's exit-code text buffer must resync to the `filters`
407
- * prop. True only when the committed `exitCode` value actually moved since
408
- * the buffer last matched it — e.g. the "Exit code" chip was removed, or
409
- * "clear filters" ran elsewhere. An unrelated filters update (any other
410
- * dimension) leaves `exitCode` untouched and must not clobber in-progress
411
- * typing in the still-open popover.
412
- */
413
- export function exitCodePropChanged(
414
- previous: string | undefined,
415
- next: string | undefined,
416
- ): boolean {
417
- return previous !== next;
418
- }
@@ -2,23 +2,26 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { RUN_STATUSES } from "@runwisp/common";
6
5
  import {
7
6
  runDuration,
8
7
  runStartDelay,
9
8
  runVerdict,
10
9
  formatTriggeredByLabel,
11
10
  runRetryLabel,
11
+ runUsageLabel,
12
12
  instanceSuffix,
13
+ runLogDownloadUrl,
13
14
  } from "./run-helpers.js";
14
15
 
15
- describe("runVerdict", () => {
16
- it("covers every run status", () => {
17
- for (const status of RUN_STATUSES) {
18
- expect(runVerdict(status).verb, status).toBeTruthy();
19
- }
16
+ describe("runLogDownloadUrl", () => {
17
+ it("points at the daemon's raw log route", () => {
18
+ expect(runLogDownloadUrl("01J0000000000000000000000A")).toBe(
19
+ "/api/runs/01J0000000000000000000000A/log/raw",
20
+ );
20
21
  });
22
+ });
21
23
 
24
+ describe("runVerdict", () => {
22
25
  it("phrases a timed outcome so a duration reads after it", () => {
23
26
  expect(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
24
27
  expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
@@ -116,6 +119,22 @@ describe("formatTriggeredByLabel", () => {
116
119
  });
117
120
  });
118
121
 
122
+ describe("runUsageLabel", () => {
123
+ it("is undefined for an unmeasured run", () => {
124
+ expect(runUsageLabel({})).toBeUndefined();
125
+ });
126
+
127
+ it("joins peak memory and CPU time", () => {
128
+ expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 1500 })).toBe(
129
+ "peak 48 MB · CPU 1s",
130
+ );
131
+ });
132
+
133
+ it("shows whichever was measured", () => {
134
+ expect(runUsageLabel({ cpuTimeMs: 0 })).toBe("CPU 0ms");
135
+ });
136
+ });
137
+
119
138
  describe("runRetryLabel", () => {
120
139
  it("returns undefined for a first attempt that is not a retry", () => {
121
140
  expect(runRetryLabel({ retryAttempt: 0 })).toBeUndefined();