@runwisp/ui 1.5.1 → 1.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 +5 -7
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +3 -24
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +2 -6
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +95 -418
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +265 -543
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +9 -0
- package/src/lib/components/dashboard/cron-format.ts +5 -1
- package/src/lib/components/dashboard/run-filters.test.ts +60 -0
- package/src/lib/components/dashboard/run-filters.ts +78 -6
- package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
- package/src/lib/components/dashboard/run-helpers.ts +122 -6
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/utils/format.test.ts +122 -0
- package/src/lib/utils/format.ts +97 -4
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/Tabs.stories.svelte +17 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { untrack } from "svelte";
|
|
5
|
+
import { visibleColumns } from "./ansi.js";
|
|
6
|
+
import type { LogCache } from "./LogCache.svelte.js";
|
|
7
|
+
|
|
8
|
+
/** Pixels per rendered row. */
|
|
9
|
+
export const LINE_HEIGHT = 20;
|
|
10
|
+
|
|
11
|
+
// Right padding on the text cell (pr-4); reserved when computing how many
|
|
12
|
+
// columns a wrapped line can use.
|
|
13
|
+
const TEXT_PADDING = 16;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many columns a wrapped line can use: 1 with wrapping off or before the
|
|
17
|
+
* font is measured. Many widths map to the same count, so a resize only costs
|
|
18
|
+
* a remeasure when this number changes.
|
|
19
|
+
*/
|
|
20
|
+
export function wrapColumns(
|
|
21
|
+
wrap: boolean,
|
|
22
|
+
width: number,
|
|
23
|
+
gutter: number,
|
|
24
|
+
charWidth: number,
|
|
25
|
+
): number {
|
|
26
|
+
if (!wrap || charWidth <= 0) return 1;
|
|
27
|
+
return Math.max(1, Math.floor((width - gutter - TEXT_PADDING) / charWidth));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface WrapLayoutInputs {
|
|
31
|
+
wrap: () => boolean;
|
|
32
|
+
/** Width of the scroll container in px. */
|
|
33
|
+
width: () => number;
|
|
34
|
+
gutterWidth: () => number;
|
|
35
|
+
/** Width of one monospace column in px. */
|
|
36
|
+
charWidth: () => number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Row geometry for the virtualized log. With wrapping off every line is one
|
|
41
|
+
* row. With wrapping on, each line occupies as many rows as its text wraps
|
|
42
|
+
* into at the current column width, so positions come from a prefix sum of the
|
|
43
|
+
* per-line row counts instead of a multiplication. Construct during component
|
|
44
|
+
* init: it registers effects.
|
|
45
|
+
*/
|
|
46
|
+
export class WrapLayout {
|
|
47
|
+
// rowCounts remembers the wrapped row count for every line whose text has
|
|
48
|
+
// been observed; lines never loaded (pruned or out-of-window) count as 1.
|
|
49
|
+
// A plain Map is deliberate: reactivity is driven by the `#version` counter
|
|
50
|
+
// (bumped on any change), not by per-key subscriptions, iterating a
|
|
51
|
+
// SvelteMap of tens of thousands of lines on every streamed line would be
|
|
52
|
+
// wasteful.
|
|
53
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
54
|
+
readonly #rowCounts = new Map<number, number>();
|
|
55
|
+
#version = $state(0);
|
|
56
|
+
readonly #cache: LogCache;
|
|
57
|
+
readonly #inputs: WrapLayoutInputs;
|
|
58
|
+
|
|
59
|
+
// How many columns a wrapped line can use at the current width. Derived,
|
|
60
|
+
// so the full remeasure below depends on the column count alone: a resize
|
|
61
|
+
// that keeps the count (most pixels of a drag) doesn't re-walk every line.
|
|
62
|
+
readonly #availableColumns = $derived.by(() => {
|
|
63
|
+
const { wrap, width, gutterWidth, charWidth } = this.#inputs;
|
|
64
|
+
return wrapColumns(wrap(), width(), gutterWidth(), charWidth());
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// prefixSums[i] is the total rows consumed by lines
|
|
68
|
+
// [firstAvailableLine, firstAvailableLine + i). Empty when wrapping is off
|
|
69
|
+
// (the fixed-height path ignores it).
|
|
70
|
+
readonly #prefixSums = $derived.by(() => {
|
|
71
|
+
const first = this.#cache.firstAvailableLine;
|
|
72
|
+
const total = this.#cache.totalLines;
|
|
73
|
+
// Subscribe to the counter that signals rowCounts changed.
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-meaningless-void-operator
|
|
75
|
+
void this.#version;
|
|
76
|
+
if (!this.#inputs.wrap()) return [];
|
|
77
|
+
const count = Math.max(0, total - first);
|
|
78
|
+
const sums = new Array<number>(count + 1);
|
|
79
|
+
sums[0] = 0;
|
|
80
|
+
let acc = 0;
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
acc += this.#rowCounts.get(first + i) ?? 1;
|
|
83
|
+
sums[i + 1] = acc;
|
|
84
|
+
}
|
|
85
|
+
return sums;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
constructor(cache: LogCache, inputs: WrapLayoutInputs) {
|
|
89
|
+
this.#cache = cache;
|
|
90
|
+
this.#inputs = inputs;
|
|
91
|
+
|
|
92
|
+
// Full remeasure of every loaded line, needed only when wrapping turns
|
|
93
|
+
// on or the available column width changes (wrap width affects every
|
|
94
|
+
// line's row count). Line appends/backfills are measured incrementally
|
|
95
|
+
// via measureRange; reading `cache.lines` in this effect's tracked scope
|
|
96
|
+
// would resubscribe it to the SvelteMap's shared version signal and
|
|
97
|
+
// re-run this O(n) walk on every appended line, hence `untrack`. Pruned
|
|
98
|
+
// lines keep their last-computed count so scroll geometry stays stable.
|
|
99
|
+
$effect(() => {
|
|
100
|
+
if (!this.#inputs.wrap()) return;
|
|
101
|
+
const cols = this.#availableColumns;
|
|
102
|
+
untrack(() => {
|
|
103
|
+
let changed = false;
|
|
104
|
+
for (const [num, text] of this.#cache.lines) {
|
|
105
|
+
changed = this.#measure(num, text, cols) || changed;
|
|
106
|
+
}
|
|
107
|
+
if (changed) this.#version++;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Drop wrap geometry when wrapping turns off so memory doesn't linger.
|
|
112
|
+
$effect(() => {
|
|
113
|
+
if (!this.#inputs.wrap()) this.#rowCounts.clear();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Measure the wrapped row counts of the lines in [min, max]. Called where
|
|
119
|
+
* new line text lands in the cache (streamed append, on-demand backfill),
|
|
120
|
+
* each of which knows the touched range, so one appended line costs O(1).
|
|
121
|
+
*/
|
|
122
|
+
measureRange(min: number, max: number): void {
|
|
123
|
+
if (!this.#inputs.wrap() || min > max) return;
|
|
124
|
+
const cols = this.#availableColumns;
|
|
125
|
+
let changed = false;
|
|
126
|
+
for (let num = min; num <= max; num++) {
|
|
127
|
+
changed = this.#measure(num, this.#cache.lines.get(num), cols) || changed;
|
|
128
|
+
}
|
|
129
|
+
if (changed) this.#version++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Rows a line occupies. */
|
|
133
|
+
rowCount(lineNum: number): number {
|
|
134
|
+
return this.#inputs.wrap() ? (this.#rowCounts.get(lineNum) ?? 1) : 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Rows consumed by the lines before `lineNum`. */
|
|
138
|
+
rowsAbove(lineNum: number): number {
|
|
139
|
+
const i = lineNum - this.#cache.firstAvailableLine;
|
|
140
|
+
if (!this.#inputs.wrap()) return i;
|
|
141
|
+
const sums = this.#prefixSums;
|
|
142
|
+
if (i <= 0) return 0;
|
|
143
|
+
if (i >= sums.length) return sums.at(-1) ?? 0;
|
|
144
|
+
return sums[i] ?? 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Rows consumed by every loaded line. */
|
|
148
|
+
get totalRows(): number {
|
|
149
|
+
const { firstAvailableLine, totalLines } = this.#cache;
|
|
150
|
+
if (!this.#inputs.wrap()) return totalLines - firstAvailableLine;
|
|
151
|
+
return this.#prefixSums.at(-1) ?? Math.max(0, totalLines - firstAvailableLine);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Offset from firstAvailableLine of the line whose rows cover `yRows`. */
|
|
155
|
+
offsetAt(yRows: number): number {
|
|
156
|
+
if (!this.#inputs.wrap()) return Math.floor(yRows);
|
|
157
|
+
const sums = this.#prefixSums;
|
|
158
|
+
if (sums.length === 0) return 0;
|
|
159
|
+
if (yRows <= (sums[0] ?? 0)) return 0;
|
|
160
|
+
const last = sums.length - 1;
|
|
161
|
+
if (yRows >= (sums[last] ?? Number.POSITIVE_INFINITY)) return last;
|
|
162
|
+
let lo = 0;
|
|
163
|
+
let hi = last;
|
|
164
|
+
while (lo < hi) {
|
|
165
|
+
const mid = (lo + hi + 1) >> 1;
|
|
166
|
+
if ((sums[mid] ?? Number.POSITIVE_INFINITY) <= yRows) lo = mid;
|
|
167
|
+
else hi = mid - 1;
|
|
168
|
+
}
|
|
169
|
+
return lo;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Offset of the first line starting at or past `limitRows`, scanning on from `yRows`. */
|
|
173
|
+
offsetEnd(yRows: number, limitRows: number): number {
|
|
174
|
+
if (!this.#inputs.wrap()) return Math.ceil(limitRows);
|
|
175
|
+
const sums = this.#prefixSums;
|
|
176
|
+
const count = sums.length - 1;
|
|
177
|
+
let end = this.offsetAt(yRows);
|
|
178
|
+
while (end < count && (sums[end] ?? Number.POSITIVE_INFINITY) < limitRows) end++;
|
|
179
|
+
return Math.max(0, end);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#measure(num: number, text: string | undefined, cols: number): boolean {
|
|
183
|
+
const rows = text && cols > 1 ? Math.max(1, Math.ceil(visibleColumns(text) / cols)) : 1;
|
|
184
|
+
if (this.#rowCounts.get(num) === rows) return false;
|
|
185
|
+
this.#rowCounts.set(num, rows);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
formatClockTime,
|
|
9
9
|
formatDayMonth,
|
|
10
10
|
formatDuration,
|
|
11
|
+
formatDurationExact,
|
|
12
|
+
parseDuration,
|
|
13
|
+
formatRelativeCompact,
|
|
11
14
|
formatRelativeTime,
|
|
12
15
|
formatRelativeTimeWithAbsolute,
|
|
13
16
|
formatTimeHM,
|
|
@@ -122,11 +125,84 @@ describe("formatRelativeTimeWithAbsolute", () => {
|
|
|
122
125
|
});
|
|
123
126
|
|
|
124
127
|
describe("formatRelativeTime", () => {
|
|
128
|
+
const now = new Date("2024-01-10T12:00:00Z");
|
|
129
|
+
const at = (seconds: number): Date => new Date(now.getTime() + seconds * 1000);
|
|
130
|
+
|
|
125
131
|
it("advances as the supplied now advances", () => {
|
|
126
132
|
const date = new Date("2024-01-01T12:00:00Z");
|
|
127
133
|
expect(formatRelativeTime(date, new Date("2024-01-01T14:00:00Z"))).toBe("2 hours ago");
|
|
128
134
|
expect(formatRelativeTime(date, new Date("2024-01-01T15:00:00Z"))).toBe("3 hours ago");
|
|
129
135
|
});
|
|
136
|
+
|
|
137
|
+
it("always shows a number by default", () => {
|
|
138
|
+
expect(formatRelativeTime(at(0), now)).toBe("in 0 seconds");
|
|
139
|
+
expect(formatRelativeTime(at(-86_400), now)).toBe("1 day ago");
|
|
140
|
+
expect(formatRelativeTime(at(86_400), now)).toBe("in 1 day");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it.each([
|
|
144
|
+
[0, "now"],
|
|
145
|
+
[-16, "16 seconds ago"],
|
|
146
|
+
[16, "in 16 seconds"],
|
|
147
|
+
[-60, "1 minute ago"],
|
|
148
|
+
[-5 * 60, "5 minutes ago"],
|
|
149
|
+
[-3600, "1 hour ago"],
|
|
150
|
+
[2 * 3600, "in 2 hours"],
|
|
151
|
+
[-86_400, "yesterday"],
|
|
152
|
+
[86_400, "tomorrow"],
|
|
153
|
+
[-3 * 86_400, "3 days ago"],
|
|
154
|
+
])("numeric auto: %is from now reads %s", (seconds, expected) => {
|
|
155
|
+
expect(formatRelativeTime(at(seconds), now, { numeric: "auto" })).toBe(expected);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("accepts a timestamp, an ISO string or a Date", () => {
|
|
159
|
+
const date = at(-120);
|
|
160
|
+
expect(formatRelativeTime(date.getTime(), now)).toBe("2 minutes ago");
|
|
161
|
+
expect(formatRelativeTime(date.toISOString(), now)).toBe("2 minutes ago");
|
|
162
|
+
expect(formatRelativeTime(date, now)).toBe("2 minutes ago");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("throws on an invalid date", () => {
|
|
166
|
+
expect(() => formatRelativeTime("not a date", now)).toThrow(RangeError);
|
|
167
|
+
expect(() => formatRelativeTime("not a date", now, { numeric: "auto" })).toThrow(
|
|
168
|
+
RangeError,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("formatRelativeCompact", () => {
|
|
174
|
+
const now = new Date("2024-01-10T12:00:00Z");
|
|
175
|
+
const ago = (seconds: number): number => now.getTime() - seconds * 1000;
|
|
176
|
+
|
|
177
|
+
it.each([
|
|
178
|
+
[0, "0s"],
|
|
179
|
+
[16.9, "16s"],
|
|
180
|
+
[59, "59s"],
|
|
181
|
+
[60, "1m"],
|
|
182
|
+
[3 * 60 + 59, "3m"],
|
|
183
|
+
[59 * 60, "59m"],
|
|
184
|
+
[3600, "1h"],
|
|
185
|
+
[23 * 3600 + 3599, "23h"],
|
|
186
|
+
[86_400, "1d"],
|
|
187
|
+
[45 * 86_400, "45d"],
|
|
188
|
+
[400 * 86_400, "400d"],
|
|
189
|
+
])("%is ago reads %s", (seconds, expected) => {
|
|
190
|
+
expect(formatRelativeCompact(ago(seconds), now)).toBe(expected);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("clamps a future date to 0s", () => {
|
|
194
|
+
expect(formatRelativeCompact(ago(-90), now)).toBe("0s");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("accepts an ISO string or a Date", () => {
|
|
198
|
+
expect(formatRelativeCompact(new Date(ago(180)).toISOString(), now)).toBe("3m");
|
|
199
|
+
expect(formatRelativeCompact(new Date(ago(180)), now)).toBe("3m");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("does not guard an invalid date", () => {
|
|
203
|
+
// Same as the station helper it replaces; callers pass server timestamps.
|
|
204
|
+
expect(formatRelativeCompact("not a date", now)).toBe("NaNd");
|
|
205
|
+
});
|
|
130
206
|
});
|
|
131
207
|
|
|
132
208
|
describe("formatClockTime", () => {
|
|
@@ -156,3 +232,49 @@ describe("formatDayMonth", () => {
|
|
|
156
232
|
expect(r).not.toContain("2026");
|
|
157
233
|
});
|
|
158
234
|
});
|
|
235
|
+
|
|
236
|
+
describe("parseDuration", () => {
|
|
237
|
+
it("reads the placeholder's own format", () => {
|
|
238
|
+
expect(parseDuration("5m 30s")).toBe(330_000);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reads compact, fractional and sub-second forms", () => {
|
|
242
|
+
expect(parseDuration("1h30m")).toBe(5_400_000);
|
|
243
|
+
expect(parseDuration("1.5h")).toBe(5_400_000);
|
|
244
|
+
expect(parseDuration("250ms")).toBe(250);
|
|
245
|
+
expect(parseDuration("2d")).toBe(172_800_000);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("reads what the ms package did", () => {
|
|
249
|
+
expect(parseDuration("500")).toBe(500);
|
|
250
|
+
expect(parseDuration("1w")).toBe(604_800_000);
|
|
251
|
+
expect(parseDuration("1y")).toBe(31_557_600_000);
|
|
252
|
+
expect(parseDuration("2 hours")).toBe(7_200_000);
|
|
253
|
+
expect(parseDuration("30 sec")).toBe(30_000);
|
|
254
|
+
expect(parseDuration("5 minutes")).toBe(300_000);
|
|
255
|
+
expect(parseDuration("5M")).toBe(300_000);
|
|
256
|
+
expect(parseDuration(".5h")).toBe(1_800_000);
|
|
257
|
+
expect(parseDuration(" 1 Hour 30 mins ")).toBe(5_400_000);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("rejects text that isn't number+unit throughout", () => {
|
|
261
|
+
expect(parseDuration("")).toBeNull();
|
|
262
|
+
expect(parseDuration("5x")).toBeNull();
|
|
263
|
+
expect(parseDuration("5m abc")).toBeNull();
|
|
264
|
+
expect(parseDuration("5 30s")).toBeNull();
|
|
265
|
+
expect(parseDuration("1.5.5")).toBeNull();
|
|
266
|
+
expect(parseDuration("9".repeat(101))).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("formatDurationExact", () => {
|
|
271
|
+
it("keeps every non-zero unit instead of rounding", () => {
|
|
272
|
+
expect(formatDurationExact(330_000)).toBe("5m 30s");
|
|
273
|
+
expect(formatDurationExact(3_930_000)).toBe("1h 5m 30s");
|
|
274
|
+
expect(formatDurationExact(0)).toBe("0ms");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("round-trips through parseDuration", () => {
|
|
278
|
+
expect(parseDuration(formatDurationExact(90_061_001))).toBe(90_061_001);
|
|
279
|
+
});
|
|
280
|
+
});
|
package/src/lib/utils/format.ts
CHANGED
|
@@ -13,18 +13,48 @@ const RELATIVE_DIVISIONS: [amount: number, unit: Intl.RelativeTimeFormatUnit][]
|
|
|
13
13
|
[Number.POSITIVE_INFINITY, "years"],
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
-
const relativeTime =
|
|
16
|
+
const relativeTime = {
|
|
17
|
+
always: new Intl.RelativeTimeFormat(undefined, { numeric: "always" }),
|
|
18
|
+
auto: new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface RelativeTimeOptions {
|
|
22
|
+
/** `"auto"` words the near units: "now", "yesterday", "tomorrow", "last week". Default `"always"`. */
|
|
23
|
+
numeric?: Intl.RelativeTimeFormatNumeric;
|
|
24
|
+
}
|
|
17
25
|
|
|
18
26
|
/** "N units ago" / "in N units" for `date` relative to `now`. */
|
|
19
|
-
export function formatRelativeTime(
|
|
27
|
+
export function formatRelativeTime(
|
|
28
|
+
date: string | number | Date,
|
|
29
|
+
now: Date = new Date(),
|
|
30
|
+
{ numeric = "always" }: RelativeTimeOptions = {},
|
|
31
|
+
): string {
|
|
32
|
+
const format = relativeTime[numeric];
|
|
20
33
|
let delta = (new Date(date).getTime() - now.getTime()) / 1000;
|
|
21
34
|
for (const [amount, unit] of RELATIVE_DIVISIONS) {
|
|
22
35
|
if (Math.abs(delta) < amount) {
|
|
23
|
-
return
|
|
36
|
+
return format.format(Math.round(delta), unit);
|
|
24
37
|
}
|
|
25
38
|
delta /= amount;
|
|
26
39
|
}
|
|
27
|
-
return
|
|
40
|
+
return format.format(Math.round(delta), "years");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compact age of a past `date` for dense rows and live labels: "45s", "5m",
|
|
45
|
+
* "3h", "2d". Each unit floors; a future date reads "0s".
|
|
46
|
+
*/
|
|
47
|
+
export function formatRelativeCompact(
|
|
48
|
+
date: string | number | Date,
|
|
49
|
+
now: Date = new Date(),
|
|
50
|
+
): string {
|
|
51
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - new Date(date).getTime()) / 1000));
|
|
52
|
+
if (seconds < 60) return `${String(seconds)}s`;
|
|
53
|
+
const minutes = Math.floor(seconds / 60);
|
|
54
|
+
if (minutes < 60) return `${String(minutes)}m`;
|
|
55
|
+
const hours = Math.floor(minutes / 60);
|
|
56
|
+
if (hours < 24) return `${String(hours)}h`;
|
|
57
|
+
return `${String(Math.floor(hours / 24))}d`;
|
|
28
58
|
}
|
|
29
59
|
|
|
30
60
|
/** A locale date/time formatter for one fixed option set, built once. */
|
|
@@ -108,3 +138,66 @@ export function formatDuration(ms: number): string {
|
|
|
108
138
|
const remM = m % 60;
|
|
109
139
|
return remM > 0 ? String(h) + "h " + String(remM) + "m" : String(h) + "h";
|
|
110
140
|
}
|
|
141
|
+
|
|
142
|
+
// Largest first, so formatDurationExact can peel units off greedily.
|
|
143
|
+
const DURATION_UNITS: [unit: string, ms: number][] = [
|
|
144
|
+
["d", 86_400_000],
|
|
145
|
+
["h", 3_600_000],
|
|
146
|
+
["m", 60_000],
|
|
147
|
+
["s", 1000],
|
|
148
|
+
["ms", 1],
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
// Every unit name parseDuration takes, in ms. The `ms` package's set.
|
|
152
|
+
const UNIT_ALIASES: [ms: number, names: string[]][] = [
|
|
153
|
+
[1, ["ms", "msec", "msecs", "millisecond", "milliseconds"]],
|
|
154
|
+
[1000, ["s", "sec", "secs", "second", "seconds"]],
|
|
155
|
+
[60_000, ["m", "min", "mins", "minute", "minutes"]],
|
|
156
|
+
[3_600_000, ["h", "hr", "hrs", "hour", "hours"]],
|
|
157
|
+
[86_400_000, ["d", "day", "days"]],
|
|
158
|
+
[604_800_000, ["w", "week", "weeks"]],
|
|
159
|
+
[31_557_600_000, ["y", "yr", "yrs", "year", "years"]],
|
|
160
|
+
];
|
|
161
|
+
const UNIT_MS = new Map(UNIT_ALIASES.flatMap(([size, names]) => names.map((n) => [n, size])));
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* parseDuration reads a duration into milliseconds. It takes what the `ms`
|
|
165
|
+
* package does ("500" as milliseconds, ".5h", "2 hours", "30 sec", "1w", "1y",
|
|
166
|
+
* any case) plus compound forms: "5m 30s", "1h30m". Returns null when any part
|
|
167
|
+
* isn't a number with a unit (only a lone number may go without one).
|
|
168
|
+
*/
|
|
169
|
+
export function parseDuration(text: string): number | null {
|
|
170
|
+
const s = text.trim().toLowerCase();
|
|
171
|
+
// Bounded like `ms`, which also keeps the number pattern's backtracking cheap.
|
|
172
|
+
if (!s || s.length > 100) return null;
|
|
173
|
+
const segment = /(-?\d*\.?\d+)\s*([a-z]*)\s*/y;
|
|
174
|
+
let total = 0;
|
|
175
|
+
let segments = 0;
|
|
176
|
+
let bare = false;
|
|
177
|
+
while (segment.lastIndex < s.length) {
|
|
178
|
+
const match = segment.exec(s);
|
|
179
|
+
if (!match) return null;
|
|
180
|
+
const [, amount = "", unit = ""] = match;
|
|
181
|
+
const size = unit ? UNIT_MS.get(unit) : 1;
|
|
182
|
+
if (size === undefined) return null;
|
|
183
|
+
bare ||= !unit;
|
|
184
|
+
total += Number(amount) * size;
|
|
185
|
+
segments++;
|
|
186
|
+
}
|
|
187
|
+
if (bare && segments > 1) return null;
|
|
188
|
+
return Math.round(total);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** formatDurationExact renders milliseconds as "1h 5m 30s", keeping every non-zero unit. */
|
|
192
|
+
export function formatDurationExact(ms: number): string {
|
|
193
|
+
const parts: string[] = [];
|
|
194
|
+
let rest = Math.round(ms);
|
|
195
|
+
for (const [unit, size] of DURATION_UNITS) {
|
|
196
|
+
const amount = Math.floor(rest / size);
|
|
197
|
+
if (amount > 0) {
|
|
198
|
+
parts.push(String(amount) + unit);
|
|
199
|
+
rest -= amount * size;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return parts.length > 0 ? parts.join(" ") : "0ms";
|
|
203
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/** True when a keyboard event came from a field the user is typing into. */
|
|
5
|
+
export function isTypingTarget(target: EventTarget | null): boolean {
|
|
6
|
+
return (
|
|
7
|
+
target instanceof HTMLElement &&
|
|
8
|
+
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -3,11 +3,32 @@
|
|
|
3
3
|
|
|
4
4
|
<script module>
|
|
5
5
|
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
6
|
-
import { LifeBuoy, Trash } from "@lucide/svelte";
|
|
6
|
+
import { LifeBuoy, Pencil, Trash } from "@lucide/svelte";
|
|
7
7
|
import Accordion from "$lib/components/Accordion.svelte";
|
|
8
8
|
import AccordionItem from "$lib/components/AccordionItem.svelte";
|
|
9
9
|
import Toggle from "$lib/components/Toggle.svelte";
|
|
10
10
|
|
|
11
|
+
const blocks = [
|
|
12
|
+
{
|
|
13
|
+
label: "Node.js 22",
|
|
14
|
+
enabled: true,
|
|
15
|
+
frameClass: "border-outline bg-surface-raised",
|
|
16
|
+
run: "RUN apk add --no-cache nodejs npm",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
label: "Python 3.12",
|
|
20
|
+
enabled: false,
|
|
21
|
+
frameClass: "border-outline-faint bg-surface",
|
|
22
|
+
run: "RUN apk add --no-cache python3",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: "Go 1.27 (stale)",
|
|
26
|
+
enabled: true,
|
|
27
|
+
frameClass: "border-warning-soft-border bg-warning-soft/30",
|
|
28
|
+
run: "RUN apk add --no-cache go",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
11
32
|
const { Story } = defineMeta({
|
|
12
33
|
title: "Navigation/Accordion",
|
|
13
34
|
component: Accordion,
|
|
@@ -59,3 +80,41 @@
|
|
|
59
80
|
<pre class="font-mono text-xs">RUN apk add --no-cache nodejs npm</pre>
|
|
60
81
|
</AccordionItem>
|
|
61
82
|
</Story>
|
|
83
|
+
|
|
84
|
+
<!-- `leading` sits before the title and never toggles the panel; `frameClass`
|
|
85
|
+
tints the frame by state. -->
|
|
86
|
+
<Story name="Framed with leading control and tint" asChild>
|
|
87
|
+
<div class="flex max-w-lg flex-col gap-2">
|
|
88
|
+
{#each blocks as block (block.label)}
|
|
89
|
+
<AccordionItem
|
|
90
|
+
variant="framed"
|
|
91
|
+
frameClass={block.frameClass}
|
|
92
|
+
class={block.enabled ? "" : "opacity-60"}
|
|
93
|
+
>
|
|
94
|
+
{#snippet leading()}
|
|
95
|
+
<Toggle checked={block.enabled} size="sm" />
|
|
96
|
+
{/snippet}
|
|
97
|
+
{#snippet title()}
|
|
98
|
+
<span class="block truncate">{block.label}</span>
|
|
99
|
+
{/snippet}
|
|
100
|
+
{#snippet actions()}
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
aria-label="Edit"
|
|
104
|
+
class="rounded-[3px] p-1 text-on-surface-faint hover:bg-surface-sunken hover:text-on-surface"
|
|
105
|
+
>
|
|
106
|
+
<Pencil size={14} />
|
|
107
|
+
</button>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
aria-label="Remove"
|
|
111
|
+
class="rounded-[3px] p-1 text-on-surface-faint hover:bg-danger-soft hover:text-danger-surface"
|
|
112
|
+
>
|
|
113
|
+
<Trash size={14} />
|
|
114
|
+
</button>
|
|
115
|
+
{/snippet}
|
|
116
|
+
<pre class="font-mono text-xs">{block.run}</pre>
|
|
117
|
+
</AccordionItem>
|
|
118
|
+
{/each}
|
|
119
|
+
</div>
|
|
120
|
+
</Story>
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
const helloTask = '[tasks.hello]\ncron = "*/5 * * * *"\nrun = "echo hello"';
|
|
9
9
|
const backupTask = '[tasks.backup]\ncron = "0 3 * * *"\nkeep_runs = 60';
|
|
10
|
+
const longLine =
|
|
11
|
+
"export RUNWISP_STATION_TOKEN=rw_live_8f2c1d9e4b7a6053c1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4";
|
|
10
12
|
|
|
11
13
|
const { Story } = defineMeta({
|
|
12
14
|
title: "Data Display/CodeBlock",
|
|
@@ -40,6 +42,17 @@
|
|
|
40
42
|
<CodeBlock variant="surface" size="sm" copyable={false} code={helloTask} />
|
|
41
43
|
</Story>
|
|
42
44
|
|
|
45
|
+
<!-- A line wider than the block scrolls without running under the copy button. -->
|
|
46
|
+
<Story name="Long line" asChild>
|
|
47
|
+
<div class="flex max-w-md flex-col gap-3">
|
|
48
|
+
<CodeBlock code={longLine} />
|
|
49
|
+
<CodeBlock code={longLine} size="sm" prompt="$" />
|
|
50
|
+
<CodeBlock code={longLine} variant="surface" />
|
|
51
|
+
<CodeBlock code={longLine} variant="surface" size="sm" />
|
|
52
|
+
<CodeBlock code="runwisp station" size="sm" />
|
|
53
|
+
</div>
|
|
54
|
+
</Story>
|
|
55
|
+
|
|
43
56
|
<!-- Rich children render the markup; `code` keeps the copied text plain. -->
|
|
44
57
|
<Story name="Highlighted children" asChild>
|
|
45
58
|
<!-- No whitespace between the line spans: inside <pre> it would render as blank lines. -->
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
component: Link,
|
|
11
11
|
tags: ["autodocs"],
|
|
12
12
|
argTypes: {
|
|
13
|
-
variant: {
|
|
13
|
+
variant: {
|
|
14
|
+
control: "select",
|
|
15
|
+
options: ["default", "muted", "primary", "danger", "unstyled"],
|
|
16
|
+
},
|
|
14
17
|
underline: { control: "select", options: ["always", "hover", "none"] },
|
|
15
18
|
},
|
|
16
19
|
});
|
|
@@ -36,3 +39,24 @@
|
|
|
36
39
|
Task configuration docs →
|
|
37
40
|
</Link>
|
|
38
41
|
</Story>
|
|
42
|
+
|
|
43
|
+
<!-- `unstyled` adds nothing beyond `class`: the row brings its own layout and colours. -->
|
|
44
|
+
<Story name="Unstyled nav row" asChild>
|
|
45
|
+
<nav class="w-64 space-y-0.5">
|
|
46
|
+
<Link
|
|
47
|
+
href="#tasks"
|
|
48
|
+
variant="unstyled"
|
|
49
|
+
aria-current="page"
|
|
50
|
+
class="flex items-center gap-3 rounded-sm bg-primary-soft px-3 py-2 font-mono text-sm text-primary-soft-text"
|
|
51
|
+
>
|
|
52
|
+
Tasks
|
|
53
|
+
</Link>
|
|
54
|
+
<Link
|
|
55
|
+
href="#runners"
|
|
56
|
+
variant="unstyled"
|
|
57
|
+
class="flex items-center gap-3 rounded-sm px-3 py-2 font-mono text-sm text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
|
|
58
|
+
>
|
|
59
|
+
Runners
|
|
60
|
+
</Link>
|
|
61
|
+
</nav>
|
|
62
|
+
</Story>
|
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
{ id: "settings", label: "Settings", icon: Settings },
|
|
33
33
|
];
|
|
34
34
|
|
|
35
|
+
const deployTabs = [
|
|
36
|
+
{ id: "binary", label: "Linux binary" },
|
|
37
|
+
{ id: "docker", label: "Docker run" },
|
|
38
|
+
{ id: "compose", label: "Docker Compose" },
|
|
39
|
+
{ id: "kubernetes", label: "Kubernetes manifest" },
|
|
40
|
+
{ id: "systemd", label: "systemd unit" },
|
|
41
|
+
];
|
|
42
|
+
|
|
35
43
|
const disabledTabs = [
|
|
36
44
|
{ id: "active", label: "Active" },
|
|
37
45
|
{ id: "completed", label: "Completed" },
|
|
@@ -105,3 +113,12 @@
|
|
|
105
113
|
{/snippet}
|
|
106
114
|
</Tabs>
|
|
107
115
|
</Story>
|
|
116
|
+
|
|
117
|
+
<!-- `scrollable` keeps every label on one line and scrolls the bar sideways. -->
|
|
118
|
+
<Story name="Scrollable" asChild>
|
|
119
|
+
<div class="flex max-w-xs flex-col gap-4">
|
|
120
|
+
<Tabs tabs={deployTabs} variant="enclosed" scrollable />
|
|
121
|
+
<Tabs tabs={deployTabs} variant="pills" scrollable />
|
|
122
|
+
<Tabs tabs={deployTabs} variant="underline" scrollable />
|
|
123
|
+
</div>
|
|
124
|
+
</Story>
|