@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
@@ -2,30 +2,57 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { Run } from "@runwisp/common";
5
6
  import {
6
7
  runDuration,
7
8
  runStartDelay,
8
- runVerdict,
9
+ RUN_VERDICTS,
10
+ runEndMarker,
11
+ runRowReadout,
12
+ highlightParts,
9
13
  formatTriggeredByLabel,
10
14
  runRetryLabel,
11
15
  runUsageLabel,
16
+ runUsageScale,
17
+ usageLevel,
12
18
  instanceSuffix,
19
+ runLogDownloadUrl,
13
20
  } from "./run-helpers.js";
14
21
 
15
- describe("runVerdict", () => {
22
+ describe("runLogDownloadUrl", () => {
23
+ it("points at the daemon's raw log route", () => {
24
+ expect(runLogDownloadUrl("01J0000000000000000000000A")).toBe(
25
+ "/api/runs/01J0000000000000000000000A/log/raw",
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("runEndMarker", () => {
31
+ it("warns only for ends the operator or daemon caused", () => {
32
+ expect(runEndMarker("stopped")).toEqual({
33
+ label: "run stopped by operator",
34
+ tone: "warn",
35
+ });
36
+ expect(runEndMarker("daemon_stopped").tone).toBe("warn");
37
+ expect(runEndMarker("timeout").label).toBe("run timed out");
38
+ expect(runEndMarker("failed")).toEqual({ label: "end of output", tone: "muted" });
39
+ });
40
+ });
41
+
42
+ describe("RUN_VERDICTS", () => {
16
43
  it("phrases a timed outcome so a duration reads after it", () => {
17
- expect(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
18
- expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
44
+ expect(RUN_VERDICTS.succeeded).toEqual({ verb: "succeeded in", timed: true });
45
+ expect(RUN_VERDICTS.failed).toEqual({ verb: "failed after", timed: true });
19
46
  });
20
47
 
21
48
  it("marks statuses that never produced a duration as untimed", () => {
22
49
  // These end without ever running, so the caller renders the verb alone
23
50
  // rather than "skipped after —".
24
- expect(runVerdict("missed").timed).toBe(false);
25
- expect(runVerdict("skipped").timed).toBe(false);
26
- expect(runVerdict("dst_skipped").timed).toBe(false);
27
- expect(runVerdict("queue_full").timed).toBe(false);
28
- expect(runVerdict("pending").timed).toBe(false);
51
+ expect(RUN_VERDICTS.missed.timed).toBe(false);
52
+ expect(RUN_VERDICTS.skipped.timed).toBe(false);
53
+ expect(RUN_VERDICTS.dst_skipped.timed).toBe(false);
54
+ expect(RUN_VERDICTS.queue_full.timed).toBe(false);
55
+ expect(RUN_VERDICTS.pending.timed).toBe(false);
29
56
  });
30
57
  });
31
58
 
@@ -115,9 +142,9 @@ describe("runUsageLabel", () => {
115
142
  expect(runUsageLabel({})).toBeUndefined();
116
143
  });
117
144
 
118
- it("joins peak memory and CPU time", () => {
119
- expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 1500 })).toBe(
120
- "peak 48 MB · CPU 1s",
145
+ it("joins CPU time and memory, never saying peak", () => {
146
+ expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 112_000 })).toBe(
147
+ "CPU 1m 52s · RAM 48 MB",
121
148
  );
122
149
  });
123
150
 
@@ -158,3 +185,95 @@ describe("instanceSuffix", () => {
158
185
  expect(instanceSuffix(2, 3)).toBe("#3");
159
186
  });
160
187
  });
188
+
189
+ describe("runRowReadout", () => {
190
+ const ended = { startedAt: "2026-01-01T00:00:00Z", endedAt: "2026-01-01T00:00:02Z" };
191
+
192
+ it("shows live, queued and the exit code of a failure", () => {
193
+ expect(runRowReadout({ ...ended, status: "running", exitCode: 0 }, "running")).toBe("live");
194
+ expect(runRowReadout({ ...ended, status: "pending", exitCode: 0 }, "pending")).toBe(
195
+ "queued",
196
+ );
197
+ expect(runRowReadout({ ...ended, status: "ended", exitCode: 3 }, "failed")).toBe("exit 3");
198
+ });
199
+
200
+ it("falls back to the duration, or a dash when it never started", () => {
201
+ expect(runRowReadout({ ...ended, status: "ended", exitCode: 0 }, "succeeded")).toBe("2s");
202
+ expect(runRowReadout({ status: "ended", exitCode: 0 }, "missed")).toBe("—");
203
+ });
204
+ });
205
+
206
+ describe("highlightParts", () => {
207
+ it("returns the whole line when nothing matches", () => {
208
+ expect(highlightParts("hello", "zzz")).toEqual({ before: "hello", match: "", after: "" });
209
+ expect(highlightParts("hello", " ")).toEqual({ before: "hello", match: "", after: "" });
210
+ });
211
+
212
+ it("splits around a case-insensitive match", () => {
213
+ expect(highlightParts("an ERROR here", "error")).toEqual({
214
+ before: "an ",
215
+ match: "ERROR",
216
+ after: " here",
217
+ });
218
+ });
219
+
220
+ it("windows a long lead-in with an ellipsis", () => {
221
+ const parts = highlightParts("x".repeat(40) + "needle", "needle");
222
+ expect(parts.before).toBe("…" + "x".repeat(14));
223
+ expect(parts.match).toBe("needle");
224
+ });
225
+ });
226
+
227
+ describe("runUsageScale", () => {
228
+ const run = (
229
+ cpuTimeMs?: number,
230
+ peakMemoryBytes?: number,
231
+ status: Run["status"] = "ended",
232
+ ) => ({
233
+ status,
234
+ ...(cpuTimeMs === undefined ? {} : { cpuTimeMs }),
235
+ ...(peakMemoryBytes === undefined ? {} : { peakMemoryBytes }),
236
+ });
237
+
238
+ it("is undefined when no finished run was measured", () => {
239
+ expect(runUsageScale([])).toBeUndefined();
240
+ expect(runUsageScale([run(5, 10, "running"), run(undefined, 10)])).toBeUndefined();
241
+ });
242
+
243
+ it("scales to the largest run and takes the median as usual", () => {
244
+ expect(runUsageScale([run(100, 1400), run(110, 2900), run(30, 640), run(1, 1)])).toEqual({
245
+ max: { cpu: 110, ram: 2900 },
246
+ usual: { cpu: 100, ram: 1400 },
247
+ });
248
+ });
249
+
250
+ it("leaves out live and unmeasured runs", () => {
251
+ expect(runUsageScale([run(10, 10), run(999, 999, "running"), run(undefined, 999)])).toEqual(
252
+ {
253
+ max: { cpu: 10, ram: 10 },
254
+ usual: { cpu: 10, ram: 10 },
255
+ },
256
+ );
257
+ });
258
+ });
259
+
260
+ describe("usageLevel", () => {
261
+ it("is 0 as usual, 1 from 1.5x, 2 from twice the usual run", () => {
262
+ expect(usageLevel(1400, 1400, "cpu")).toBe(0);
263
+ expect(usageLevel(2099, 1400, "cpu")).toBe(0);
264
+ expect(usageLevel(2100, 1400, "cpu")).toBe(1);
265
+ expect(usageLevel(2799, 1400, "cpu")).toBe(1);
266
+ expect(usageLevel(2800, 1400, "cpu")).toBe(2);
267
+ expect(usageLevel(10, 1400, "cpu")).toBe(0);
268
+ });
269
+
270
+ it("judges a zero or tiny usual run against a floor", () => {
271
+ // A mostly idle task (median CPU 0) that burned 30s of CPU.
272
+ expect(usageLevel(30_000, 0, "cpu")).toBe(2);
273
+ expect(usageLevel(1, 0, "cpu")).toBe(0);
274
+ expect(usageLevel(3, 1, "cpu")).toBe(0);
275
+ // 64 MiB against an idle task's 1 MiB stands out; 8 MiB doesn't.
276
+ expect(usageLevel(64 * 1024 * 1024, 1024 * 1024, "ram")).toBe(2);
277
+ expect(usageLevel(8 * 1024 * 1024, 1024 * 1024, "ram")).toBe(0);
278
+ });
279
+ });
@@ -1,26 +1,18 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { Run, RunStatus } from "@runwisp/common";
4
+ import type { APIPaths, Run, RunStatus, Trigger } from "@runwisp/common";
5
5
  import { formatBytes, formatDuration } from "../../utils/format.js";
6
6
 
7
7
  export interface RunVerdict {
8
8
  /** Verb phrase for the outcome, ending in its preposition when `timed`. */
9
9
  verb: string;
10
- /**
11
- * Whether the phrase expects a duration after it. False for statuses that
12
- * never produced one (nothing ran, or nothing has yet), so the caller
13
- * renders the verb alone rather than "skipped after —".
14
- */
10
+ /** Whether a duration follows the verb; false when nothing ran yet. */
15
11
  timed: boolean;
16
12
  }
17
13
 
18
- /**
19
- * The run's outcome as a verb phrase, so a detail view can state it as one
20
- * sentence ("succeeded in 933ms") instead of a status badge competing with a
21
- * separate duration readout for the same glance.
22
- */
23
- const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
14
+ // The run's outcome as a verb phrase, read as one sentence: "succeeded in 933ms".
15
+ export const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
24
16
  succeeded: { verb: "succeeded in", timed: true },
25
17
  failed: { verb: "failed after", timed: true },
26
18
  crashed: { verb: "crashed after", timed: true },
@@ -39,8 +31,21 @@ const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
39
31
  queue_full: { verb: "skipped", timed: false },
40
32
  };
41
33
 
42
- export function runVerdict(status: RunStatus): RunVerdict {
43
- return RUN_VERDICTS[status];
34
+ export interface RunEndMarker {
35
+ label: string;
36
+ tone: "muted" | "warn";
37
+ }
38
+
39
+ // The line closing a run's console output; only abnormal ends are worth a warning tone.
40
+ const RUN_END_MARKERS: Partial<Record<RunStatus, RunEndMarker>> = {
41
+ stopped: { label: "run stopped by operator", tone: "warn" },
42
+ daemon_stopped: { label: "daemon stopped mid-run", tone: "warn" },
43
+ timeout: { label: "run timed out", tone: "warn" },
44
+ };
45
+ const DEFAULT_END_MARKER: RunEndMarker = { label: "end of output", tone: "muted" };
46
+
47
+ export function runEndMarker(status: RunStatus): RunEndMarker {
48
+ return RUN_END_MARKERS[status] ?? DEFAULT_END_MARKER;
44
49
  }
45
50
 
46
51
  export function runDuration(
@@ -54,12 +59,8 @@ export function runDuration(
54
59
  }
55
60
 
56
61
  /**
57
- * Gap between when a run was scheduled (`createdAt`, the cron tick) and when
58
- * it actually started (`startedAt`), formatted — or undefined when the two are
59
- * within a second of each other. This is the visible face of `jitter`: a
60
- * jittered run is created at its tick but starts later inside the window, and
61
- * this is by how much. It also surfaces queue-wait, since a queued run is
62
- * created when it joins the line and started when the line clears.
62
+ * How long a run waited between `createdAt` and `startedAt` (jitter or queue
63
+ * wait), or undefined when it started within a second.
63
64
  */
64
65
  export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string | undefined {
65
66
  if (!run.startedAt) return undefined;
@@ -68,14 +69,7 @@ export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string
68
69
  return formatDuration(delay);
69
70
  }
70
71
 
71
- /**
72
- * Display suffix for a run's instance slot. A service configured with more than
73
- * one instance gets a 1-based suffix (`#1`, `#2`, …) on every one of its runs;
74
- * a single-instance task (or any non-service, where `instanceCount` is 1)
75
- * returns an empty string so the bare task name is shown. `instanceIndex` is
76
- * the stored 0-based slot; `instanceCount` is the task's currently configured
77
- * instance count.
78
- */
72
+ /** 1-based `#N` suffix for a multi-instance service run, else "". */
79
73
  export function instanceSuffix(instanceIndex: number, instanceCount: number): string {
80
74
  if (instanceCount > 1) {
81
75
  return `#${String(instanceIndex + 1)}`;
@@ -83,23 +77,32 @@ export function instanceSuffix(instanceIndex: number, instanceCount: number): st
83
77
  return "";
84
78
  }
85
79
 
80
+ // Typed against the generated paths so a route rename fails the build.
81
+ const RAW_LOG_PATH: keyof APIPaths = "/api/runs/{runId}/log/raw";
82
+
83
+ /** The full log of a run as one plain-text download. */
84
+ export function runLogDownloadUrl(runId: string): string {
85
+ return RAW_LOG_PATH.replace("{runId}", encodeURIComponent(runId));
86
+ }
87
+
88
+ /** Labels per trigger source: `short` for the row badge, `long` for descriptions. */
89
+ export const TRIGGER_LABELS: Record<Trigger, { short: string; long: string }> = {
90
+ cron: { short: "Cron", long: "Scheduled (cron)" },
91
+ api: { short: "API", long: "REST API" },
92
+ ui: { short: "UI", long: "UI" },
93
+ cli: { short: "CLI", long: "CLI" },
94
+ station: { short: "Station", long: "Control plane" },
95
+ service: { short: "Service", long: "Service auto-start" },
96
+ startup: { short: "Startup", long: "On daemon start" },
97
+ hook: { short: "Hook", long: "Hook" },
98
+ };
99
+
86
100
  /** Human label for why a run fired (the `triggeredBy` source). */
87
101
  export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string {
88
- if (triggeredBy === "api") return "API";
89
- if (triggeredBy === "ui") return "UI";
90
- if (triggeredBy === "cli") return "CLI";
91
- if (triggeredBy === "cron") return "Cron";
92
- if (triggeredBy === "service") return "Service";
93
- if (triggeredBy === "startup") return "Startup";
94
- if (triggeredBy === "hook") return "Hook";
95
- return "Station";
102
+ return TRIGGER_LABELS[triggeredBy].short;
96
103
  }
97
104
 
98
- /**
99
- * "retry #N" label when a run is a retry of an earlier one, else undefined.
100
- * A run is a retry if it carries a positive attempt number or points back at
101
- * the run it re-attempts.
102
- */
105
+ /** "retry #N" when the run re-attempts an earlier one, else undefined. */
103
106
  export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">): string | undefined {
104
107
  if (run.retryAttempt > 0 || run.retryOfRunId) {
105
108
  return `retry #${String(run.retryAttempt)}`;
@@ -108,16 +111,82 @@ export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">):
108
111
  }
109
112
 
110
113
  /**
111
- * "peak 48 MB · CPU 1s" for a run whose resources were measured (shell runs
112
- * only), else undefined.
114
+ * "CPU 1m 52s · RAM 48 MB" for a run whose resources were measured (shell
115
+ * runs only), else undefined. CPU is the CPU time the run used, RAM the most
116
+ * memory it held.
113
117
  */
114
118
  export function runUsageLabel(run: Pick<Run, "peakMemoryBytes" | "cpuTimeMs">): string | undefined {
115
119
  const parts: string[] = [];
116
- if (run.peakMemoryBytes !== undefined) parts.push("peak " + formatBytes(run.peakMemoryBytes));
117
120
  if (run.cpuTimeMs !== undefined) parts.push("CPU " + formatDuration(run.cpuTimeMs));
121
+ if (run.peakMemoryBytes !== undefined) parts.push("RAM " + formatBytes(run.peakMemoryBytes));
118
122
  return parts.length > 0 ? parts.join(" · ") : undefined;
119
123
  }
120
124
 
125
+ /** CPU time (ms) and peak memory (bytes) of one run, or of a task's typical run. */
126
+ export interface RunUsageAmounts {
127
+ cpu: number;
128
+ ram: number;
129
+ }
130
+
131
+ /**
132
+ * How a task's runs compare: `max` is its largest run (the full length of a
133
+ * usage bar), `usual` the median run (what "normal for this task" means).
134
+ */
135
+ export interface RunUsageScale {
136
+ max: RunUsageAmounts;
137
+ usual: RunUsageAmounts;
138
+ }
139
+
140
+ /** A finished run's measured usage, or undefined when it wasn't measured. */
141
+ export function runUsageAmounts(
142
+ run: Pick<Run, "status" | "cpuTimeMs" | "peakMemoryBytes">,
143
+ ): RunUsageAmounts | undefined {
144
+ if (run.status !== "ended") return undefined;
145
+ if (run.cpuTimeMs === undefined || run.peakMemoryBytes === undefined) return undefined;
146
+ return { cpu: run.cpuTimeMs, ram: run.peakMemoryBytes };
147
+ }
148
+
149
+ function median(values: number[]): number {
150
+ const sorted = values.toSorted((a, b) => a - b);
151
+ return sorted[Math.floor(sorted.length / 2)] ?? 0;
152
+ }
153
+
154
+ /** The scale for a task's usage bars, over the measured runs given. */
155
+ export function runUsageScale(
156
+ runs: readonly Pick<Run, "status" | "cpuTimeMs" | "peakMemoryBytes">[],
157
+ ): RunUsageScale | undefined {
158
+ const amounts = runs.flatMap((run) => runUsageAmounts(run) ?? []);
159
+ if (amounts.length === 0) return undefined;
160
+ const cpu = amounts.map((a) => a.cpu);
161
+ const ram = amounts.map((a) => a.ram);
162
+ return {
163
+ max: { cpu: Math.max(...cpu), ram: Math.max(...ram) },
164
+ usual: { cpu: median(cpu), ram: median(ram) },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The least a usual run counts as using, so a tiny or zero baseline (a mostly
170
+ * idle task's median CPU time is often 0) doesn't hide a run that burns real
171
+ * CPU or memory, nor flag 3ms against 1ms. CPU time in ms, memory in bytes.
172
+ */
173
+ const USAGE_FLOOR: RunUsageAmounts = { cpu: 1000, ram: 16 * 1024 * 1024 };
174
+
175
+ /**
176
+ * How unusual a run's amount is for its task: 0 about as usual, 1 at 1.5×
177
+ * the usual run, 2 at twice it or more. A task that always uses the same
178
+ * amount stays at 0, however large that amount is. The usual run counts as
179
+ * at least USAGE_FLOOR for its kind.
180
+ */
181
+ export function usageLevel(value: number, usual: number, kind: keyof RunUsageAmounts): 0 | 1 | 2 {
182
+ const baseline = Math.max(usual, USAGE_FLOOR[kind]);
183
+ if (baseline <= 0) return 0;
184
+ const ratio = value / baseline;
185
+ if (ratio >= 2) return 2;
186
+ if (ratio >= 1.5) return 1;
187
+ return 0;
188
+ }
189
+
121
190
  /**
122
191
  * Monotonic position of a run's status in its lifecycle. Used to reject stale
123
192
  * updates (e.g. a `pending` HTTP response arriving after an SSE already
@@ -128,3 +197,40 @@ export function runPhaseOrder(status: string): number {
128
197
  if (status === "running") return 1;
129
198
  return 2;
130
199
  }
200
+
201
+ /** Right-hand mono readout of a run row: live / queued / exit N / duration. */
202
+ export function runRowReadout(
203
+ run: Pick<Run, "status" | "exitCode" | "startedAt" | "endedAt">,
204
+ displayed: RunStatus,
205
+ ): string {
206
+ if (run.status === "running") return "live";
207
+ if (run.status === "pending") return "queued";
208
+ if (displayed === "failed" || displayed === "crashed") return "exit " + String(run.exitCode);
209
+ return runDuration(run) ?? "—";
210
+ }
211
+
212
+ export interface HighlightParts {
213
+ before: string;
214
+ match: string;
215
+ after: string;
216
+ }
217
+
218
+ /**
219
+ * Split an output line into [before, match, after] around the first
220
+ * occurrence of the query, windowed to keep the match in view. Plain strings,
221
+ * so rendering them as text never lets untrusted HTML reach the DOM.
222
+ */
223
+ export function highlightParts(text: string, query: string): HighlightParts {
224
+ const q = query.trim();
225
+ const idx = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
226
+ if (idx === -1) return { before: text, match: "", after: "" };
227
+ const start = Math.max(0, idx - 14);
228
+ const lead = start > 0 ? "…" : "";
229
+ const windowed = text.slice(start);
230
+ const fi = windowed.toLowerCase().indexOf(q.toLowerCase());
231
+ return {
232
+ before: lead + windowed.slice(0, fi),
233
+ match: windowed.slice(fi, fi + q.length),
234
+ after: windowed.slice(fi + q.length),
235
+ };
236
+ }
@@ -23,7 +23,14 @@ interface RunStatusConfig {
23
23
  color: string;
24
24
  bg: string;
25
25
  dot: string;
26
+ /** `dot` without the running pulse, for static markers (spines, filter swatches). */
27
+ solidDot: string;
26
28
  badge: string;
29
+ /**
30
+ * Accent colour for statuses that mean "something to triage", or undefined.
31
+ * A deliberate stop or a skip is not an alarm, and success least of all.
32
+ */
33
+ alarm: string | undefined;
27
34
  /** One-sentence explanation of what this status means, for tooltips. */
28
35
  description: string;
29
36
  }
@@ -34,19 +41,25 @@ const DANGER = {
34
41
  color: "text-danger-surface",
35
42
  bg: "bg-danger-soft",
36
43
  dot: "bg-danger-surface",
44
+ solidDot: "bg-danger-surface",
37
45
  badge: "bg-danger-soft text-danger-soft-text",
46
+ alarm: "var(--color-danger-surface)",
38
47
  };
39
48
  const WARNING = {
40
49
  color: "text-warning-surface",
41
50
  bg: "bg-warning-soft",
42
51
  dot: "bg-warning-surface",
52
+ solidDot: "bg-warning-surface",
43
53
  badge: "bg-warning-soft text-warning-soft-text",
54
+ alarm: "var(--color-warning-surface)",
44
55
  };
45
56
  const NEUTRAL = {
46
57
  color: "text-on-surface-muted",
47
58
  bg: "bg-surface-sunken",
48
59
  dot: "bg-on-surface-faint",
60
+ solidDot: "bg-on-surface-faint",
49
61
  badge: "bg-surface-sunken text-on-surface",
62
+ alarm: undefined,
50
63
  };
51
64
 
52
65
  export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
@@ -55,7 +68,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
55
68
  color: "text-info-surface",
56
69
  bg: "bg-info-soft",
57
70
  dot: "bg-info-surface animate-pulse",
71
+ solidDot: "bg-info-surface",
58
72
  badge: "bg-info-soft text-info-soft-text",
73
+ alarm: undefined,
59
74
  description: "This run is executing right now.",
60
75
  },
61
76
  succeeded: {
@@ -63,7 +78,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
63
78
  color: "text-success-surface",
64
79
  bg: "bg-success-soft",
65
80
  dot: "bg-success-surface",
81
+ solidDot: "bg-success-surface",
66
82
  badge: "bg-success-soft text-success-soft-text",
83
+ alarm: undefined,
67
84
  description: "The run finished with exit code 0 - everything OK.",
68
85
  },
69
86
  failed: {
@@ -79,6 +96,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
79
96
  },
80
97
  stopped: {
81
98
  ...WARNING,
99
+ alarm: undefined,
82
100
  icon: CircleStop,
83
101
  description: "A human or an external script manually stopped this run before it finished.",
84
102
  },
@@ -7,3 +7,9 @@ export interface DaemonStats {
7
7
  activeTasks: number;
8
8
  successRate: number;
9
9
  }
10
+
11
+ /** A single output-search hit surfaced under its run in the history rail. */
12
+ export interface RunOutputMatch {
13
+ line: number;
14
+ text: string;
15
+ }
@@ -1,14 +1,6 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- // DataGrid's header checkbox (checked / indeterminate / unchecked) must
5
- // reflect which rows on the current page are ACTUALLY selected, not merely
6
- // how many rows are selected. A bare count comparison is wrong the moment
7
- // `selectedRows` holds a different set of rows than `pagedRows` but happens
8
- // to be the same size — e.g. rows selected on a page the grid has since
9
- // paged/filtered away from. Extracted so the membership check is unit
10
- // testable without a component-render harness.
11
-
12
4
  export interface SelectionState {
13
5
  allSelected: boolean;
14
6
  someSelected: boolean;
@@ -17,7 +9,8 @@ export interface SelectionState {
17
9
  /**
18
10
  * Header checkbox state for `pagedRows`: fully checked only when every row
19
11
  * currently on the page is selected, indeterminate when some (but not all)
20
- * of them are. Matches rows by `rowKey`, not by array position or count.
12
+ * of them are. Matches rows by `rowKey`, not by count: the selection can
13
+ * hold rows from a page the grid has since moved away from.
21
14
  */
22
15
  export function selectionState<T>(
23
16
  pagedRows: readonly T[],
@@ -3,7 +3,7 @@
3
3
 
4
4
  // Declarative filter spec. A `FilterField[]` + one bound `FilterValues` object
5
5
  // drives <FilterBar>, the client engine (`applyFilters`), and the URL sync
6
- // helper — the same way DataGrid's `Column[]` drives the table. Add a field
6
+ // helper, the same way DataGrid's `Column[]` drives the table. Add a field
7
7
  // type here, and every table that adopts it renders identically.
8
8
 
9
9
  import type { SelectOption } from "./Select.svelte";
@@ -44,7 +44,7 @@ export type FilterField =
44
44
  primary?: boolean;
45
45
  };
46
46
 
47
- // All values are strings — DOM inputs are string-valued and URL params are
47
+ // All values are strings, DOM inputs are string-valued and URL params are
48
48
  // strings; the client engine coerces (e.g. Number()) where a field needs it.
49
49
  export type FilterValues = Record<string, string>;
50
50
 
@@ -65,7 +65,7 @@ export function isBlank(v: string | undefined): boolean {
65
65
  return !v || v === "all";
66
66
  }
67
67
 
68
- // Which value keys a field owns — daterange owns two, everything else one.
68
+ // Which value keys a field owns, daterange owns two, everything else one.
69
69
  // Used by chips, active-state detection, clear, and URL (de)serialization so
70
70
  // none of them has to special-case the range type.
71
71
  export function fieldKeys(field: FilterField): string[] {
package/src/lib/index.ts CHANGED
@@ -24,7 +24,7 @@ export { default as FormField } from "./components/FormField.svelte";
24
24
  export { default as Input } from "./components/Input.svelte";
25
25
  export { default as Radio } from "./components/Radio.svelte";
26
26
  export { default as RadioGroup } from "./components/RadioGroup.svelte";
27
- export { default as Select } from "./components/Select.svelte";
27
+ export { default as Select, type SelectOption } from "./components/Select.svelte";
28
28
  export { default as Textarea } from "./components/Textarea.svelte";
29
29
  export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
30
30
  export { default as Toggle } from "./components/Toggle.svelte";
@@ -86,14 +86,16 @@ export type { FetchLogsFn, LogEvent, LogSlice } from "./log-console/types.js";
86
86
  export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
87
87
  export { default as RunsList } from "./components/dashboard/RunsList.svelte";
88
88
  export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
89
- export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
89
+ export type { RunOutputMatch } from "./components/dashboard/types.js";
90
90
  export {
91
91
  activeFilterCount,
92
92
  emptyRunFilters,
93
93
  FAILURE_STATUS_TOKEN,
94
94
  humanizeStatus,
95
95
  exitCodeRange,
96
+ runFilterParams,
96
97
  type ExitCodeRange,
98
+ type RunFilterParams,
97
99
  type RunsListFilters,
98
100
  } from "./components/dashboard/run-filters.js";
99
101
  export { RUN_STATUS_CONFIG } from "./components/dashboard/status-config.js";
@@ -102,6 +104,10 @@ export {
102
104
  runPhaseOrder,
103
105
  formatTriggeredByLabel,
104
106
  instanceSuffix,
107
+ runUsageLabel,
108
+ runUsageScale,
109
+ usageLevel,
110
+ type RunUsageScale,
105
111
  } from "./components/dashboard/run-helpers.js";
106
112
  export { humanizeCron } from "./components/dashboard/cron-format.js";
107
113
  export type { DaemonStats } from "./components/dashboard/types.js";
@@ -110,14 +116,19 @@ export { toast } from "./utils/toast.svelte.js";
110
116
  export { extractErrorMessage } from "./utils/error.js";
111
117
  export {
112
118
  formatBytes,
119
+ formatDayMonth,
120
+ formatRelativeCompact,
113
121
  formatRelativeTime,
114
122
  formatRelativeTimeWithAbsolute,
123
+ type RelativeTimeOptions,
115
124
  formatDuration,
116
125
  formatFullDateTime,
117
126
  } from "./utils/format.js";
118
127
  export { formatShortId } from "./utils/id.js";
119
128
  export { debounce } from "./utils/debounce.js";
129
+ export { isTypingTarget } from "./utils/typing-target.js";
120
130
  export { TickingNow } from "./utils/ticking-now.svelte.js";
121
131
  export { CopyFeedback, copyText } from "./utils/clipboard.svelte.js";
122
132
  export { RunMotion } from "./utils/run-motion.js";
123
133
  export { arrival, leave, shift } from "./actions/row-motion.js";
134
+ export { foldToFit, foldUntilFits, type FoldToFitOptions } from "./actions/fold-to-fit.js";
@@ -0,0 +1,12 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { ansiLineToHtml } from "./ansi.js";
6
+
7
+ let { text }: { text: string } = $props();
8
+ </script>
9
+
10
+ <!-- ansiLineToHtml sanitises its input, which is what makes @html safe here. -->
11
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
12
+ <span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>