@runwisp/ui 0.7.0 → 0.9.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 (52) hide show
  1. package/package.json +4 -12
  2. package/src/lib/components/Alert.svelte +4 -4
  3. package/src/lib/components/Avatar.svelte +8 -8
  4. package/src/lib/components/Badge.svelte +23 -23
  5. package/src/lib/components/CronInput.svelte +1 -1
  6. package/src/lib/components/Dropdown.svelte +16 -3
  7. package/src/lib/components/DurationInput.svelte +4 -2
  8. package/src/lib/components/EmptyState.svelte +1 -1
  9. package/src/lib/components/FormField.svelte +4 -2
  10. package/src/lib/components/Input.svelte +4 -2
  11. package/src/lib/components/LogConsole.svelte +22 -29
  12. package/src/lib/components/Progress.svelte +4 -4
  13. package/src/lib/components/RadioGroup.svelte +3 -1
  14. package/src/lib/components/Select.svelte +1 -1
  15. package/src/lib/components/Sparkline.svelte +11 -3
  16. package/src/lib/components/TaskCard.svelte +4 -4
  17. package/src/lib/components/Textarea.svelte +2 -2
  18. package/src/lib/components/ToastContainer.svelte +4 -4
  19. package/src/lib/components/Tooltip.svelte +11 -2
  20. package/src/lib/components/dashboard/RunDetailPanel.svelte +34 -18
  21. package/src/lib/components/dashboard/RunsList.svelte +19 -2
  22. package/src/lib/components/dashboard/cron-format.test.ts +67 -0
  23. package/src/lib/components/dashboard/cron-format.ts +71 -0
  24. package/src/lib/components/dashboard/run-helpers.test.ts +25 -1
  25. package/src/lib/components/dashboard/run-helpers.ts +15 -0
  26. package/src/lib/components/dashboard/status-config.test.ts +18 -0
  27. package/src/lib/components/dashboard/status-config.ts +41 -0
  28. package/src/lib/index.ts +2 -5
  29. package/src/lib/log-console/ansi.test.ts +50 -0
  30. package/src/lib/log-console/ansi.ts +39 -0
  31. package/src/lib/theme-tokens.css +33 -0
  32. package/src/lib/theme.css +6 -0
  33. package/src/lib/utils/error.test.ts +34 -0
  34. package/src/lib/utils/format.test.ts +37 -1
  35. package/src/lib/utils/format.ts +8 -6
  36. package/src/lib/utils/id.test.ts +23 -0
  37. package/src/stories/Avatar.stories.svelte +1 -1
  38. package/src/stories/Breadcrumb.stories.svelte +3 -3
  39. package/src/stories/Card.stories.svelte +21 -19
  40. package/src/stories/Checkbox.stories.svelte +2 -2
  41. package/src/stories/DataGrid.stories.svelte +1 -1
  42. package/src/stories/Dropdown.stories.svelte +3 -3
  43. package/src/stories/EmptyState.stories.svelte +1 -1
  44. package/src/stories/LogConsole.stories.svelte +75 -0
  45. package/src/stories/LogConsoleDemo.svelte +60 -0
  46. package/src/stories/Logo.stories.svelte +9 -9
  47. package/src/stories/Modal.stories.svelte +12 -10
  48. package/src/stories/Progress.stories.svelte +6 -6
  49. package/src/stories/Tabs.stories.svelte +16 -12
  50. package/src/stories/Toggle.stories.svelte +2 -2
  51. package/src/stories/Tooltip.stories.svelte +5 -5
  52. package/src/lib/utils/toast.ts +0 -60
@@ -15,12 +15,13 @@
15
15
  import Button from "../Button.svelte";
16
16
  import EmptyState from "../EmptyState.svelte";
17
17
  import LogConsole from "../LogConsole.svelte";
18
+ import Tooltip from "../Tooltip.svelte";
18
19
  import type { Run } from "./types.js";
19
20
  import type { LogEvent, LogSlice } from "../../log-console/types.js";
20
21
  import { formatDateTime } from "../../utils/format.js";
21
22
  import { formatShortId } from "../../utils/id.js";
22
23
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
23
- import { runDuration } from "./run-helpers.js";
24
+ import { runDuration, runStartDelay } from "./run-helpers.js";
24
25
 
25
26
  let {
26
27
  run,
@@ -93,9 +94,10 @@
93
94
  {@const config = getRunStatusConfig(runDisplayStatus(run))}
94
95
  {@const DetailIcon = config.icon}
95
96
  {@const duration = runDuration(run)}
97
+ {@const startDelay = runStartDelay(run)}
96
98
  <!-- Detailed Header -->
97
- <div class="shrink-0 border-b border-outline-faint bg-surface-raised p-6">
98
- <div class="flex flex-col justify-between gap-6 lg:flex-row lg:items-start">
99
+ <div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
100
+ <div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
99
101
  <div class="flex items-start gap-4">
100
102
  <!-- Large Status Icon -->
101
103
  <div
@@ -119,15 +121,17 @@
119
121
  {/if}
120
122
  {/if}
121
123
  </h2>
122
- <Badge
123
- variant={runDisplayStatus(run) === "success"
124
- ? "success"
125
- : run.status === "running"
126
- ? "info"
127
- : "danger"}
128
- >
129
- {runDisplayStatus(run).toUpperCase()}
130
- </Badge>
124
+ <Tooltip content={config.description} position="bottom" wide>
125
+ <Badge
126
+ variant={runDisplayStatus(run) === "success"
127
+ ? "success"
128
+ : run.status === "running"
129
+ ? "info"
130
+ : "danger"}
131
+ >
132
+ {runDisplayStatus(run).toUpperCase()}
133
+ </Badge>
134
+ </Tooltip>
131
135
  {#if canDelete}
132
136
  <Button
133
137
  variant="ghost"
@@ -187,7 +191,7 @@
187
191
 
188
192
  <!-- Stats Grid -->
189
193
  <div
190
- class="grid grid-cols-3 gap-6 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3"
194
+ class="grid grid-cols-1 gap-3 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3 @lg:grid-cols-3 @lg:gap-6 @4xl:shrink-0"
191
195
  >
192
196
  <div class="flex flex-col gap-1">
193
197
  <span
@@ -197,9 +201,19 @@
197
201
  <span class="font-mono text-sm font-medium whitespace-nowrap text-on-surface">
198
202
  {run.start_at ? formatDateTime(run.start_at) : "--:--"}
199
203
  </span>
204
+ {#if startDelay}
205
+ <span
206
+ class="font-mono text-xs whitespace-nowrap text-on-surface-faint"
207
+ title="Scheduled at {formatDateTime(run.created_at)}"
208
+ >
209
+ +{startDelay} after scheduled
210
+ </span>
211
+ {/if}
200
212
  </div>
201
213
 
202
- <div class="flex flex-col gap-1 border-l border-outline pl-6">
214
+ <div
215
+ class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
216
+ >
203
217
  <span
204
218
  class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
205
219
  >Duration</span
@@ -209,7 +223,9 @@
209
223
  </span>
210
224
  </div>
211
225
 
212
- <div class="flex flex-col gap-1 border-l border-outline pl-6">
226
+ <div
227
+ class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
228
+ >
213
229
  <span
214
230
  class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
215
231
  >Exited</span
@@ -227,9 +243,9 @@
227
243
  </div>
228
244
 
229
245
  <!-- Console View -->
230
- <div class="flex min-h-0 flex-1 flex-col bg-slate-950">
246
+ <div class="flex min-h-0 flex-1 flex-col bg-mist-950">
231
247
  <div
232
- class="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900/50 px-4 py-2 font-mono text-xs text-slate-400"
248
+ class="flex shrink-0 items-center justify-between border-b border-mist-800 bg-mist-900/50 px-4 py-2 font-mono text-xs text-mist-400"
233
249
  >
234
250
  <div class="flex items-center gap-2">
235
251
  <TerminalIcon size={14} />
@@ -254,7 +270,7 @@
254
270
  run.id,
255
271
  )}/log/raw"
256
272
  download="{run.task_name}-{run.id}.log"
257
- class="flex items-center gap-1.5 rounded border border-slate-700 px-2 py-1 text-slate-300 transition-colors hover:border-slate-500 hover:bg-slate-800 hover:text-slate-100"
273
+ class="flex items-center gap-1.5 rounded border border-mist-700 px-2 py-1 text-mist-200 transition-colors hover:border-mist-500 hover:bg-mist-800 hover:text-mist-100"
258
274
  title="Download the full log (rotated and current parts as one file)"
259
275
  >
260
276
  <Download size={12} />
@@ -28,6 +28,7 @@
28
28
  import Button from "../Button.svelte";
29
29
  import Badge from "../Badge.svelte";
30
30
  import EmptyState from "../EmptyState.svelte";
31
+ import Tooltip from "../Tooltip.svelte";
31
32
  import type { Run } from "./types.js";
32
33
  import type { RunSelector } from "@runwisp/common";
33
34
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
@@ -50,6 +51,7 @@
50
51
  taskNameFilter,
51
52
  headerLabel = "Run History",
52
53
  emptyText = "No runs yet",
54
+ emptyDescription,
53
55
  bulkActions = false,
54
56
  onBulkCancel,
55
57
  onBulkDelete,
@@ -67,6 +69,7 @@
67
69
  taskNameFilter?: string;
68
70
  headerLabel?: string;
69
71
  emptyText?: string;
72
+ emptyDescription?: string;
70
73
  bulkActions?: boolean;
71
74
  onBulkCancel?: BulkHandler;
72
75
  onBulkDelete?: BulkHandler;
@@ -341,7 +344,13 @@
341
344
 
342
345
  <div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
343
346
  {#if items.length === 0 && !loading}
344
- <EmptyState title={emptyText} icon={Clock} iconSize={32} class="py-8" />
347
+ <EmptyState
348
+ title={emptyText}
349
+ description={emptyDescription}
350
+ icon={Clock}
351
+ iconSize={32}
352
+ class="py-8"
353
+ />
345
354
  {:else}
346
355
  <div
347
356
  style:height="{$virtualizer.getTotalSize()}px"
@@ -463,7 +472,15 @@
463
472
 
464
473
  <div class="flex items-center justify-between text-xs">
465
474
  <div class="flex items-center gap-2 text-on-surface-muted">
466
- <span class="capitalize">{runDisplayStatus(run)}</span>
475
+ <Tooltip
476
+ content={config.description}
477
+ position="right"
478
+ wide
479
+ >
480
+ <span class="capitalize"
481
+ >{runDisplayStatus(run)}</span
482
+ >
483
+ </Tooltip>
467
484
  {#if run.instance_index > 0}
468
485
  <span
469
486
  class="font-mono text-2xs text-on-surface-faint"
@@ -0,0 +1,67 @@
1
+ // Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { humanizeCron } from "./cron-format.js";
5
+
6
+ describe("humanizeCron", () => {
7
+ it("humanizes a standard 5-field expression", () => {
8
+ const result = humanizeCron("*/5 * * * *");
9
+ expect(result.humanized).toBe("Every 5 minutes");
10
+ expect(result.raw).toBe("*/5 * * * *");
11
+ expect(result.isHumanized).toBe(true);
12
+ });
13
+
14
+ it("humanizes a daily expression", () => {
15
+ const result = humanizeCron("0 3 * * *");
16
+ expect(result.humanized).toBe("At 03:00 AM");
17
+ expect(result.isHumanized).toBe(true);
18
+ });
19
+
20
+ it("humanizes @hourly via cronstrue", () => {
21
+ const result = humanizeCron("@hourly");
22
+ expect(result.humanized).toBe("Every hour");
23
+ expect(result.isHumanized).toBe(true);
24
+ });
25
+
26
+ it("handles robfig's @every with a compound duration", () => {
27
+ const result = humanizeCron("@every 1h30m");
28
+ expect(result.humanized).toBe("Every 1 hour 30 minutes");
29
+ expect(result.raw).toBe("@every 1h30m");
30
+ expect(result.isHumanized).toBe(true);
31
+ });
32
+
33
+ it("handles @every with a single unit", () => {
34
+ expect(humanizeCron("@every 10s").humanized).toBe("Every 10 seconds");
35
+ expect(humanizeCron("@every 1m").humanized).toBe("Every 1 minute");
36
+ expect(humanizeCron("@every 2h").humanized).toBe("Every 2 hours");
37
+ });
38
+
39
+ it("skips zero-valued duration segments", () => {
40
+ expect(humanizeCron("@every 1h0m").humanized).toBe("Every 1 hour");
41
+ });
42
+
43
+ it("falls back to raw for @every with sub-second units", () => {
44
+ const result = humanizeCron("@every 500ms");
45
+ expect(result.humanized).toBe("@every 500ms");
46
+ expect(result.isHumanized).toBe(false);
47
+ });
48
+
49
+ it("falls back to raw for @every with a garbage duration", () => {
50
+ const result = humanizeCron("@every banana");
51
+ expect(result.humanized).toBe("@every banana");
52
+ expect(result.isHumanized).toBe(false);
53
+ });
54
+
55
+ it("falls back to raw for unparseable expressions — never 'Invalid'", () => {
56
+ const result = humanizeCron("not a cron");
57
+ expect(result.humanized).toBe("not a cron");
58
+ expect(result.isHumanized).toBe(false);
59
+ expect(result.humanized).not.toMatch(/invalid/i);
60
+ });
61
+
62
+ it("trims surrounding whitespace", () => {
63
+ const result = humanizeCron(" */5 * * * * ");
64
+ expect(result.raw).toBe("*/5 * * * *");
65
+ expect(result.isHumanized).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,71 @@
1
+ // Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
2
+
3
+ import cronstrue from "cronstrue";
4
+
5
+ export interface HumanizedCron {
6
+ /** Display text — humanized when possible, otherwise the raw expression. */
7
+ humanized: string;
8
+ /** The original cron expression, for tooltips. */
9
+ raw: string;
10
+ /** False when humanizing failed and `humanized` is just `raw`. */
11
+ isHumanized: boolean;
12
+ }
13
+
14
+ const EVERY_RE = /^@every\s+(\S+)$/;
15
+
16
+ const UNIT_NAMES: Record<string, [string, string]> = {
17
+ h: ["hour", "hours"],
18
+ m: ["minute", "minutes"],
19
+ s: ["second", "seconds"],
20
+ };
21
+
22
+ // humanizeEvery turns robfig's `@every 1h30m` (Go duration syntax) into
23
+ // "Every 1 hour 30 minutes". Returns null when the duration doesn't parse.
24
+ function humanizeEvery(duration: string): string | null {
25
+ const parts = [...duration.matchAll(/(\d+(?:\.\d+)?)(h|m|s|ms|us|µs|ns)/g)];
26
+ if (parts.length === 0 || parts.map((p) => p[0]).join("") !== duration) {
27
+ return null;
28
+ }
29
+
30
+ const words: string[] = [];
31
+ for (const [, amount, unit] of parts) {
32
+ if (!amount || !unit) return null;
33
+ const value = Number(amount);
34
+ if (value === 0) continue;
35
+ const names = UNIT_NAMES[unit];
36
+ if (!names) {
37
+ // Sub-second units are valid Go durations but absurd as schedules;
38
+ // show the raw expression rather than "Every 500 milliseconds".
39
+ return null;
40
+ }
41
+ words.push(`${amount} ${value === 1 ? names[0] : names[1]}`);
42
+ }
43
+ if (words.length === 0) return null;
44
+
45
+ return `Every ${words.join(" ")}`;
46
+ }
47
+
48
+ /**
49
+ * humanizeCron renders a cron expression as plain English ("Every 5 minutes").
50
+ * Handles robfig's `@every <duration>` extension (cronstrue throws on it) and
51
+ * falls back to the raw expression on anything unparseable — never "Invalid".
52
+ */
53
+ export function humanizeCron(cron: string): HumanizedCron {
54
+ const raw = cron.trim();
55
+
56
+ const every = EVERY_RE.exec(raw);
57
+ if (every?.[1]) {
58
+ const humanized = humanizeEvery(every[1]);
59
+ if (humanized) {
60
+ return { humanized, raw, isHumanized: true };
61
+ }
62
+ return { humanized: raw, raw, isHumanized: false };
63
+ }
64
+
65
+ try {
66
+ const humanized = cronstrue.toString(raw, { throwExceptionOnParseError: true });
67
+ return { humanized, raw, isHumanized: true };
68
+ } catch {
69
+ return { humanized: raw, raw, isHumanized: false };
70
+ }
71
+ }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { runDuration } from "./run-helpers.js";
5
+ import { runDuration, runStartDelay } from "./run-helpers.js";
6
6
 
7
7
  describe("runDuration", () => {
8
8
  it("returns undefined when start_at is not set", () => {
@@ -35,3 +35,27 @@ describe("runDuration", () => {
35
35
  });
36
36
  });
37
37
  });
38
+
39
+ describe("runStartDelay", () => {
40
+ it("returns undefined when start_at is not set", () => {
41
+ expect(runStartDelay({ created_at: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
42
+ });
43
+
44
+ it("returns undefined when the run started within a second of its tick", () => {
45
+ expect(
46
+ runStartDelay({
47
+ created_at: "2024-06-15T12:00:00.000Z",
48
+ start_at: "2024-06-15T12:00:00.300Z",
49
+ }),
50
+ ).toBeUndefined();
51
+ });
52
+
53
+ it("formats the jitter/queue gap when the run started meaningfully later", () => {
54
+ expect(
55
+ runStartDelay({
56
+ created_at: "2024-06-15T03:00:00.000Z",
57
+ start_at: "2024-06-15T03:07:12.000Z",
58
+ }),
59
+ ).toBe("7m 12s");
60
+ });
61
+ });
@@ -11,6 +11,21 @@ export function runDuration(run: Pick<Run, "start_at" | "end_at">): string | und
11
11
  return formatDuration(end - start);
12
12
  }
13
13
 
14
+ /**
15
+ * Gap between when a run was scheduled (`created_at`, the cron tick) and when
16
+ * it actually started (`start_at`), formatted — or undefined when the two are
17
+ * within a second of each other. This is the visible face of `jitter`: a
18
+ * jittered run is created at its tick but starts later inside the window, and
19
+ * this is by how much. It also surfaces queue-wait, since a queued run is
20
+ * created when it joins the line and started when the line clears.
21
+ */
22
+ export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string | undefined {
23
+ if (!run.start_at) return undefined;
24
+ const delay = new Date(run.start_at).getTime() - new Date(run.created_at).getTime();
25
+ if (delay < 1000) return undefined;
26
+ return formatDuration(delay);
27
+ }
28
+
14
29
  /**
15
30
  * Monotonic position of a run's status in its lifecycle. Used to reject stale
16
31
  * updates (e.g. a `pending` HTTP response arriving after an SSE already
@@ -0,0 +1,18 @@
1
+ // Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { RUN_STATUSES } from "@runwisp/common";
5
+ import { RUN_STATUS_CONFIG } from "./status-config.js";
6
+
7
+ describe("RUN_STATUS_CONFIG", () => {
8
+ it("has a non-empty description for every run status", () => {
9
+ for (const status of RUN_STATUSES) {
10
+ const config = RUN_STATUS_CONFIG[status];
11
+ expect(config, `missing config for status "${status}"`).toBeDefined();
12
+ expect(
13
+ config.description.trim().length,
14
+ `empty description for status "${status}"`,
15
+ ).toBeGreaterThan(0);
16
+ }
17
+ });
18
+ });
@@ -11,6 +11,8 @@ import {
11
11
  CircleDashed,
12
12
  SkipForward,
13
13
  FileExclamationPoint,
14
+ CalendarX,
15
+ OctagonX,
14
16
  } from "@lucide/svelte";
15
17
  import type { Component } from "svelte";
16
18
  import { displayStatus, type RunStatus, type Run } from "@runwisp/common";
@@ -22,6 +24,8 @@ export interface RunStatusConfig {
22
24
  border: string;
23
25
  dot: string;
24
26
  badge: string;
27
+ /** One-sentence explanation of what this status means, for tooltips. */
28
+ description: string;
25
29
  }
26
30
 
27
31
  export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
@@ -32,6 +36,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
32
36
  border: "border-info-soft",
33
37
  dot: "bg-info-surface animate-pulse",
34
38
  badge: "bg-info-soft text-info-soft-text",
39
+ description: "This run is executing right now.",
35
40
  },
36
41
  success: {
37
42
  icon: CircleCheck,
@@ -40,6 +45,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
40
45
  border: "border-success-soft",
41
46
  dot: "bg-success-surface",
42
47
  badge: "bg-success-soft text-success-soft-text",
48
+ description: "The run finished with exit code 0.",
43
49
  },
44
50
  failed: {
45
51
  icon: CircleX,
@@ -48,6 +54,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
48
54
  border: "border-danger-soft",
49
55
  dot: "bg-danger-surface",
50
56
  badge: "bg-danger-soft text-danger-soft-text",
57
+ description: "The run exited with a non-zero code.",
51
58
  },
52
59
  crashed: {
53
60
  icon: CircleAlert,
@@ -56,6 +63,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
56
63
  border: "border-danger-soft",
57
64
  dot: "bg-danger-surface",
58
65
  badge: "bg-danger-soft text-danger-soft-text",
66
+ description:
67
+ "The process was killed, or the daemon found it still 'running' after a hard crash and marked it crashed (exit -2). It was not resumed.",
59
68
  },
60
69
  stopped: {
61
70
  icon: CircleStop,
@@ -64,6 +73,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
64
73
  border: "border-warning-soft",
65
74
  dot: "bg-warning-surface",
66
75
  badge: "bg-warning-soft text-warning-soft-text",
76
+ description: "An operator stopped this run before it finished.",
67
77
  },
68
78
  timeout: {
69
79
  icon: TimerOff,
@@ -72,6 +82,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
72
82
  border: "border-warning-soft",
73
83
  dot: "bg-warning-surface",
74
84
  badge: "bg-warning-soft text-warning-soft-text",
85
+ description: "The run exceeded its configured timeout and was terminated.",
75
86
  },
76
87
  skipped: {
77
88
  icon: SkipForward,
@@ -80,6 +91,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
80
91
  border: "border-outline-faint",
81
92
  dot: "bg-on-surface-faint",
82
93
  badge: "bg-surface-sunken text-on-surface",
94
+ description:
95
+ 'Skipped by the concurrency policy (on_overlap = "skip") because a previous run was still going.',
83
96
  },
84
97
  log_overflow: {
85
98
  icon: FileExclamationPoint,
@@ -88,6 +101,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
88
101
  border: "border-danger-soft",
89
102
  dot: "bg-danger-surface",
90
103
  badge: "bg-danger-soft text-danger-soft-text",
104
+ description: "The run hit log_max_size and was handled per log_on_full.",
91
105
  },
92
106
  queue_full: {
93
107
  icon: SkipForward,
@@ -96,6 +110,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
96
110
  border: "border-warning-soft",
97
111
  dot: "bg-warning-surface",
98
112
  badge: "bg-warning-soft text-warning-soft-text",
113
+ description: "Skipped because the task's queue was already at queue_max.",
99
114
  },
100
115
  dst_skipped: {
101
116
  icon: SkipForward,
@@ -104,6 +119,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
104
119
  border: "border-outline-faint",
105
120
  dot: "bg-on-surface-faint",
106
121
  badge: "bg-surface-sunken text-on-surface",
122
+ description:
123
+ "Skipped: this cron tick was the duplicate half of a DST fall-back, so it was recorded but not run.",
107
124
  },
108
125
  daemon_stopped: {
109
126
  icon: CircleStop,
@@ -112,6 +129,28 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
112
129
  border: "border-warning-soft",
113
130
  dot: "bg-warning-surface",
114
131
  badge: "bg-warning-soft text-warning-soft-text",
132
+ description:
133
+ "The daemon shut down while this run was in flight and it exceeded shutdown_timeout; it was not resumed.",
134
+ },
135
+ missed: {
136
+ icon: CalendarX,
137
+ color: "text-danger-surface",
138
+ bg: "bg-danger-soft",
139
+ border: "border-danger-soft",
140
+ dot: "bg-danger-surface",
141
+ badge: "bg-danger-soft text-danger-soft-text",
142
+ description:
143
+ "A scheduled run never happened because the daemon was down. Detected and recorded on restart.",
144
+ },
145
+ start_failed: {
146
+ icon: OctagonX,
147
+ color: "text-danger-surface",
148
+ bg: "bg-danger-soft",
149
+ border: "border-danger-soft",
150
+ dot: "bg-danger-surface",
151
+ badge: "bg-danger-soft text-danger-soft-text",
152
+ description:
153
+ "The service kept failing to stay up for healthy_after; after start_retries the supervisor gave up (FATAL) and stopped restarting it.",
115
154
  },
116
155
  pending: {
117
156
  icon: Clock,
@@ -120,6 +159,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
120
159
  border: "border-outline",
121
160
  dot: "bg-on-surface-faint",
122
161
  badge: "bg-surface-sunken text-on-surface",
162
+ description: "Queued and waiting to start.",
123
163
  },
124
164
  ended: {
125
165
  icon: CircleDashed,
@@ -128,6 +168,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
128
168
  border: "border-outline-faint",
129
169
  dot: "bg-on-surface-faint",
130
170
  badge: "bg-surface-sunken text-on-surface",
171
+ description: "The run finished. No specific end reason was recorded.",
131
172
  },
132
173
  };
133
174
 
package/src/lib/index.ts CHANGED
@@ -43,8 +43,6 @@ export { default as AccordionItem } from "./components/AccordionItem.svelte";
43
43
  export { default as Breadcrumb } from "./components/Breadcrumb.svelte";
44
44
  export { default as Pagination } from "./components/Pagination.svelte";
45
45
  export { default as Tabs } from "./components/Tabs.svelte";
46
- export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
47
-
48
46
  export { default as Avatar } from "./components/Avatar.svelte";
49
47
  export { default as Badge } from "./components/Badge.svelte";
50
48
  export { default as Card } from "./components/Card.svelte";
@@ -71,8 +69,6 @@ export { default as ToastContainer } from "./components/ToastContainer.svelte";
71
69
  export { default as Tooltip } from "./components/Tooltip.svelte";
72
70
 
73
71
  export { default as CodeBlock } from "./components/CodeBlock.svelte";
74
- export { default as FeatureCard } from "./components/FeatureCard.svelte";
75
- export { default as FeatureGrid } from "./components/FeatureGrid.svelte";
76
72
  export { default as TaskCard } from "./components/TaskCard.svelte";
77
73
  export type { TaskCardAccent } from "./components/TaskCard.svelte";
78
74
 
@@ -94,7 +90,8 @@ export {
94
90
  RUN_STATUS_CONFIG,
95
91
  type RunStatusConfig,
96
92
  } from "./components/dashboard/status-config.js";
97
- export { runDuration, runPhaseOrder } from "./components/dashboard/run-helpers.js";
93
+ export { runDuration, runStartDelay, runPhaseOrder } from "./components/dashboard/run-helpers.js";
94
+ export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
98
95
  export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
99
96
 
100
97
  export { toast, type Toast, type ToastType } from "./utils/toast.svelte.js";
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { ansiLineToHtml } from "./ansi.js";
6
+
7
+ describe("ansiLineToHtml", () => {
8
+ it("passes plain text through unchanged", () => {
9
+ expect(ansiLineToHtml("hello world")).toBe("hello world");
10
+ });
11
+
12
+ it("maps standard foreground codes to theme tokens", () => {
13
+ expect(ansiLineToHtml("\x1b[31merror\x1b[0m")).toBe(
14
+ '<span style="color:var(--rw-ansi-red)">error</span>',
15
+ );
16
+ expect(ansiLineToHtml("\x1b[32mok\x1b[0m")).toBe(
17
+ '<span style="color:var(--rw-ansi-green)">ok</span>',
18
+ );
19
+ expect(ansiLineToHtml("\x1b[36minfo\x1b[0m")).toBe(
20
+ '<span style="color:var(--rw-ansi-cyan)">info</span>',
21
+ );
22
+ });
23
+
24
+ it("maps bright foreground codes to bright theme tokens", () => {
25
+ expect(ansiLineToHtml("\x1b[91mwarn\x1b[0m")).toBe(
26
+ '<span style="color:var(--rw-ansi-bright-red)">warn</span>',
27
+ );
28
+ expect(ansiLineToHtml("\x1b[97mtext\x1b[0m")).toBe(
29
+ '<span style="color:var(--rw-ansi-bright-white)">text</span>',
30
+ );
31
+ });
32
+
33
+ it("maps background codes to theme tokens", () => {
34
+ expect(ansiLineToHtml("\x1b[41mfail\x1b[0m")).toBe(
35
+ '<span style="background-color:var(--rw-ansi-red)">fail</span>',
36
+ );
37
+ });
38
+
39
+ it("escapes HTML in log content", () => {
40
+ expect(ansiLineToHtml("<script>alert(1)</script>")).toBe(
41
+ "&lt;script&gt;alert(1)&lt;/script&gt;",
42
+ );
43
+ });
44
+
45
+ it("does not leak colour state between calls", () => {
46
+ // An unterminated colour in one line must not affect the next.
47
+ ansiLineToHtml("\x1b[31munclosed red");
48
+ expect(ansiLineToHtml("plain")).toBe("plain");
49
+ });
50
+ });
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import Convert from "ansi-to-html";
5
+
6
+ // Theme-derived 16-color ANSI palette. ansi-to-html inlines these values
7
+ // verbatim into style attributes, so CSS var() references resolve against
8
+ // the --rw-ansi-* tokens in theme-tokens.css. Codes 16-255 keep the
9
+ // library's computed RGB defaults.
10
+ const ANSI_PALETTE: Record<number, string> = Object.freeze({
11
+ 0: "var(--rw-ansi-black)",
12
+ 1: "var(--rw-ansi-red)",
13
+ 2: "var(--rw-ansi-green)",
14
+ 3: "var(--rw-ansi-yellow)",
15
+ 4: "var(--rw-ansi-blue)",
16
+ 5: "var(--rw-ansi-magenta)",
17
+ 6: "var(--rw-ansi-cyan)",
18
+ 7: "var(--rw-ansi-white)",
19
+ 8: "var(--rw-ansi-bright-black)",
20
+ 9: "var(--rw-ansi-bright-red)",
21
+ 10: "var(--rw-ansi-bright-green)",
22
+ 11: "var(--rw-ansi-bright-yellow)",
23
+ 12: "var(--rw-ansi-bright-blue)",
24
+ 13: "var(--rw-ansi-bright-magenta)",
25
+ 14: "var(--rw-ansi-bright-cyan)",
26
+ 15: "var(--rw-ansi-bright-white)",
27
+ });
28
+
29
+ // Convert ANSI escape sequences in a single log line to safe HTML spans.
30
+ // A fresh converter per call ensures no colour state leaks between
31
+ // independently virtualised lines.
32
+ export function ansiLineToHtml(text: string): string {
33
+ return new Convert({
34
+ escapeXML: true,
35
+ fg: "var(--rw-color-mist-200)",
36
+ bg: "var(--rw-color-mist-950)",
37
+ colors: ANSI_PALETTE,
38
+ }).toHtml(text);
39
+ }