@runwisp/ui 1.5.0 → 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 (70) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/focusTrap.ts +3 -3
  3. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  4. package/src/lib/actions/fold-to-fit.ts +89 -0
  5. package/src/lib/actions/modal-dialog.test.ts +47 -0
  6. package/src/lib/actions/modal-dialog.ts +48 -0
  7. package/src/lib/components/AccordionItem.svelte +16 -5
  8. package/src/lib/components/Badge.svelte +4 -25
  9. package/src/lib/components/Button.svelte +2 -9
  10. package/src/lib/components/CodeBlock.svelte +7 -6
  11. package/src/lib/components/DataGrid.svelte +5 -5
  12. package/src/lib/components/Drawer.svelte +22 -30
  13. package/src/lib/components/DurationInput.svelte +6 -25
  14. package/src/lib/components/FilterBar.svelte +3 -7
  15. package/src/lib/components/FilterPill.svelte +2 -2
  16. package/src/lib/components/FormField.svelte +1 -1
  17. package/src/lib/components/Link.svelte +9 -4
  18. package/src/lib/components/LogConsole.svelte +101 -424
  19. package/src/lib/components/Modal.svelte +7 -30
  20. package/src/lib/components/SearchInput.svelte +1 -1
  21. package/src/lib/components/Select.svelte +3 -2
  22. package/src/lib/components/Sparkline.svelte +2 -2
  23. package/src/lib/components/Spinner.svelte +10 -5
  24. package/src/lib/components/StatusDot.svelte +1 -1
  25. package/src/lib/components/StatusIndicator.svelte +37 -104
  26. package/src/lib/components/Tabs.svelte +15 -1
  27. package/src/lib/components/Tooltip.svelte +1 -1
  28. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  29. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  30. package/src/lib/components/dashboard/RunDetailPanel.svelte +137 -725
  31. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  32. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  33. package/src/lib/components/dashboard/RunFilterPopover.svelte +9 -10
  34. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  35. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  36. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  37. package/src/lib/components/dashboard/RunsList.svelte +267 -545
  38. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  39. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  40. package/src/lib/components/dashboard/cron-format.test.ts +10 -1
  41. package/src/lib/components/dashboard/cron-format.ts +7 -3
  42. package/src/lib/components/dashboard/run-filters.test.ts +62 -2
  43. package/src/lib/components/dashboard/run-filters.ts +104 -99
  44. package/src/lib/components/dashboard/run-helpers.test.ts +131 -12
  45. package/src/lib/components/dashboard/run-helpers.ts +150 -44
  46. package/src/lib/components/dashboard/status-config.ts +18 -0
  47. package/src/lib/components/dashboard/types.ts +6 -0
  48. package/src/lib/components/data-grid-selection.ts +2 -9
  49. package/src/lib/components/filter-spec.ts +3 -3
  50. package/src/lib/index.ts +13 -2
  51. package/src/lib/log-console/AnsiLine.svelte +12 -0
  52. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  53. package/src/lib/log-console/LogCache.svelte.ts +1 -1
  54. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  55. package/src/lib/log-console/ansi.ts +1 -1
  56. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  57. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  58. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  59. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  60. package/src/lib/theme-tokens.css +21 -21
  61. package/src/lib/theme.css +13 -13
  62. package/src/lib/utils/format.test.ts +125 -3
  63. package/src/lib/utils/format.ts +101 -8
  64. package/src/lib/utils/toast.svelte.ts +2 -3
  65. package/src/lib/utils/typing-target.ts +10 -0
  66. package/src/stories/Accordion.stories.svelte +60 -1
  67. package/src/stories/CodeBlock.stories.svelte +13 -0
  68. package/src/stories/Link.stories.svelte +25 -1
  69. package/src/stories/LogConsole.stories.svelte +2 -2
  70. 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");
@@ -52,7 +61,7 @@ describe("humanizeCron", () => {
52
61
  expect(result.isHumanized).toBe(false);
53
62
  });
54
63
 
55
- it("falls back to raw for unparseable expressions — never 'Invalid'", () => {
64
+ it("falls back to raw for unparseable expressions, never 'Invalid'", () => {
56
65
  const result = humanizeCron("not a cron");
57
66
  expect(result.humanized).toBe("not a cron");
58
67
  expect(result.isHumanized).toBe(false);
@@ -3,7 +3,7 @@
3
3
  import cronstrue from "cronstrue";
4
4
 
5
5
  interface HumanizedCron {
6
- /** Display text — humanized when possible, otherwise the raw expression. */
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();
@@ -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";
@@ -42,7 +44,7 @@ const base = (overrides: Partial<RunsListFilters> = {}): RunsListFilters => ({
42
44
  });
43
45
 
44
46
  describe("emptyRunFilters", () => {
45
- it("is a fully-open filter — no dimension active, newest-first", () => {
47
+ it("is a fully-open filter, no dimension active, newest-first", () => {
46
48
  const f = emptyRunFilters();
47
49
  expect(f.statuses).toEqual([]);
48
50
  expect(f.sortDirection).toBe("desc");
@@ -132,7 +134,7 @@ describe("statusChipLabel", () => {
132
134
  describe("STATUS_BUCKETS", () => {
133
135
  // The Failed bucket is the failure sentinel (resolved server-side to the
134
136
  // per-task is_failure classification), so the execution-failure reasons are
135
- // not bucketed — they stay reachable via the popover's Advanced expander.
137
+ // not bucketed, they stay reachable via the popover's Advanced expander.
136
138
  const FAILURE_REASONS = [
137
139
  "failed",
138
140
  "crashed",
@@ -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,17 +2,14 @@
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";
6
+ import { TRIGGER_LABELS } from "./run-helpers.js";
7
+ import { RUN_STATUS_CONFIG } from "./status-config.js";
5
8
 
6
9
  /**
7
- * The single filter shape shared by the runs list, the filter popover, the
8
- * SSE-merge source, and the bulk selector. Every field beyond `search` and
9
- * `sortDirection` is an optional dimension applied server-side; the zero
10
- * value (`emptyRunFilters`) matches every run.
11
- *
12
- * `statuses` is a multi-select set joined to a comma-separated string only at
13
- * the two wire boundaries (the list query and the bulk selector). Time bounds
14
- * are absolute RFC3339 instants — presets resolve to `now − delta` at apply
15
- * time so a live SSE row created after the boundary keeps matching.
10
+ * The filter shared by the runs list, the filter popover, the SSE-merge source
11
+ * and the bulk selector. Time bounds are absolute RFC3339 instants, so a live
12
+ * SSE row created after a preset was applied keeps matching.
16
13
  */
17
14
  export interface RunsListFilters {
18
15
  search: string;
@@ -20,82 +17,77 @@ export interface RunsListFilters {
20
17
  sortDirection: "asc" | "desc" | "";
21
18
  // Optional dimensions explicitly admit `undefined` so a dimension can be
22
19
  // cleared by reassignment (`{ ...f, x: undefined }`) under the project's
23
- // exactOptionalPropertyTypes — a fresh object reference is what re-triggers
20
+ // exactOptionalPropertyTypes, a fresh object reference is what re-triggers
24
21
  // the parent's fetch effect through several levels of `bind:`.
25
22
  taskName?: string | undefined;
26
23
  createdAfter?: string | undefined;
27
24
  createdBefore?: string | undefined;
28
25
  triggeredBy?: string | undefined;
29
- // A human exit-code expression — a bare code (`137`), a comparison (`>100`,
30
- // `<=150`), or a space-separated combination (`>100 <150`). Normalized to an
31
- // inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
32
- // server only ever sees `exitCodeMin` / `exitCodeMax`.
26
+ // An exit-code expression such as `137`, `>100` or `>100 <150`; see
27
+ // parseExitCodeRange.
33
28
  exitCode?: string | undefined;
34
29
  retriesOnly?: boolean | undefined;
35
30
  }
36
31
 
37
- /** A fresh default filter — no dimensions active, newest-first. */
32
+ /** A fresh default filter, no dimensions active, newest-first. */
38
33
  export function emptyRunFilters(): RunsListFilters {
39
34
  return { search: "", statuses: [], sortDirection: "desc" };
40
35
  }
41
36
 
42
37
  /**
43
- * Reserved `status` token the "Failed" browse filter sends instead of a fixed
44
- * end-reason list. The server (splitFailureToken in api_types.go) strips it and
45
- * matches on each run's persisted `isFailure` bit, so the browse filter agrees
46
- * with the attention badges, the failed stat, and notifications — all of which
47
- * follow the per-task `failures` policy. It is not a real status/end-reason, so
48
- * it OR-combines with the other buckets. Must match `failureStatusToken` in the
49
- * daemon.
38
+ * Reserved `status` token for the "Failed" filter. The daemon matches it on each
39
+ * run's `isFailure` bit (per-task `failures` policy) instead of a fixed
40
+ * end-reason list. Must match `failureStatusToken` in the daemon.
50
41
  */
51
42
  export const FAILURE_STATUS_TOKEN = "failure";
52
43
 
53
44
  /**
54
- * Outcome buckets: the 14 individual run statuses collapsed into five
55
- * plain-language groups, so the common case is a five-item pick instead of a
56
- * flat checklist. Each bucket is purely a UI grouping over `statuses` — toggling
57
- * one adds/removes its members, and the popover's "Advanced" section still
58
- * exposes the individual statuses for surgical filters (e.g. only `timeout`).
59
- *
60
- * Every status appears in exactly one bucket; `Failed` is the failure sentinel,
61
- * which the server resolves to each run's `isFailure` bit. `dot` mirrors that
62
- * group's color in
63
- * RUN_STATUS_CONFIG so the bucket reads at a glance.
45
+ * Outcome buckets: a UI grouping of the individual run statuses into five
46
+ * plain-language picks. The popover's "Advanced" section still exposes the
47
+ * individual statuses.
64
48
  */
65
49
  export interface StatusBucket {
66
50
  key: string;
67
51
  label: string;
68
- /** Tailwind background class for the bucket's status dot. */
69
52
  dot: string;
70
53
  statuses: readonly string[];
71
54
  }
72
55
 
73
56
  export const STATUS_BUCKETS: readonly StatusBucket[] = [
74
- { key: "running", label: "Running", dot: "bg-info-surface", statuses: ["pending", "running"] },
75
- { 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
+ },
76
69
  {
77
70
  key: "failed",
78
71
  label: "Failed",
79
- dot: "bg-danger-surface",
72
+ dot: RUN_STATUS_CONFIG.failed.solidDot,
80
73
  statuses: [FAILURE_STATUS_TOKEN],
81
74
  },
82
75
  {
83
76
  key: "skipped",
84
77
  label: "Skipped",
85
- dot: "bg-on-surface-faint",
78
+ dot: RUN_STATUS_CONFIG.skipped.solidDot,
86
79
  statuses: ["skipped", "dst_skipped", "queue_full"],
87
80
  },
88
81
  {
89
82
  key: "stopped",
90
83
  label: "Stopped",
91
- dot: "bg-warning-surface",
84
+ dot: RUN_STATUS_CONFIG.stopped.solidDot,
92
85
  statuses: ["stopped", "daemon_stopped"],
93
86
  },
94
87
  ];
95
88
 
96
89
  export type BucketState = "on" | "partial" | "off";
97
90
 
98
- /** Whether none, some, or all of a bucket's statuses are currently selected. */
99
91
  export function bucketState(statuses: string[], bucket: StatusBucket): BucketState {
100
92
  const set = new Set(statuses);
101
93
  const present = bucket.statuses.filter((s) => set.has(s)).length;
@@ -122,7 +114,7 @@ function exactBucket(statuses: string[]): StatusBucket | undefined {
122
114
 
123
115
  /**
124
116
  * The popover-managed dimensions, in display (most→least useful) order.
125
- * `task` only ever applies on the cross-task /runs view — on a single task's
117
+ * `task` only ever applies on the cross-task /runs view, on a single task's
126
118
  * page the task name is the page scope (injected at fetch time), never a
127
119
  * popover-set filter, so it stays absent from those filters and is not counted.
128
120
  */
@@ -137,7 +129,6 @@ const DIMENSION_ORDER: FilterDimension[] = [
137
129
  "retries",
138
130
  ];
139
131
 
140
- /** Whether a single dimension is currently constraining the result set. */
141
132
  export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boolean {
142
133
  switch (dim) {
143
134
  case "status":
@@ -155,17 +146,16 @@ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boole
155
146
  }
156
147
  }
157
148
 
158
- /** The active dimensions, in display order — drives the chip row. */
149
+ /** The active dimensions, in display order, drives the chip row. */
159
150
  export function activeDimensions(f: RunsListFilters): FilterDimension[] {
160
151
  return DIMENSION_ORDER.filter((dim) => dimensionActive(f, dim));
161
152
  }
162
153
 
163
- /** How many dimensions are active — the count badge on the Filter button. */
154
+ /** How many dimensions are active, the count badge on the Filter button. */
164
155
  export function activeFilterCount(f: RunsListFilters): number {
165
156
  return activeDimensions(f).length;
166
157
  }
167
158
 
168
- /** Reset one dimension, leaving the others (and search/sort) untouched. */
169
159
  export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsListFilters {
170
160
  switch (dim) {
171
161
  case "status":
@@ -190,47 +180,20 @@ export function humanizeStatus(status: string): string {
190
180
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
191
181
  }
192
182
 
193
- /**
194
- * Descriptive label for a run's trigger source — fuller than the one-word row
195
- * badge (`formatTriggeredByLabel`) for the "cron"/"station"/"service"/"startup"
196
- * sources, where the extra words disambiguate (e.g. "REST API" vs a bare
197
- * "API"). "ui", "cli" and "hook" already read fine as their bare badge word,
198
- * so those three match `formatTriggeredByLabel` exactly.
199
- *
200
- * Note `cron` covers both on-time schedule firings and catch-up for missed
201
- * runs, and `startup` is specifically `run_on_start` (not catch-up).
202
- */
183
+ /** Descriptive label for a run's trigger source (see {@link TRIGGER_LABELS}). */
203
184
  export function triggerDescription(trigger: string): string {
204
- switch (trigger) {
205
- case "cron":
206
- return "Scheduled (cron)";
207
- case "api":
208
- return "REST API";
209
- case "ui":
210
- return "UI";
211
- case "cli":
212
- return "CLI";
213
- case "station":
214
- return "Control plane";
215
- case "service":
216
- return "Service auto-start";
217
- case "startup":
218
- return "On daemon start";
219
- case "hook":
220
- return "Hook";
221
- default:
222
- return humanizeStatus(trigger);
223
- }
185
+ const known = TRIGGERS.find((t) => t === trigger);
186
+ return known ? TRIGGER_LABELS[known].long : humanizeStatus(trigger);
224
187
  }
225
188
 
226
189
  /**
227
190
  * The triggers offered in the filter dropdown. `station` is intentionally
228
- * excluded for now — control-plane runs still carry the `station` trigger, but
191
+ * excluded for now, control-plane runs still carry the `station` trigger, but
229
192
  * it isn't a selectable filter dimension.
230
193
  */
231
194
  export const FILTERABLE_TRIGGERS: readonly Trigger[] = TRIGGERS.filter((t) => t !== "station");
232
195
 
233
- /** Chip label for the status dimension — names a whole bucket when it matches. */
196
+ /** Chip label for the status dimension, names a whole bucket when it matches. */
234
197
  export function statusChipLabel(statuses: string[]): string {
235
198
  const bucket = exactBucket(statuses);
236
199
  if (bucket) return bucket.label;
@@ -239,15 +202,6 @@ export function statusChipLabel(statuses: string[]): string {
239
202
  return `${String(statuses.length)} statuses`;
240
203
  }
241
204
 
242
- // --- Time helpers --------------------------------------------------------
243
- //
244
- // A bare calendar day (`YYYY-MM-DD` from a native date input) maps to a local
245
- // instant: the "from" edge is that day's 00:00, the "to" edge is its end
246
- // (23:59:59.999) so picking the same day for both bounds — or the one-click
247
- // "on day" shortcut — captures the whole day and round-trips back to the same
248
- // date in the inputs. Bounds are stored as absolute RFC3339 (UTC) instants, the
249
- // shape the list query and SSE-merge filter already expect.
250
-
251
205
  /** Parse `YYYY-MM-DD` into the local Date for that day, or null if malformed. */
252
206
  function localDay(dateStr: string): Date | null {
253
207
  const parts = dateStr.split("-");
@@ -295,14 +249,6 @@ export function isWholeDay(after?: string, before?: string): boolean {
295
249
  );
296
250
  }
297
251
 
298
- // --- Exit-code expression ------------------------------------------------
299
- //
300
- // The popover takes a free-form exit-code expression and normalizes it to an
301
- // inclusive integer [min, max] range — the shape the server gates on. Because
302
- // exit codes are integers, a strict comparison collapses to an inclusive
303
- // bound: `>100` is min 101, `<150` is max 149. Tokens combine (`>100 <150`),
304
- // and a bare number is an exact match (min = max = n).
305
-
306
252
  export interface ExitCodeRange {
307
253
  min?: number;
308
254
  max?: number;
@@ -311,10 +257,10 @@ export interface ExitCodeRange {
311
257
  const EXIT_CODE_TOKEN = /^(>=|<=|>|<)?(-?\d+)$/;
312
258
 
313
259
  /**
314
- * Parse an exit-code expression into an inclusive [min, max] range. Returns
315
- * `valid: false` if any token is unparseable, so the UI can flag a typo
316
- * instead of silently applying half of it. An empty expression is valid with
317
- * no bounds (no constraint).
260
+ * Parse an exit-code expression into the inclusive integer range the server
261
+ * filters on: `>100` is min 101, `<150` is max 149, a bare number is an exact
262
+ * match, and tokens combine. Any unparseable token makes the whole expression
263
+ * invalid so the UI can flag the typo. An empty expression is valid and unbounded.
318
264
  */
319
265
  export function parseExitCodeRange(expr: string): { range: ExitCodeRange; valid: boolean } {
320
266
  const range: ExitCodeRange = {};
@@ -353,18 +299,77 @@ export function exitCodeRange(expr: string | undefined): ExitCodeRange {
353
299
  return valid ? range : {};
354
300
  }
355
301
 
356
- /** True when the expression resolves to at least one bound (a real filter). */
357
302
  export function exitCodeRangeActive(expr: string | undefined): boolean {
358
303
  const { min, max } = exitCodeRange(expr);
359
304
  return min !== undefined || max !== undefined;
360
305
  }
361
306
 
362
- /** True when the expression is empty or fully parseable — drives input validation. */
307
+ /** True when the expression is empty or fully parseable, drives input validation. */
363
308
  export function isExitCodeExprValid(expr: string): boolean {
364
309
  return parseExitCodeRange(expr).valid;
365
310
  }
366
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
+
367
345
  /** Chip label for an active exit-code filter, e.g. `Exit >100 <150`. */
368
346
  export function exitCodeChipLabel(expr: string | undefined): string {
369
347
  return `Exit ${(expr ?? "").trim()}`;
370
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
+ }