@runwisp/ui 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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 +76 -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 +27 -17
  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/status-config.test.ts +18 -0
  25. package/src/lib/components/dashboard/status-config.ts +19 -0
  26. package/src/lib/index.ts +1 -4
  27. package/src/lib/log-console/ansi.test.ts +50 -0
  28. package/src/lib/log-console/ansi.ts +39 -0
  29. package/src/lib/theme-tokens.css +33 -0
  30. package/src/lib/theme.css +6 -0
  31. package/src/lib/utils/format.test.ts +37 -1
  32. package/src/lib/utils/format.ts +8 -6
  33. package/src/stories/Avatar.stories.svelte +1 -1
  34. package/src/stories/Breadcrumb.stories.svelte +3 -3
  35. package/src/stories/Card.stories.svelte +21 -19
  36. package/src/stories/Checkbox.stories.svelte +2 -2
  37. package/src/stories/DataGrid.stories.svelte +1 -1
  38. package/src/stories/Dropdown.stories.svelte +3 -3
  39. package/src/stories/EmptyState.stories.svelte +1 -1
  40. package/src/stories/LogConsole.stories.svelte +75 -0
  41. package/src/stories/LogConsoleDemo.svelte +60 -0
  42. package/src/stories/Logo.stories.svelte +9 -9
  43. package/src/stories/Modal.stories.svelte +12 -10
  44. package/src/stories/Progress.stories.svelte +6 -6
  45. package/src/stories/Tabs.stories.svelte +16 -12
  46. package/src/stories/Toggle.stories.svelte +2 -2
  47. package/src/stories/Tooltip.stories.svelte +5 -5
  48. package/src/lib/utils/toast.ts +0 -60
@@ -67,14 +67,14 @@
67
67
  ></textarea>
68
68
 
69
69
  {#if error}
70
- <div class="pointer-events-none absolute top-3 right-3 text-danger-500">
70
+ <div class="pointer-events-none absolute top-3 right-3 text-danger-soft-text">
71
71
  <CircleAlert size={18} />
72
72
  </div>
73
73
  {/if}
74
74
  </div>
75
75
 
76
76
  {#if error}
77
- <p class="text-sm text-danger-600">{error}</p>
77
+ <p class="text-sm text-danger-soft-text">{error}</p>
78
78
  {:else if hint}
79
79
  <p class="text-sm text-on-surface-muted">{hint}</p>
80
80
  {/if}
@@ -26,13 +26,13 @@
26
26
  function getColorClasses(type: Toast["type"]) {
27
27
  switch (type) {
28
28
  case "success":
29
- return "border-success-200 text-success-700";
29
+ return "border-success-soft-border text-success-soft-text";
30
30
  case "error":
31
- return "border-danger-200 text-danger-700";
31
+ return "border-danger-soft-border text-danger-soft-text";
32
32
  case "warning":
33
- return "border-warning-200 text-warning-700";
33
+ return "border-warning-soft-border text-warning-soft-text";
34
34
  case "info":
35
- return "border-wisp-200 text-wisp-700";
35
+ return "border-primary-soft-border text-primary-soft-text";
36
36
  }
37
37
  }
38
38
 
@@ -11,9 +11,17 @@
11
11
  position?: TooltipPosition;
12
12
  children: Snippet;
13
13
  class?: string;
14
+ /** Wrap long content across lines instead of one nowrap line. */
15
+ wide?: boolean;
14
16
  }
15
17
 
16
- let { content, position = "top", children, class: className = "" }: Props = $props();
18
+ let {
19
+ content,
20
+ position = "top",
21
+ children,
22
+ class: className = "",
23
+ wide = false,
24
+ }: Props = $props();
17
25
 
18
26
  const positionClasses: Record<TooltipPosition, string> = {
19
27
  top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
@@ -39,7 +47,8 @@
39
47
  pointer-events-none invisible rounded-lg
40
48
  bg-mist-800 px-2.5 py-1.5 text-xs
41
49
  font-medium
42
- whitespace-nowrap text-white opacity-0 transition-opacity
50
+ {wide ? 'w-max max-w-xs whitespace-normal' : 'whitespace-nowrap'}
51
+ text-white opacity-0 transition-opacity
43
52
  duration-150 group-hover:visible
44
53
  group-hover:opacity-100
45
54
  "
@@ -15,6 +15,7 @@
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";
@@ -28,6 +29,7 @@
28
29
  streamLogs,
29
30
  showTaskName = false,
30
31
  onDelete,
32
+ highlightLine = null,
31
33
  }: {
32
34
  run: Run | undefined;
33
35
  fetchLogs: (
@@ -42,6 +44,7 @@
42
44
  ) => () => void;
43
45
  showTaskName?: boolean;
44
46
  onDelete?: (runId: string) => void;
47
+ highlightLine?: number | null;
45
48
  } = $props();
46
49
 
47
50
  let canDelete = $derived.by(() => {
@@ -92,8 +95,8 @@
92
95
  {@const DetailIcon = config.icon}
93
96
  {@const duration = runDuration(run)}
94
97
  <!-- Detailed Header -->
95
- <div class="shrink-0 border-b border-outline-faint bg-surface-raised p-6">
96
- <div class="flex flex-col justify-between gap-6 lg:flex-row lg:items-start">
98
+ <div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
99
+ <div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
97
100
  <div class="flex items-start gap-4">
98
101
  <!-- Large Status Icon -->
99
102
  <div
@@ -117,15 +120,17 @@
117
120
  {/if}
118
121
  {/if}
119
122
  </h2>
120
- <Badge
121
- variant={runDisplayStatus(run) === "success"
122
- ? "success"
123
- : run.status === "running"
124
- ? "info"
125
- : "danger"}
126
- >
127
- {runDisplayStatus(run).toUpperCase()}
128
- </Badge>
123
+ <Tooltip content={config.description} position="bottom" wide>
124
+ <Badge
125
+ variant={runDisplayStatus(run) === "success"
126
+ ? "success"
127
+ : run.status === "running"
128
+ ? "info"
129
+ : "danger"}
130
+ >
131
+ {runDisplayStatus(run).toUpperCase()}
132
+ </Badge>
133
+ </Tooltip>
129
134
  {#if canDelete}
130
135
  <Button
131
136
  variant="ghost"
@@ -185,7 +190,7 @@
185
190
 
186
191
  <!-- Stats Grid -->
187
192
  <div
188
- class="grid grid-cols-3 gap-6 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3"
193
+ 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"
189
194
  >
190
195
  <div class="flex flex-col gap-1">
191
196
  <span
@@ -197,7 +202,9 @@
197
202
  </span>
198
203
  </div>
199
204
 
200
- <div class="flex flex-col gap-1 border-l border-outline pl-6">
205
+ <div
206
+ 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"
207
+ >
201
208
  <span
202
209
  class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
203
210
  >Duration</span
@@ -207,7 +214,9 @@
207
214
  </span>
208
215
  </div>
209
216
 
210
- <div class="flex flex-col gap-1 border-l border-outline pl-6">
217
+ <div
218
+ 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"
219
+ >
211
220
  <span
212
221
  class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
213
222
  >Exited</span
@@ -225,9 +234,9 @@
225
234
  </div>
226
235
 
227
236
  <!-- Console View -->
228
- <div class="flex min-h-0 flex-1 flex-col bg-slate-950">
237
+ <div class="flex min-h-0 flex-1 flex-col bg-mist-950">
229
238
  <div
230
- 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"
239
+ 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"
231
240
  >
232
241
  <div class="flex items-center gap-2">
233
242
  <TerminalIcon size={14} />
@@ -252,7 +261,7 @@
252
261
  run.id,
253
262
  )}/log/raw"
254
263
  download="{run.task_name}-{run.id}.log"
255
- 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"
264
+ 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"
256
265
  title="Download the full log (rotated and current parts as one file)"
257
266
  >
258
267
  <Download size={12} />
@@ -265,6 +274,7 @@
265
274
  bind:this={logConsole}
266
275
  fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
267
276
  class="min-h-0 flex-1"
277
+ {highlightLine}
268
278
  />
269
279
  {/key}
270
280
  </div>
@@ -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
+ }
@@ -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
+ });
@@ -22,6 +22,8 @@ export interface RunStatusConfig {
22
22
  border: string;
23
23
  dot: string;
24
24
  badge: string;
25
+ /** One-sentence explanation of what this status means, for tooltips. */
26
+ description: string;
25
27
  }
26
28
 
27
29
  export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
@@ -32,6 +34,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
32
34
  border: "border-info-soft",
33
35
  dot: "bg-info-surface animate-pulse",
34
36
  badge: "bg-info-soft text-info-soft-text",
37
+ description: "This run is executing right now.",
35
38
  },
36
39
  success: {
37
40
  icon: CircleCheck,
@@ -40,6 +43,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
40
43
  border: "border-success-soft",
41
44
  dot: "bg-success-surface",
42
45
  badge: "bg-success-soft text-success-soft-text",
46
+ description: "The run finished with exit code 0.",
43
47
  },
44
48
  failed: {
45
49
  icon: CircleX,
@@ -48,6 +52,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
48
52
  border: "border-danger-soft",
49
53
  dot: "bg-danger-surface",
50
54
  badge: "bg-danger-soft text-danger-soft-text",
55
+ description: "The run exited with a non-zero code.",
51
56
  },
52
57
  crashed: {
53
58
  icon: CircleAlert,
@@ -56,6 +61,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
56
61
  border: "border-danger-soft",
57
62
  dot: "bg-danger-surface",
58
63
  badge: "bg-danger-soft text-danger-soft-text",
64
+ description:
65
+ "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
66
  },
60
67
  stopped: {
61
68
  icon: CircleStop,
@@ -64,6 +71,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
64
71
  border: "border-warning-soft",
65
72
  dot: "bg-warning-surface",
66
73
  badge: "bg-warning-soft text-warning-soft-text",
74
+ description: "An operator stopped this run before it finished.",
67
75
  },
68
76
  timeout: {
69
77
  icon: TimerOff,
@@ -72,6 +80,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
72
80
  border: "border-warning-soft",
73
81
  dot: "bg-warning-surface",
74
82
  badge: "bg-warning-soft text-warning-soft-text",
83
+ description: "The run exceeded its configured timeout and was terminated.",
75
84
  },
76
85
  skipped: {
77
86
  icon: SkipForward,
@@ -80,6 +89,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
80
89
  border: "border-outline-faint",
81
90
  dot: "bg-on-surface-faint",
82
91
  badge: "bg-surface-sunken text-on-surface",
92
+ description:
93
+ 'Skipped by the concurrency policy (on_overlap = "skip") because a previous run was still going.',
83
94
  },
84
95
  log_overflow: {
85
96
  icon: FileExclamationPoint,
@@ -88,6 +99,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
88
99
  border: "border-danger-soft",
89
100
  dot: "bg-danger-surface",
90
101
  badge: "bg-danger-soft text-danger-soft-text",
102
+ description: "The run hit log_max_size and was handled per log_on_full.",
91
103
  },
92
104
  queue_full: {
93
105
  icon: SkipForward,
@@ -96,6 +108,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
96
108
  border: "border-warning-soft",
97
109
  dot: "bg-warning-surface",
98
110
  badge: "bg-warning-soft text-warning-soft-text",
111
+ description: "Skipped because the task's queue was already at queue_max.",
99
112
  },
100
113
  dst_skipped: {
101
114
  icon: SkipForward,
@@ -104,6 +117,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
104
117
  border: "border-outline-faint",
105
118
  dot: "bg-on-surface-faint",
106
119
  badge: "bg-surface-sunken text-on-surface",
120
+ description:
121
+ "Skipped: this cron tick was the duplicate half of a DST fall-back, so it was recorded but not run.",
107
122
  },
108
123
  daemon_stopped: {
109
124
  icon: CircleStop,
@@ -112,6 +127,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
112
127
  border: "border-warning-soft",
113
128
  dot: "bg-warning-surface",
114
129
  badge: "bg-warning-soft text-warning-soft-text",
130
+ description:
131
+ "The daemon shut down while this run was in flight and it exceeded shutdown_timeout; it was not resumed.",
115
132
  },
116
133
  pending: {
117
134
  icon: Clock,
@@ -120,6 +137,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
120
137
  border: "border-outline",
121
138
  dot: "bg-on-surface-faint",
122
139
  badge: "bg-surface-sunken text-on-surface",
140
+ description: "Queued and waiting to start.",
123
141
  },
124
142
  ended: {
125
143
  icon: CircleDashed,
@@ -128,6 +146,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
128
146
  border: "border-outline-faint",
129
147
  dot: "bg-on-surface-faint",
130
148
  badge: "bg-surface-sunken text-on-surface",
149
+ description: "The run finished. No specific end reason was recorded.",
131
150
  },
132
151
  };
133
152
 
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
 
@@ -95,6 +91,7 @@ export {
95
91
  type RunStatusConfig,
96
92
  } from "./components/dashboard/status-config.js";
97
93
  export { runDuration, 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
+ }