@runwisp/ui 0.4.0 → 0.6.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.
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { runDuration } from "./run-helpers.js";
6
+
7
+ describe("runDuration", () => {
8
+ it("returns undefined when start_at is not set", () => {
9
+ expect(runDuration({})).toBeUndefined();
10
+ });
11
+
12
+ it("returns formatted duration when both start_at and end_at are set", () => {
13
+ const start = "2024-06-15T12:00:00.000Z";
14
+ const end = "2024-06-15T12:00:05.000Z";
15
+ expect(runDuration({ start_at: start, end_at: end })).toBe("5s");
16
+ });
17
+
18
+ it("returns ms duration for sub-second runs", () => {
19
+ const start = "2024-06-15T12:00:00.000Z";
20
+ const end = "2024-06-15T12:00:00.500Z";
21
+ expect(runDuration({ start_at: start, end_at: end })).toBe("500ms");
22
+ });
23
+
24
+ describe("with fake clock", () => {
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ vi.setSystemTime(new Date("2024-06-15T12:00:02.000Z"));
28
+ });
29
+ afterEach(() => {
30
+ vi.useRealTimers();
31
+ });
32
+
33
+ it("uses current time when end_at is not set (run still in progress)", () => {
34
+ expect(runDuration({ start_at: "2024-06-15T12:00:00.000Z" })).toBe("2s");
35
+ });
36
+ });
37
+ });
@@ -10,3 +10,14 @@ export function runDuration(run: Pick<Run, "start_at" | "end_at">): string | und
10
10
  const end = run.end_at ? new Date(run.end_at).getTime() : Date.now();
11
11
  return formatDuration(end - start);
12
12
  }
13
+
14
+ /**
15
+ * Monotonic position of a run's status in its lifecycle. Used to reject stale
16
+ * updates (e.g. a `pending` HTTP response arriving after an SSE already
17
+ * advanced the row to `success`). Higher = further along.
18
+ */
19
+ export function runPhaseOrder(status: string): number {
20
+ if (status === "pending") return 0;
21
+ if (status === "running") return 1;
22
+ return 2;
23
+ }
@@ -13,8 +13,7 @@ import {
13
13
  FileExclamationPoint,
14
14
  } from "@lucide/svelte";
15
15
  import type { Component } from "svelte";
16
- import { displayStatus, type RunStatus } from "@runwisp/common";
17
- import type { Run } from "@runwisp/common";
16
+ import { displayStatus, type RunStatus, type Run } from "@runwisp/common";
18
17
 
19
18
  export interface RunStatusConfig {
20
19
  icon: Component;
package/src/lib/index.ts CHANGED
@@ -1,27 +1,22 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- // Theme
5
4
  export { default as theme } from "./theme.css?inline";
6
5
 
7
- // Brand
8
6
  export { default as Logo } from "./components/Logo.svelte";
9
7
 
10
- // Layout
11
8
  export { default as PageContainer } from "./components/PageContainer.svelte";
9
+ export { default as PageHeader } from "./components/PageHeader.svelte";
12
10
 
13
- // Typography
14
11
  export { default as Heading } from "./components/Heading.svelte";
15
12
  export { default as Prose } from "./components/Prose.svelte";
16
13
  export { default as Text } from "./components/Text.svelte";
17
14
 
18
- // Inline
19
15
  export { default as Kbd } from "./components/Kbd.svelte";
20
16
  export { default as Link } from "./components/Link.svelte";
21
17
  export { default as Separator } from "./components/Separator.svelte";
22
18
  export { default as Spinner } from "./components/Spinner.svelte";
23
19
 
24
- // Form
25
20
  export { default as Button } from "./components/Button.svelte";
26
21
  export { default as LinkButton } from "./components/LinkButton.svelte";
27
22
  export {
@@ -43,7 +38,6 @@ export { default as Textarea } from "./components/Textarea.svelte";
43
38
  export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
44
39
  export { default as Toggle } from "./components/Toggle.svelte";
45
40
 
46
- // Navigation
47
41
  export { default as Accordion } from "./components/Accordion.svelte";
48
42
  export { default as AccordionItem } from "./components/AccordionItem.svelte";
49
43
  export { default as Breadcrumb } from "./components/Breadcrumb.svelte";
@@ -51,7 +45,6 @@ export { default as Pagination } from "./components/Pagination.svelte";
51
45
  export { default as Tabs } from "./components/Tabs.svelte";
52
46
  export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
53
47
 
54
- // Data display
55
48
  export { default as Avatar } from "./components/Avatar.svelte";
56
49
  export { default as Badge } from "./components/Badge.svelte";
57
50
  export { default as Card } from "./components/Card.svelte";
@@ -66,7 +59,6 @@ export { default as TableCell } from "./components/TableCell.svelte";
66
59
  export { default as TableHead } from "./components/TableHead.svelte";
67
60
  export { default as TableRow } from "./components/TableRow.svelte";
68
61
 
69
- // Feedback / overlay
70
62
  export { default as Alert } from "./components/Alert.svelte";
71
63
  export { default as AlertDialog } from "./components/AlertDialog.svelte";
72
64
  export { default as Drawer } from "./components/Drawer.svelte";
@@ -78,37 +70,33 @@ export { default as Popover } from "./components/Popover.svelte";
78
70
  export { default as ToastContainer } from "./components/ToastContainer.svelte";
79
71
  export { default as Tooltip } from "./components/Tooltip.svelte";
80
72
 
81
- // Generic
82
73
  export { default as CodeBlock } from "./components/CodeBlock.svelte";
83
74
  export { default as FeatureCard } from "./components/FeatureCard.svelte";
84
75
  export { default as FeatureGrid } from "./components/FeatureGrid.svelte";
76
+ export { default as TaskCard } from "./components/TaskCard.svelte";
77
+ export type { TaskCardAccent } from "./components/TaskCard.svelte";
85
78
 
86
- // Log console
87
79
  export { default as LogConsole } from "./components/LogConsole.svelte";
88
- export type {
89
- FetchLogsFn,
90
- LineNumber,
91
- LogEvent,
92
- LogMessage,
93
- LogSlice,
94
- } from "./log-console/types.js";
80
+ export type { FetchLogsFn, LogEvent, LogSlice } from "./log-console/types.js";
95
81
  export { isLogEvent } from "./log-console/types.js";
96
82
  export { LogCache } from "./log-console/LogCache.svelte.js";
97
83
  export { LogFetcher } from "./log-console/LogFetcher.svelte.js";
98
84
 
99
- // Dashboard
100
85
  export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
101
86
  export { default as RunsList } from "./components/dashboard/RunsList.svelte";
87
+ export type {
88
+ RunsListFilters,
89
+ RunsListSortDirection,
90
+ } from "./components/dashboard/RunsList.svelte";
102
91
  export {
103
92
  getRunStatusConfig,
104
93
  runDisplayStatus,
105
94
  RUN_STATUS_CONFIG,
106
95
  type RunStatusConfig,
107
96
  } from "./components/dashboard/status-config.js";
108
- export { runDuration } from "./components/dashboard/run-helpers.js";
97
+ export { runDuration, runPhaseOrder } from "./components/dashboard/run-helpers.js";
109
98
  export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
110
99
 
111
- // Utilities
112
100
  export { toast, type Toast, type ToastType } from "./utils/toast.svelte.js";
113
101
  export { extractErrorMessage } from "./utils/error.js";
114
102
  export {
@@ -19,7 +19,7 @@ const logger = createLogger("LogFetcher");
19
19
  * - Throttles and prioritizes requests to prevent viewport jitter.
20
20
  */
21
21
  export class LogFetcher {
22
- private cache: LogCache;
22
+ private readonly cache: LogCache;
23
23
  private fetchLogsFn: FetchLogsFn | undefined;
24
24
  private chunkSize: number;
25
25
 
@@ -28,12 +28,12 @@ export class LogFetcher {
28
28
 
29
29
  isFetching = $state(false);
30
30
 
31
- private pendingChunks = new SvelteSet<number>();
32
- private inFlightChunks = new SvelteSet<number>();
31
+ private readonly pendingChunks = new SvelteSet<number>();
32
+ private readonly inFlightChunks = new SvelteSet<number>();
33
33
  private fetchTimer: ReturnType<typeof setTimeout> | null = null;
34
34
  private lastRequestAt = 0;
35
35
 
36
- private onDataLoaded?: ((min: number, max: number) => void) | undefined;
36
+ private readonly onDataLoaded: ((min: number, max: number) => void) | undefined;
37
37
 
38
38
  constructor(
39
39
  cache: LogCache,
@@ -1,10 +1,7 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- export type LineNumber = number;
5
- export type LogMessage = string;
6
-
7
- export type LogSlice = Record<LineNumber, LogMessage>;
4
+ export type LogSlice = Record<number, string>;
8
5
 
9
6
  export type LogEvent = {
10
7
  lines: LogSlice;
@@ -87,10 +87,8 @@
87
87
  --rw-color-warning-500: oklch(0.75 0.16 65);
88
88
  --rw-color-warning-600: oklch(0.65 0.14 60);
89
89
  --rw-color-warning-700: oklch(0.55 0.12 55);
90
- }
91
90
 
92
- /* Light-mode semantic tokens (default) */
93
- :root {
91
+ /* Light-mode semantic tokens (default) */
94
92
  /* Surfaces */
95
93
  --rw-surface: var(--rw-color-mist-50);
96
94
  --rw-surface-raised: white;
@@ -0,0 +1,88 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { formatBytes, formatDuration, formatRelativeTimeWithAbsolute } from "./format.js";
6
+
7
+ describe("formatBytes", () => {
8
+ it("formats 0 bytes", () => {
9
+ expect(formatBytes(0)).toBe("0 B");
10
+ });
11
+ it("formats bytes under 1 KB", () => {
12
+ expect(formatBytes(500)).toBe("500 B");
13
+ });
14
+ it("formats exactly 1 KB", () => {
15
+ expect(formatBytes(1024)).toBe("1 KB");
16
+ });
17
+ it("formats fractional KB when value < 10 (one decimal)", () => {
18
+ expect(formatBytes(1024 * 1.5)).toBe("1.5 KB");
19
+ });
20
+ it("formats KB >= 10 without decimal", () => {
21
+ expect(formatBytes(1024 * 10)).toBe("10 KB");
22
+ });
23
+ it("formats MB", () => {
24
+ expect(formatBytes(1024 ** 2)).toBe("1 MB");
25
+ });
26
+ it("formats GB", () => {
27
+ expect(formatBytes(1024 ** 3)).toBe("1 GB");
28
+ });
29
+ it("formats TB", () => {
30
+ expect(formatBytes(1024 ** 4)).toBe("1 TB");
31
+ });
32
+ it("formats PB (largest unit)", () => {
33
+ expect(formatBytes(1024 ** 5)).toBe("1 PB");
34
+ });
35
+ });
36
+
37
+ describe("formatDuration", () => {
38
+ it("returns ms for 0", () => {
39
+ expect(formatDuration(0)).toBe("0ms");
40
+ });
41
+ it("returns ms for < 1 second", () => {
42
+ expect(formatDuration(999)).toBe("999ms");
43
+ });
44
+ it("returns seconds for exactly 1s", () => {
45
+ expect(formatDuration(1000)).toBe("1s");
46
+ });
47
+ it("returns seconds for 59s", () => {
48
+ expect(formatDuration(59_000)).toBe("59s");
49
+ });
50
+ it("returns minutes for exactly 1m (no remaining seconds)", () => {
51
+ expect(formatDuration(60_000)).toBe("1m");
52
+ });
53
+ it("returns minutes + seconds when remainder > 0", () => {
54
+ expect(formatDuration(90_000)).toBe("1m 30s");
55
+ });
56
+ it("returns exactly 59m", () => {
57
+ expect(formatDuration(59 * 60_000)).toBe("59m");
58
+ });
59
+ it("returns hours for exactly 1h (no remaining minutes)", () => {
60
+ expect(formatDuration(3_600_000)).toBe("1h");
61
+ });
62
+ it("returns hours + minutes when remainder > 0", () => {
63
+ expect(formatDuration(5_400_000)).toBe("1h 30m");
64
+ });
65
+ it("returns hours only when remM = 0", () => {
66
+ expect(formatDuration(2 * 3_600_000)).toBe("2h");
67
+ });
68
+ });
69
+
70
+ describe("formatRelativeTimeWithAbsolute", () => {
71
+ it("uses time-only format (HH:MM) for dates within the same day", () => {
72
+ const recent = new Date(Date.now() - 30 * 60_000).toISOString();
73
+ const r = formatRelativeTimeWithAbsolute(recent);
74
+ // Same-day branch produces `relative (HH:MM)` — assert on the colon
75
+ // that only appears inside the time format.
76
+ expect(r).toMatch(/\(\d{1,2}:\d{2}\)$/);
77
+ });
78
+ it("uses month-day format for dates more than 1 day ago", () => {
79
+ const old = new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString();
80
+ const r = formatRelativeTimeWithAbsolute(old);
81
+ // Older-than-day branch produces a parenthesised calendar-date suffix.
82
+ // Locales render { month: "short", day: "numeric" } very differently
83
+ // ("May 15" / "15 May" / "16. 5." / "5月15日"), so the locale-invariant
84
+ // contract we assert is: parens contain a day digit and no time colon.
85
+ expect(r).toMatch(/\([^)]*\d[^)]*\)$/);
86
+ expect(r).not.toMatch(/\(\d{1,2}:\d{2}\)$/);
87
+ });
88
+ });
@@ -5,20 +5,32 @@ import { generateUlid } from "@runwisp/common";
5
5
 
6
6
  export type ToastType = "success" | "error" | "warning" | "info";
7
7
 
8
+ export interface ToastAction {
9
+ label: string;
10
+ onClick: () => void;
11
+ }
12
+
8
13
  export interface Toast {
9
14
  id: string;
10
15
  type: ToastType;
11
16
  message: string;
12
17
  duration?: number;
18
+ action?: ToastAction;
19
+ }
20
+
21
+ export interface ToastOptions {
22
+ duration?: number;
23
+ action?: ToastAction;
13
24
  }
14
25
 
15
26
  const DEFAULT_DURATION = 5000;
16
27
 
17
28
  class ToastStore {
18
29
  items = $state<Toast[]>([]);
19
- private timers = new Map<string, ReturnType<typeof setTimeout>>();
30
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
20
31
 
21
- add(type: ToastType, message: string, duration = DEFAULT_DURATION): string {
32
+ add(type: ToastType, message: string, opts: ToastOptions = {}): string {
33
+ const duration = opts.duration ?? DEFAULT_DURATION;
22
34
  const existing = this.items.find((t) => t.type === type && t.message === message);
23
35
  if (existing) {
24
36
  this.refreshTimer(existing.id, duration);
@@ -26,7 +38,9 @@ class ToastStore {
26
38
  }
27
39
 
28
40
  const id = generateUlid();
29
- this.items = [...this.items, { id, type, message, duration }];
41
+ const next: Toast = { id, type, message, duration };
42
+ if (opts.action) next.action = opts.action;
43
+ this.items = [...this.items, next];
30
44
  if (duration > 0) {
31
45
  this.scheduleRemoval(id, duration);
32
46
  }
@@ -59,20 +73,20 @@ class ToastStore {
59
73
  this.items = this.items.filter((t) => t.id !== id);
60
74
  }
61
75
 
62
- success(message: string, duration?: number): string {
63
- return this.add("success", message, duration);
76
+ success(message: string, opts?: ToastOptions): string {
77
+ return this.add("success", message, opts);
64
78
  }
65
79
 
66
- error(message: string, duration?: number): string {
67
- return this.add("error", message, duration);
80
+ error(message: string, opts?: ToastOptions): string {
81
+ return this.add("error", message, opts);
68
82
  }
69
83
 
70
- warning(message: string, duration?: number): string {
71
- return this.add("warning", message, duration);
84
+ warning(message: string, opts?: ToastOptions): string {
85
+ return this.add("warning", message, opts);
72
86
  }
73
87
 
74
- info(message: string, duration?: number): string {
75
- return this.add("info", message, duration);
88
+ info(message: string, opts?: ToastOptions): string {
89
+ return this.add("info", message, opts);
76
90
  }
77
91
 
78
92
  clear() {