@runwisp/ui 0.4.0 → 0.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.
- package/package.json +8 -16
- package/src/app.html +1 -0
- package/src/lib/actions/portal.ts +1 -3
- package/src/lib/components/AccordionItem.svelte +2 -4
- package/src/lib/components/Badge.svelte +4 -8
- package/src/lib/components/Card.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +2 -3
- package/src/lib/components/Dropdown.svelte +3 -3
- package/src/lib/components/DurationInput.svelte +2 -2
- package/src/lib/components/Heading.svelte +1 -2
- package/src/lib/components/LogConsole.svelte +62 -7
- package/src/lib/components/PageHeader.svelte +29 -0
- package/src/lib/components/Pagination.svelte +2 -2
- package/src/lib/components/Popover.svelte +3 -3
- package/src/lib/components/Select.svelte +6 -10
- package/src/lib/components/TaskCard.svelte +33 -0
- package/src/lib/components/ToastContainer.svelte +12 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +39 -9
- package/src/lib/components/dashboard/RunsList.svelte +412 -243
- package/src/lib/components/dashboard/run-helpers.test.ts +37 -0
- package/src/lib/components/dashboard/run-helpers.ts +11 -0
- package/src/lib/components/dashboard/status-config.ts +1 -2
- package/src/lib/index.ts +9 -21
- package/src/lib/log-console/LogFetcher.svelte.ts +4 -4
- package/src/lib/log-console/types.ts +1 -4
- package/src/lib/theme-tokens.css +1 -3
- package/src/lib/utils/format.test.ts +88 -0
- package/src/lib/utils/toast.svelte.ts +25 -11
|
@@ -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
|
|
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
|
|
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;
|
package/src/lib/theme-tokens.css
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
63
|
-
return this.add("success", message,
|
|
76
|
+
success(message: string, opts?: ToastOptions): string {
|
|
77
|
+
return this.add("success", message, opts);
|
|
64
78
|
}
|
|
65
79
|
|
|
66
|
-
error(message: string,
|
|
67
|
-
return this.add("error", message,
|
|
80
|
+
error(message: string, opts?: ToastOptions): string {
|
|
81
|
+
return this.add("error", message, opts);
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
warning(message: string,
|
|
71
|
-
return this.add("warning", message,
|
|
84
|
+
warning(message: string, opts?: ToastOptions): string {
|
|
85
|
+
return this.add("warning", message, opts);
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
info(message: string,
|
|
75
|
-
return this.add("info", message,
|
|
88
|
+
info(message: string, opts?: ToastOptions): string {
|
|
89
|
+
return this.add("info", message, opts);
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
clear() {
|