@runwisp/ui 1.4.0 → 1.5.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 +4 -5
- package/src/lib/actions/floating.ts +73 -0
- package/src/lib/actions/portal.ts +5 -1
- package/src/lib/components/Accordion.svelte +6 -2
- package/src/lib/components/AccordionItem.svelte +80 -23
- package/src/lib/components/Avatar.svelte +0 -1
- package/src/lib/components/CodeBlock.svelte +84 -35
- package/src/lib/components/CronInput.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +95 -133
- package/src/lib/components/Drawer.svelte +2 -6
- package/src/lib/components/Dropdown.svelte +19 -57
- package/src/lib/components/EmptyState.svelte +2 -1
- package/src/lib/components/ErrorState.svelte +2 -1
- package/src/lib/components/Heading.svelte +19 -12
- package/src/lib/components/Kbd.svelte +25 -10
- package/src/lib/components/Link.svelte +7 -1
- package/src/lib/components/LogConsole.svelte +30 -32
- package/src/lib/components/Modal.svelte +44 -33
- package/src/lib/components/NotificationBell.svelte +2 -1
- package/src/lib/components/PageContainer.svelte +1 -10
- package/src/lib/components/PageHeader.svelte +8 -4
- package/src/lib/components/Popover.svelte +9 -51
- package/src/lib/components/Progress.svelte +19 -6
- package/src/lib/components/Radio.svelte +16 -4
- package/src/lib/components/RadioGroup.svelte +15 -2
- package/src/lib/components/Select.svelte +13 -45
- package/src/lib/components/Table.svelte +13 -4
- package/src/lib/components/TableBody.svelte +2 -1
- package/src/lib/components/TableCell.svelte +38 -8
- package/src/lib/components/TableHead.svelte +12 -4
- package/src/lib/components/TableRow.svelte +23 -3
- package/src/lib/components/TaskCard.svelte +1 -1
- package/src/lib/components/TimezoneSelect.svelte +2 -2
- package/src/lib/components/accordion.svelte.ts +19 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +91 -63
- package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
- package/src/lib/components/dashboard/RunsList.svelte +9 -18
- package/src/lib/components/dashboard/cron-format.ts +1 -1
- package/src/lib/components/dashboard/run-filters.test.ts +2 -58
- package/src/lib/components/dashboard/run-filters.ts +5 -53
- package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
- package/src/lib/components/dashboard/run-helpers.ts +12 -1
- package/src/lib/components/dashboard/status-config.ts +38 -83
- package/src/lib/components/dashboard/types.ts +0 -26
- package/src/lib/components/radio-group.ts +11 -0
- package/src/lib/index.ts +8 -43
- package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
- package/src/lib/log-console/highlight-scroll.test.ts +59 -0
- package/src/lib/log-console/highlight-scroll.ts +41 -0
- package/src/lib/theme.css +10 -4
- package/src/lib/utils/clipboard.svelte.test.ts +92 -0
- package/src/lib/utils/clipboard.svelte.ts +73 -0
- package/src/lib/utils/format.ts +65 -94
- package/src/lib/utils/id.test.ts +3 -7
- package/src/lib/utils/id.ts +3 -2
- package/src/lib/utils/theme.svelte.test.ts +0 -6
- package/src/lib/utils/theme.svelte.ts +3 -5
- package/src/lib/utils/toast.svelte.ts +3 -11
- package/src/stories/Accordion.stories.svelte +61 -0
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +0 -5
- package/src/stories/Heading.stories.svelte +31 -0
- package/src/stories/Kbd.stories.svelte +36 -0
- package/src/stories/Link.stories.svelte +38 -0
- package/src/stories/PageHeader.stories.svelte +31 -0
- package/src/stories/Progress.stories.svelte +26 -0
- package/src/stories/RadioGroup.stories.svelte +43 -0
- package/src/stories/Table.stories.svelte +69 -0
- package/src/svelte-shims.d.ts +0 -5
- package/src/lib/components/dashboard/status-config.test.ts +0 -18
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { untrack } from "svelte";
|
|
5
|
+
|
|
6
|
+
export interface HighlightScrollOptions {
|
|
7
|
+
/** Reactive getter for the line to reveal; null/undefined means none. */
|
|
8
|
+
line: () => number | null | undefined;
|
|
9
|
+
/** Reactive getter: true once the layout can place `line` (container measured, line exists). */
|
|
10
|
+
ready: (line: number) => boolean;
|
|
11
|
+
/** Scrolls to `line`. Runs untracked, so layout reads here never re-trigger the sync. */
|
|
12
|
+
reveal: (line: number) => void;
|
|
13
|
+
/** Called when the highlight is cleared. */
|
|
14
|
+
clear: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns a function to call from inside an `$effect`. It reveals each
|
|
19
|
+
* highlighted line exactly once: after the first reveal, layout changes (new
|
|
20
|
+
* log lines, resizes) no longer re-run the scroll, so the operator can scroll
|
|
21
|
+
* away from a deep-linked search hit and stay there. It waits until `ready`,
|
|
22
|
+
* and a changed or re-set `line` reveals again.
|
|
23
|
+
*/
|
|
24
|
+
export function createHighlightScroll(options: HighlightScrollOptions): () => void {
|
|
25
|
+
let revealed: number | null = null;
|
|
26
|
+
return () => {
|
|
27
|
+
const target = options.line();
|
|
28
|
+
// Not `!target`: line 0 is a valid highlight.
|
|
29
|
+
if (typeof target !== "number") {
|
|
30
|
+
revealed = null;
|
|
31
|
+
options.clear();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Checked before `ready` so a revealed line leaves no layout dependency behind.
|
|
35
|
+
if (revealed === target || !options.ready(target)) return;
|
|
36
|
+
revealed = target;
|
|
37
|
+
untrack(() => {
|
|
38
|
+
options.reveal(target);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/lib/theme.css
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
@plugin "@tailwindcss/typography";
|
|
20
20
|
|
|
21
21
|
@theme {
|
|
22
|
+
/* Wide enough for the sidebar, run list and run detail to stay side by
|
|
23
|
+
* side for good; below it the operator can fold the first two away. */
|
|
24
|
+
--breakpoint-3xl: 100rem;
|
|
25
|
+
|
|
22
26
|
/* Palette aliases — Tailwind utilities (bg-wisp-500, text-mist-900, …)
|
|
23
27
|
* resolve through to --rw-color-* declared in theme-tokens.css. */
|
|
24
28
|
--color-wisp-50: var(--rw-color-wisp-50);
|
|
@@ -251,10 +255,12 @@ body {
|
|
|
251
255
|
color: var(--rw-on-surface);
|
|
252
256
|
}
|
|
253
257
|
|
|
254
|
-
/* Headings are mono chrome — the runbook voice.
|
|
255
|
-
h1
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
/* Headings are mono chrome — the runbook voice. Unlayered, so it beats the
|
|
259
|
+
weight/leading/tracking utilities on a raw <h1>-<h3>: every heading reads the
|
|
260
|
+
same. The Heading component opts out (data-rw-heading) and carries this look
|
|
261
|
+
in its own classes, so its props are the source of truth. :is()/:where()
|
|
262
|
+
keep the selector at element specificity, so app stylesheets still win. */
|
|
263
|
+
:is(h1, h2, h3):where(:not([data-rw-heading])) {
|
|
258
264
|
font-family: var(--font-mono);
|
|
259
265
|
font-weight: 800;
|
|
260
266
|
line-height: 1.1;
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { CopyFeedback, copyText } from "./clipboard.svelte.js";
|
|
6
|
+
|
|
7
|
+
describe("copyText", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("writes through navigator.clipboard", async () => {
|
|
13
|
+
const writeText = vi.fn(() => Promise.resolve());
|
|
14
|
+
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
|
15
|
+
expect(await copyText("runwisp")).toBe(true);
|
|
16
|
+
expect(writeText).toHaveBeenCalledWith("runwisp");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to execCommand when the clipboard API is missing", async () => {
|
|
20
|
+
vi.stubGlobal("navigator", {});
|
|
21
|
+
const textarea = {
|
|
22
|
+
value: "",
|
|
23
|
+
readOnly: false,
|
|
24
|
+
style: {},
|
|
25
|
+
select: vi.fn(),
|
|
26
|
+
remove: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
const execCommand = vi.fn(() => true);
|
|
29
|
+
class FakeElement {
|
|
30
|
+
focus = vi.fn();
|
|
31
|
+
}
|
|
32
|
+
const focused = new FakeElement();
|
|
33
|
+
vi.stubGlobal("HTMLElement", FakeElement);
|
|
34
|
+
vi.stubGlobal("document", {
|
|
35
|
+
activeElement: focused,
|
|
36
|
+
createElement: () => textarea,
|
|
37
|
+
body: { appendChild: vi.fn() },
|
|
38
|
+
execCommand,
|
|
39
|
+
});
|
|
40
|
+
expect(await copyText("runwisp")).toBe(true);
|
|
41
|
+
expect(textarea.value).toBe("runwisp");
|
|
42
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
43
|
+
expect(textarea.remove).toHaveBeenCalled();
|
|
44
|
+
expect(focused.focus).toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reports failure when no copy path exists", async () => {
|
|
48
|
+
vi.stubGlobal("navigator", {});
|
|
49
|
+
vi.stubGlobal("document", undefined);
|
|
50
|
+
expect(await copyText("runwisp")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("CopyFeedback", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
vi.stubGlobal("navigator", { clipboard: { writeText: () => Promise.resolve() } });
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
vi.unstubAllGlobals();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("flags the copied key until the reset delay passes", async () => {
|
|
65
|
+
const feedback = new CopyFeedback(1000);
|
|
66
|
+
await feedback.copy("docker run", "docker");
|
|
67
|
+
expect(feedback.copied).toBe(true);
|
|
68
|
+
expect(feedback.isCopied("docker")).toBe(true);
|
|
69
|
+
expect(feedback.isCopied("binary")).toBe(false);
|
|
70
|
+
vi.advanceTimersByTime(1000);
|
|
71
|
+
expect(feedback.copied).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("restarts the reset delay on a second copy", async () => {
|
|
75
|
+
const feedback = new CopyFeedback(1000);
|
|
76
|
+
await feedback.copy("a");
|
|
77
|
+
vi.advanceTimersByTime(800);
|
|
78
|
+
await feedback.copy("b");
|
|
79
|
+
vi.advanceTimersByTime(800);
|
|
80
|
+
expect(feedback.copied).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stays idle when the copy fails", async () => {
|
|
84
|
+
vi.stubGlobal("navigator", {
|
|
85
|
+
clipboard: { writeText: () => Promise.reject(new Error("denied")) },
|
|
86
|
+
});
|
|
87
|
+
vi.stubGlobal("document", undefined);
|
|
88
|
+
const feedback = new CopyFeedback();
|
|
89
|
+
expect(await feedback.copy("a")).toBe(false);
|
|
90
|
+
expect(feedback.copied).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const DEFAULT_RESET_MS = 2000;
|
|
5
|
+
|
|
6
|
+
// Copies text to the clipboard and reports whether it worked. navigator.clipboard
|
|
7
|
+
// only exists in secure contexts, so a daemon served over plain http on a LAN IP
|
|
8
|
+
// falls back to the legacy textarea + execCommand path.
|
|
9
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
10
|
+
if (typeof navigator !== "undefined" && "clipboard" in navigator) {
|
|
11
|
+
try {
|
|
12
|
+
await navigator.clipboard.writeText(text);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
// Denied or blocked: try the legacy path below.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return legacyCopy(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function legacyCopy(text: string): boolean {
|
|
22
|
+
if (typeof document === "undefined") return false;
|
|
23
|
+
const previousFocus = document.activeElement;
|
|
24
|
+
const ta = document.createElement("textarea");
|
|
25
|
+
ta.value = text;
|
|
26
|
+
ta.readOnly = true;
|
|
27
|
+
ta.style.position = "fixed";
|
|
28
|
+
ta.style.opacity = "0";
|
|
29
|
+
document.body.appendChild(ta);
|
|
30
|
+
ta.select();
|
|
31
|
+
let ok = false;
|
|
32
|
+
try {
|
|
33
|
+
// Deprecated, but still the only copy path outside a secure context.
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
35
|
+
ok = document.execCommand("copy"); // NOSONAR
|
|
36
|
+
} catch {
|
|
37
|
+
ok = false;
|
|
38
|
+
}
|
|
39
|
+
ta.remove();
|
|
40
|
+
if (previousFocus instanceof HTMLElement) previousFocus.focus();
|
|
41
|
+
return ok;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// CopyFeedback drives a copy button's transient "copied" state. `key` tells
|
|
45
|
+
// several buttons sharing one instance apart (one per tab or snippet).
|
|
46
|
+
export class CopyFeedback {
|
|
47
|
+
#key = $state<string | null>(null);
|
|
48
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
readonly #resetMs: number;
|
|
50
|
+
|
|
51
|
+
constructor(resetMs: number = DEFAULT_RESET_MS) {
|
|
52
|
+
this.#resetMs = resetMs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get copied(): boolean {
|
|
56
|
+
return this.#key !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isCopied(key: string): boolean {
|
|
60
|
+
return this.#key === key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async copy(text: string, key: string = ""): Promise<boolean> {
|
|
64
|
+
const ok = await copyText(text);
|
|
65
|
+
if (!ok) return false;
|
|
66
|
+
clearTimeout(this.#timer);
|
|
67
|
+
this.#key = key;
|
|
68
|
+
this.#timer = setTimeout(() => {
|
|
69
|
+
this.#key = null;
|
|
70
|
+
}, this.#resetMs);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/lib/utils/format.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
// Signed magnitude thresholds for Intl.RelativeTimeFormat, largest-first
|
|
5
|
-
// division.
|
|
5
|
+
// division. The buckets match date-fns' formatDistance.
|
|
6
6
|
const RELATIVE_DIVISIONS: [amount: number, unit: Intl.RelativeTimeFormatUnit][] = [
|
|
7
7
|
[60, "seconds"],
|
|
8
8
|
[60, "minutes"],
|
|
@@ -13,105 +13,88 @@ const RELATIVE_DIVISIONS: [amount: number, unit: Intl.RelativeTimeFormatUnit][]
|
|
|
13
13
|
[Number.POSITIVE_INFINITY, "years"],
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
let delta = (date.getTime() - base.getTime()) / 1000;
|
|
16
|
+
const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: "always" });
|
|
17
|
+
|
|
18
|
+
/** "N units ago" / "in N units" for `date` relative to `now`. */
|
|
19
|
+
export function formatRelativeTime(date: string | Date, now: Date = new Date()): string {
|
|
20
|
+
let delta = (new Date(date).getTime() - now.getTime()) / 1000;
|
|
22
21
|
for (const [amount, unit] of RELATIVE_DIVISIONS) {
|
|
23
22
|
if (Math.abs(delta) < amount) {
|
|
24
|
-
return
|
|
23
|
+
return relativeTime.format(Math.round(delta), unit);
|
|
25
24
|
}
|
|
26
25
|
delta /= amount;
|
|
27
26
|
}
|
|
28
|
-
return
|
|
27
|
+
return relativeTime.format(Math.round(delta), "years");
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
36
|
-
value /= 1024;
|
|
37
|
-
unitIndex++;
|
|
38
|
-
}
|
|
39
|
-
const unit = units[unitIndex] ?? "B";
|
|
40
|
-
const digits = value < 10 && unitIndex > 0 ? 1 : 0;
|
|
41
|
-
return `${new Intl.NumberFormat("en", { maximumFractionDigits: digits }).format(value)} ${unit}`;
|
|
30
|
+
/** A locale date/time formatter for one fixed option set, built once. */
|
|
31
|
+
function dateFormatter(options: Intl.DateTimeFormatOptions): (date: string | Date) => string {
|
|
32
|
+
const format = new Intl.DateTimeFormat(undefined, options);
|
|
33
|
+
return (date) => format.format(new Date(date));
|
|
42
34
|
}
|
|
43
35
|
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
export const formatDateTime = dateFormatter({
|
|
37
|
+
year: "numeric",
|
|
38
|
+
month: "short",
|
|
39
|
+
day: "numeric",
|
|
40
|
+
hour: "numeric",
|
|
41
|
+
minute: "2-digit",
|
|
42
|
+
});
|
|
47
43
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
44
|
+
/** Wall-clock time of day, seconds included, 24-hour — e.g. "17:15:02". */
|
|
45
|
+
export const formatClockTime = dateFormatter({
|
|
46
|
+
hour: "2-digit",
|
|
47
|
+
minute: "2-digit",
|
|
48
|
+
second: "2-digit",
|
|
49
|
+
hour12: false,
|
|
50
|
+
});
|
|
56
51
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
});
|
|
64
|
-
} else {
|
|
65
|
-
absolute = date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
66
|
-
}
|
|
52
|
+
/** Calendar date without the time — e.g. "22 Jun 2026". */
|
|
53
|
+
export const formatCalendarDate = dateFormatter({
|
|
54
|
+
year: "numeric",
|
|
55
|
+
month: "short",
|
|
56
|
+
day: "numeric",
|
|
57
|
+
});
|
|
67
58
|
|
|
68
|
-
|
|
69
|
-
}
|
|
59
|
+
/** Time of day, no seconds, 24-hour — e.g. "17:15". */
|
|
60
|
+
export const formatTimeHM = dateFormatter({ hour: "2-digit", minute: "2-digit", hour12: false });
|
|
70
61
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
year: "numeric",
|
|
74
|
-
month: "short",
|
|
75
|
-
day: "numeric",
|
|
76
|
-
hour: "numeric",
|
|
77
|
-
minute: "2-digit",
|
|
78
|
-
});
|
|
79
|
-
}
|
|
62
|
+
/** Day and month, no year — e.g. "22 Jun". */
|
|
63
|
+
export const formatDayMonth = dateFormatter({ month: "short", day: "numeric" });
|
|
80
64
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
65
|
+
export const formatFullDateTime = dateFormatter({
|
|
66
|
+
year: "numeric",
|
|
67
|
+
month: "short",
|
|
68
|
+
day: "2-digit",
|
|
69
|
+
hour: "2-digit",
|
|
70
|
+
minute: "2-digit",
|
|
71
|
+
second: "2-digit",
|
|
72
|
+
hour12: false,
|
|
73
|
+
});
|
|
90
74
|
|
|
91
|
-
|
|
92
|
-
export function formatCalendarDate(dateStr: string): string {
|
|
93
|
-
return new Date(dateStr).toLocaleDateString(undefined, {
|
|
94
|
-
year: "numeric",
|
|
95
|
-
month: "short",
|
|
96
|
-
day: "numeric",
|
|
97
|
-
});
|
|
98
|
-
}
|
|
75
|
+
const formatShortTime = dateFormatter({ hour: "numeric", minute: "2-digit", hour12: false });
|
|
99
76
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
77
|
+
export function formatRelativeTimeWithAbsolute(
|
|
78
|
+
dateStr: string | Date,
|
|
79
|
+
now: Date = new Date(),
|
|
80
|
+
): string {
|
|
81
|
+
const date = new Date(dateStr);
|
|
82
|
+
const diffDays = Math.abs(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24);
|
|
83
|
+
const absolute = diffDays < 1 ? formatShortTime(date) : formatDayMonth(date);
|
|
84
|
+
return `${formatRelativeTime(date, now)} (${absolute})`;
|
|
107
85
|
}
|
|
108
86
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
87
|
+
export function formatBytes(bytes: number): string {
|
|
88
|
+
const units: [string, ...string[]] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
89
|
+
let value = bytes;
|
|
90
|
+
let unitIndex = 0;
|
|
91
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
92
|
+
value /= 1024;
|
|
93
|
+
unitIndex++;
|
|
94
|
+
}
|
|
95
|
+
const unit = units[unitIndex] ?? "B";
|
|
96
|
+
const digits = value < 10 && unitIndex > 0 ? 1 : 0;
|
|
97
|
+
return `${new Intl.NumberFormat("en", { maximumFractionDigits: digits }).format(value)} ${unit}`;
|
|
115
98
|
}
|
|
116
99
|
|
|
117
100
|
export function formatDuration(ms: number): string {
|
|
@@ -125,15 +108,3 @@ export function formatDuration(ms: number): string {
|
|
|
125
108
|
const remM = m % 60;
|
|
126
109
|
return remM > 0 ? String(h) + "h " + String(remM) + "m" : String(h) + "h";
|
|
127
110
|
}
|
|
128
|
-
|
|
129
|
-
export function formatFullDateTime(date: Date | string): string {
|
|
130
|
-
return new Intl.DateTimeFormat(undefined, {
|
|
131
|
-
year: "numeric",
|
|
132
|
-
month: "short",
|
|
133
|
-
day: "2-digit",
|
|
134
|
-
hour: "2-digit",
|
|
135
|
-
minute: "2-digit",
|
|
136
|
-
second: "2-digit",
|
|
137
|
-
hour12: false,
|
|
138
|
-
}).format(new Date(date));
|
|
139
|
-
}
|
package/src/lib/utils/id.test.ts
CHANGED
|
@@ -5,16 +5,12 @@ import { describe, expect, it } from "vitest";
|
|
|
5
5
|
import { formatShortId } from "./id.js";
|
|
6
6
|
|
|
7
7
|
describe("formatShortId", () => {
|
|
8
|
-
it("returns the last 8 characters
|
|
8
|
+
it("returns the last 8 characters", () => {
|
|
9
9
|
expect(formatShortId("01HVZ4XYZ123456ABCDEF")).toBe("56ABCDEF");
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
it("
|
|
13
|
-
expect(formatShortId("
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("returns the whole string when shorter than the requested length", () => {
|
|
17
|
-
expect(formatShortId("abc", 8)).toBe("abc");
|
|
12
|
+
it("returns the whole string when shorter than 8", () => {
|
|
13
|
+
expect(formatShortId("abc")).toBe("abc");
|
|
18
14
|
});
|
|
19
15
|
|
|
20
16
|
it("returns an empty string for an empty input", () => {
|
package/src/lib/utils/id.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/** The last 8 characters of a ULID, the part that differs between runs. */
|
|
5
|
+
export function formatShortId(id: string): string {
|
|
6
|
+
return id.slice(-8);
|
|
6
7
|
}
|
|
@@ -89,12 +89,6 @@ describe("themeStore", () => {
|
|
|
89
89
|
expect(themeStore.resolved).toBe("dark");
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it("falls back to the legacy storage key", async () => {
|
|
93
|
-
stubBrowser({ storage: { "runwisp-theme": "light" } });
|
|
94
|
-
const { themeStore } = await import("./theme.svelte.js");
|
|
95
|
-
expect(themeStore.preference).toBe("light");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
92
|
it("ignores an invalid stored value", async () => {
|
|
99
93
|
stubBrowser({ storage: { "runwisp:theme": "purple" } });
|
|
100
94
|
const { themeStore } = await import("./theme.svelte.js");
|
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
export type ThemePreference = "auto" | "light" | "dark";
|
|
5
|
-
|
|
5
|
+
type ResolvedTheme = "light" | "dark";
|
|
6
6
|
|
|
7
7
|
// Shared by every RunWisp surface. Keep byte-for-byte in sync with the
|
|
8
8
|
// no-flash <head> scripts in each app's app.html / layout.
|
|
9
|
-
|
|
10
|
-
// The daemon dashboard used this key before the store moved here.
|
|
11
|
-
const LEGACY_STORAGE_KEY = "runwisp-theme";
|
|
9
|
+
const THEME_STORAGE_KEY = "runwisp:theme";
|
|
12
10
|
|
|
13
11
|
function isPreference(value: string | null): value is ThemePreference {
|
|
14
12
|
return value === "auto" || value === "light" || value === "dark";
|
|
@@ -16,7 +14,7 @@ function isPreference(value: string | null): value is ThemePreference {
|
|
|
16
14
|
|
|
17
15
|
function readStored(): string | null {
|
|
18
16
|
try {
|
|
19
|
-
return localStorage.getItem(THEME_STORAGE_KEY)
|
|
17
|
+
return localStorage.getItem(THEME_STORAGE_KEY);
|
|
20
18
|
} catch {
|
|
21
19
|
return null; // private mode / embedded contexts
|
|
22
20
|
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { generateUlid } from "@runwisp/common";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
type ToastType = "success" | "error" | "warning" | "info";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
interface ToastAction {
|
|
9
9
|
label: string;
|
|
10
10
|
onClick: () => void;
|
|
11
11
|
}
|
|
@@ -18,7 +18,7 @@ export interface Toast {
|
|
|
18
18
|
action?: ToastAction;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
interface ToastOptions {
|
|
22
22
|
duration?: number;
|
|
23
23
|
action?: ToastAction;
|
|
24
24
|
}
|
|
@@ -88,14 +88,6 @@ class ToastStore {
|
|
|
88
88
|
info(message: string, opts?: ToastOptions): string {
|
|
89
89
|
return this.add("info", message, opts);
|
|
90
90
|
}
|
|
91
|
-
|
|
92
|
-
clear() {
|
|
93
|
-
for (const timer of this.timers.values()) {
|
|
94
|
-
clearTimeout(timer);
|
|
95
|
-
}
|
|
96
|
-
this.timers.clear();
|
|
97
|
-
this.items = [];
|
|
98
|
-
}
|
|
99
91
|
}
|
|
100
92
|
|
|
101
93
|
export const toast = new ToastStore();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script module>
|
|
5
|
+
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
6
|
+
import { LifeBuoy, Trash } from "@lucide/svelte";
|
|
7
|
+
import Accordion from "$lib/components/Accordion.svelte";
|
|
8
|
+
import AccordionItem from "$lib/components/AccordionItem.svelte";
|
|
9
|
+
import Toggle from "$lib/components/Toggle.svelte";
|
|
10
|
+
|
|
11
|
+
const { Story } = defineMeta({
|
|
12
|
+
title: "Navigation/Accordion",
|
|
13
|
+
component: Accordion,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
});
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<Story name="Multiple" asChild>
|
|
19
|
+
<Accordion class="max-w-lg">
|
|
20
|
+
<AccordionItem title="Retries" open>Retry a failed run up to three times.</AccordionItem>
|
|
21
|
+
<AccordionItem title="Timeouts">Kill the run after 10 minutes.</AccordionItem>
|
|
22
|
+
<AccordionItem title="Notifications">Send failures to Slack.</AccordionItem>
|
|
23
|
+
</Accordion>
|
|
24
|
+
</Story>
|
|
25
|
+
|
|
26
|
+
<Story name="Single" asChild>
|
|
27
|
+
<Accordion type="single" class="max-w-lg">
|
|
28
|
+
<AccordionItem title="Retries" open>Opening another section closes this one.</AccordionItem>
|
|
29
|
+
<AccordionItem title="Timeouts">Kill the run after 10 minutes.</AccordionItem>
|
|
30
|
+
<AccordionItem title="Notifications">Send failures to Slack.</AccordionItem>
|
|
31
|
+
</Accordion>
|
|
32
|
+
</Story>
|
|
33
|
+
|
|
34
|
+
<Story name="Framed, default open" asChild>
|
|
35
|
+
<AccordionItem variant="framed" open class="max-w-lg">
|
|
36
|
+
{#snippet title()}
|
|
37
|
+
<span class="flex items-center gap-2.5">
|
|
38
|
+
<LifeBuoy size={16} class="text-on-surface-faint" />
|
|
39
|
+
Schedule recovery
|
|
40
|
+
<span class="font-sans text-xs text-on-surface-faint">Missed ticks & jitter</span>
|
|
41
|
+
</span>
|
|
42
|
+
{/snippet}
|
|
43
|
+
What to do with cron ticks missed while the scheduler was offline.
|
|
44
|
+
</AccordionItem>
|
|
45
|
+
</Story>
|
|
46
|
+
|
|
47
|
+
<Story name="Framed with actions" asChild>
|
|
48
|
+
<AccordionItem variant="framed" title="Node.js 22" class="max-w-lg">
|
|
49
|
+
{#snippet actions()}
|
|
50
|
+
<Toggle checked size="sm" />
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
aria-label="Remove"
|
|
54
|
+
class="text-on-surface-faint hover:text-danger-surface"
|
|
55
|
+
>
|
|
56
|
+
<Trash size={14} />
|
|
57
|
+
</button>
|
|
58
|
+
{/snippet}
|
|
59
|
+
<pre class="font-mono text-xs">RUN apk add --no-cache nodejs npm</pre>
|
|
60
|
+
</AccordionItem>
|
|
61
|
+
</Story>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script module>
|
|
5
|
+
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
6
|
+
import CodeBlock from "$lib/components/CodeBlock.svelte";
|
|
7
|
+
|
|
8
|
+
const helloTask = '[tasks.hello]\ncron = "*/5 * * * *"\nrun = "echo hello"';
|
|
9
|
+
const backupTask = '[tasks.backup]\ncron = "0 3 * * *"\nkeep_runs = 60';
|
|
10
|
+
|
|
11
|
+
const { Story } = defineMeta({
|
|
12
|
+
title: "Data Display/CodeBlock",
|
|
13
|
+
component: CodeBlock,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
argTypes: {
|
|
16
|
+
variant: { control: "select", options: ["terminal", "surface"] },
|
|
17
|
+
size: { control: "select", options: ["sm", "md"] },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<Story
|
|
23
|
+
name="Default"
|
|
24
|
+
args={{
|
|
25
|
+
code: "export RUNWISP_STATION_TOKEN=rw_xxx\nexport RUNWISP_STATION_URL=https://app.runwisp.com\nrunwisp station",
|
|
26
|
+
}}
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
<Story
|
|
30
|
+
name="Filename"
|
|
31
|
+
args={{ code: "FROM alpine:3.20\nRUN apk add --no-cache curl", filename: "Dockerfile" }}
|
|
32
|
+
/>
|
|
33
|
+
|
|
34
|
+
<Story
|
|
35
|
+
name="Shell prompt"
|
|
36
|
+
args={{ code: "curl -fsSL https://get.runwisp.com/install.sh | bash", prompt: "$" }}
|
|
37
|
+
/>
|
|
38
|
+
|
|
39
|
+
<Story name="Surface variant" asChild>
|
|
40
|
+
<CodeBlock variant="surface" size="sm" copyable={false} code={helloTask} />
|
|
41
|
+
</Story>
|
|
42
|
+
|
|
43
|
+
<!-- Rich children render the markup; `code` keeps the copied text plain. -->
|
|
44
|
+
<Story name="Highlighted children" asChild>
|
|
45
|
+
<!-- No whitespace between the line spans: inside <pre> it would render as blank lines. -->
|
|
46
|
+
<CodeBlock filename="runwisp.toml" code={backupTask}
|
|
47
|
+
><span class="block text-[var(--rw-syn-fn)]">[tasks.backup]</span><span class="block"
|
|
48
|
+
><span class="text-[var(--rw-syn-kw)]">cron</span> =
|
|
49
|
+
<span class="text-[var(--rw-syn-str)]">"0 3 * * *"</span></span
|
|
50
|
+
><span class="block"
|
|
51
|
+
><span class="text-[var(--rw-syn-kw)]">keep_runs</span> =
|
|
52
|
+
<span class="text-[var(--rw-syn-num)]">60</span></span
|
|
53
|
+
></CodeBlock
|
|
54
|
+
>
|
|
55
|
+
</Story>
|