@runwisp/ui 0.16.0 → 1.0.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 -4
- package/src/lib/actions/focusTrap.ts +1 -1
- package/src/lib/components/DataGrid.svelte +1 -1
- package/src/lib/components/LogConsole.svelte +45 -15
- package/src/lib/components/dashboard/run-filters.test.ts +47 -21
- package/src/lib/components/dashboard/run-filters.ts +26 -9
- package/src/lib/components/dashboard/run-helpers.test.ts +2 -0
- package/src/lib/components/dashboard/run-helpers.ts +2 -0
- package/src/lib/components/dashboard/status-config.ts +1 -1
- package/src/lib/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runwisp/ui",
|
|
3
3
|
"description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@chromatic-com/storybook": "^5.2.1",
|
|
51
51
|
"@eslint/js": "^10.0.1",
|
|
52
|
-
"@runwisp/eslint-config": "0.
|
|
53
|
-
"@runwisp/typescript-config": "0.
|
|
52
|
+
"@runwisp/eslint-config": "1.0.0",
|
|
53
|
+
"@runwisp/typescript-config": "1.0.0",
|
|
54
54
|
"@storybook/addon-a11y": "^10.5.5",
|
|
55
55
|
"@storybook/addon-docs": "^10.5.5",
|
|
56
56
|
"@storybook/addon-svelte-csf": "^5.1.2",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
94
94
|
"@fontsource/tasa-orbiter": "^5.3.0",
|
|
95
95
|
"@lucide/svelte": "^1.28.0",
|
|
96
|
-
"@runwisp/common": "0.
|
|
96
|
+
"@runwisp/common": "1.0.0",
|
|
97
97
|
"@tanstack/svelte-virtual": "^3.13.35",
|
|
98
98
|
"ansi-to-html": "^0.7.2",
|
|
99
99
|
"cron-parser": "^5.7.0",
|
|
@@ -37,7 +37,7 @@ export function resolveTrapFocusTarget<T>(
|
|
|
37
37
|
): T | null {
|
|
38
38
|
if (event.key !== "Tab" || focusable.length === 0) return null;
|
|
39
39
|
const first = focusable[0];
|
|
40
|
-
const last = focusable
|
|
40
|
+
const last = focusable.at(-1);
|
|
41
41
|
if (first === undefined || last === undefined) return null;
|
|
42
42
|
|
|
43
43
|
if (event.shiftKey && active === first) return last;
|
|
@@ -168,28 +168,56 @@
|
|
|
168
168
|
return Math.max(1, Math.ceil(visibleColumns(text) / cols));
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// Measures wrapped row counts for exactly the lines in [min, max] and bumps
|
|
172
|
+
// rowCountVersion if anything changed. Called directly wherever new line
|
|
173
|
+
// text lands in the cache (streamed append, on-demand backfill), each of
|
|
174
|
+
// which already knows the touched range from LogCache's merge result — so
|
|
175
|
+
// a single appended line costs O(1) here instead of re-walking the whole
|
|
176
|
+
// (up to 50k-line) cache. Plain function, not an $effect: reads/writes
|
|
177
|
+
// rowCounts and cache.lines by key, never by iterating the map.
|
|
178
|
+
function measureRange(min: number, max: number) {
|
|
179
|
+
if (!wrap || min > max) return;
|
|
180
|
+
let changed = false;
|
|
181
|
+
for (let num = min; num <= max; num++) {
|
|
182
|
+
const r = wrappedRowsFor(cache.lines.get(num));
|
|
183
|
+
if (rowCounts.get(num) !== r) {
|
|
184
|
+
rowCounts.set(num, r);
|
|
185
|
+
changed = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (changed) rowCountVersion++;
|
|
189
|
+
}
|
|
190
|
+
|
|
171
191
|
// Line-text white-space model: wrapped lines break anywhere (so a long
|
|
172
192
|
// token wraps mid-word the way a terminal would), unwrapped lines stay on
|
|
173
193
|
// one horizontal-scrolling row.
|
|
174
194
|
let lineTextClass = $derived(wrap ? "break-anywhere whitespace-pre-wrap" : "whitespace-pre");
|
|
175
195
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
196
|
+
// Full remeasure of every loaded line, needed only when wrapping turns on
|
|
197
|
+
// or the available column width changes (wrap width affects every line's
|
|
198
|
+
// row count, so there's no way to scope that to a range). Line
|
|
199
|
+
// appends/backfills are measured incrementally at their call sites via
|
|
200
|
+
// measureRange instead — reading `cache.lines` in this effect's tracked
|
|
201
|
+
// scope would resubscribe it to the SvelteMap's shared version signal and
|
|
202
|
+
// re-run this full O(n) walk on every single appended line. `untrack`
|
|
203
|
+
// (same pattern as the fetch/prune effects below) keeps that read out of
|
|
204
|
+
// the dependency set, so only `wrap`/`availableColumns` retrigger this.
|
|
205
|
+
// Pruned lines keep their last-computed count so scroll geometry stays
|
|
206
|
+
// stable.
|
|
179
207
|
$effect(() => {
|
|
180
208
|
if (!wrap) return;
|
|
181
|
-
const lines = cache.lines;
|
|
182
209
|
void availableColumns;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
210
|
+
untrack(() => {
|
|
211
|
+
let changed = false;
|
|
212
|
+
for (const [num, text] of cache.lines) {
|
|
213
|
+
const r = wrappedRowsFor(text);
|
|
214
|
+
if (rowCounts.get(num) !== r) {
|
|
215
|
+
rowCounts.set(num, r);
|
|
216
|
+
changed = true;
|
|
217
|
+
}
|
|
190
218
|
}
|
|
191
|
-
|
|
192
|
-
|
|
219
|
+
if (changed) rowCountVersion++;
|
|
220
|
+
});
|
|
193
221
|
});
|
|
194
222
|
|
|
195
223
|
// Rebuild the prefix-sum table when wrap is on and any geometry input
|
|
@@ -378,7 +406,8 @@
|
|
|
378
406
|
$effect(() => {
|
|
379
407
|
const fn = fetchLogs;
|
|
380
408
|
const cs = chunkSize;
|
|
381
|
-
fetcher = new LogFetcher(cache, fn, cs, () => {
|
|
409
|
+
fetcher = new LogFetcher(cache, fn, cs, (min, max) => {
|
|
410
|
+
measureRange(min, max);
|
|
382
411
|
if (isAutoScroll && !userScrolledUp) {
|
|
383
412
|
requestAnimationFrame(() => scrollToBottom());
|
|
384
413
|
}
|
|
@@ -484,7 +513,8 @@
|
|
|
484
513
|
|
|
485
514
|
export function onStream(event: LogEvent) {
|
|
486
515
|
const prevTotal = cache.totalLines;
|
|
487
|
-
cache.applyEvent(event);
|
|
516
|
+
const merged = cache.applyEvent(event);
|
|
517
|
+
if (merged.touched) measureRange(merged.min, merged.max);
|
|
488
518
|
|
|
489
519
|
if (cache.totalLines > prevTotal && isAutoScroll && !userScrolledUp) {
|
|
490
520
|
requestAnimationFrame(() => scrollToBottom());
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import {
|
|
5
|
+
import { END_REASONS } from "@runwisp/common";
|
|
6
6
|
import {
|
|
7
7
|
emptyRunFilters,
|
|
8
|
+
FAILURE_STATUS_TOKEN,
|
|
8
9
|
NEEDS_ATTENTION_STATUSES,
|
|
9
10
|
isNeedsAttention,
|
|
10
11
|
dimensionActive,
|
|
@@ -59,14 +60,11 @@ describe("isNeedsAttention", () => {
|
|
|
59
60
|
expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES].reverse())).toBe(true);
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
it("
|
|
63
|
-
|
|
64
|
-
expect(NEEDS_ATTENTION_STATUSES).toContain(reason);
|
|
65
|
-
}
|
|
66
|
-
expect(NEEDS_ATTENTION_STATUSES).toContain("missed");
|
|
63
|
+
it("is exactly the failure sentinel (server resolves it to is_failure)", () => {
|
|
64
|
+
expect(NEEDS_ATTENTION_STATUSES).toEqual([FAILURE_STATUS_TOKEN]);
|
|
67
65
|
});
|
|
68
66
|
|
|
69
|
-
it("is false for a
|
|
67
|
+
it("is false for a raw end reason or a different set", () => {
|
|
70
68
|
expect(isNeedsAttention(["failed"])).toBe(false);
|
|
71
69
|
expect(isNeedsAttention(["succeeded"])).toBe(false);
|
|
72
70
|
expect(isNeedsAttention([])).toBe(false);
|
|
@@ -172,32 +170,58 @@ describe("statusChipLabel", () => {
|
|
|
172
170
|
});
|
|
173
171
|
|
|
174
172
|
describe("STATUS_BUCKETS", () => {
|
|
175
|
-
|
|
173
|
+
// The Failed bucket is the failure sentinel (resolved server-side to the
|
|
174
|
+
// per-task is_failure classification), so the execution-failure reasons are
|
|
175
|
+
// not bucketed — they stay reachable via the popover's Advanced expander.
|
|
176
|
+
const FAILURE_REASONS = [
|
|
177
|
+
"failed",
|
|
178
|
+
"crashed",
|
|
179
|
+
"timeout",
|
|
180
|
+
"log_overflow",
|
|
181
|
+
"start_failed",
|
|
182
|
+
"missed",
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
it("no status appears in two buckets", () => {
|
|
176
186
|
const all = STATUS_BUCKETS.flatMap((b) => [...b.statuses]);
|
|
177
|
-
expect(new Set(all).size).toBe(all.length);
|
|
178
|
-
expect(new Set(all)).toEqual(new Set([...END_REASONS, "pending", "running"]));
|
|
187
|
+
expect(new Set(all).size).toBe(all.length);
|
|
179
188
|
});
|
|
180
189
|
|
|
181
|
-
it("
|
|
182
|
-
|
|
190
|
+
it("buckets cover every non-failure status plus the failure sentinel", () => {
|
|
191
|
+
const all = new Set(STATUS_BUCKETS.flatMap((b) => [...b.statuses]));
|
|
192
|
+
const nonFailureReasons = END_REASONS.filter((r) => !FAILURE_REASONS.includes(r));
|
|
193
|
+
expect(all).toEqual(
|
|
194
|
+
new Set([...nonFailureReasons, "pending", "running", FAILURE_STATUS_TOKEN]),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("Failed bucket is the failure sentinel", () => {
|
|
199
|
+
expect(bucketByKey("failed").statuses).toEqual([FAILURE_STATUS_TOKEN]);
|
|
183
200
|
});
|
|
184
201
|
});
|
|
185
202
|
|
|
186
203
|
describe("bucketState / toggleBucket", () => {
|
|
187
|
-
const failed = bucketByKey("failed");
|
|
204
|
+
const failed = bucketByKey("failed"); // single-token (the failure sentinel)
|
|
205
|
+
const running = bucketByKey("running"); // multi-member: pending + running
|
|
188
206
|
|
|
189
|
-
it("reads off /
|
|
207
|
+
it("reads off / on for the single-token Failed bucket", () => {
|
|
190
208
|
expect(bucketState([], failed)).toBe("off");
|
|
191
|
-
expect(bucketState(["failed"], failed)).toBe("
|
|
209
|
+
expect(bucketState(["failed"], failed)).toBe("off"); // a raw reason is not the sentinel
|
|
192
210
|
expect(bucketState([...failed.statuses], failed)).toBe("on");
|
|
193
211
|
});
|
|
194
212
|
|
|
213
|
+
it("reads off / partial / on for a multi-member bucket", () => {
|
|
214
|
+
expect(bucketState([], running)).toBe("off");
|
|
215
|
+
expect(bucketState(["running"], running)).toBe("partial");
|
|
216
|
+
expect(bucketState([...running.statuses], running)).toBe("on");
|
|
217
|
+
});
|
|
218
|
+
|
|
195
219
|
it("selects all of an off or partial bucket", () => {
|
|
196
|
-
const fromOff = toggleBucket(base(),
|
|
197
|
-
expect(new Set(fromOff.statuses)).toEqual(new Set(
|
|
220
|
+
const fromOff = toggleBucket(base(), running);
|
|
221
|
+
expect(new Set(fromOff.statuses)).toEqual(new Set(running.statuses));
|
|
198
222
|
|
|
199
|
-
const fromPartial = toggleBucket(base({ statuses: ["
|
|
200
|
-
expect(new Set(fromPartial.statuses)).toEqual(new Set(
|
|
223
|
+
const fromPartial = toggleBucket(base({ statuses: ["running"] }), running);
|
|
224
|
+
expect(new Set(fromPartial.statuses)).toEqual(new Set(running.statuses));
|
|
201
225
|
});
|
|
202
226
|
|
|
203
227
|
it("clears a fully-selected bucket without touching other statuses", () => {
|
|
@@ -237,7 +261,9 @@ describe("date helpers", () => {
|
|
|
237
261
|
describe("triggerDescription", () => {
|
|
238
262
|
it("gives a fuller label than the row badge", () => {
|
|
239
263
|
expect(triggerDescription("cron")).toBe("Scheduled (cron)");
|
|
240
|
-
expect(triggerDescription("api")).toBe("
|
|
264
|
+
expect(triggerDescription("api")).toBe("REST API");
|
|
265
|
+
expect(triggerDescription("ui")).toBe("UI");
|
|
266
|
+
expect(triggerDescription("cli")).toBe("CLI");
|
|
241
267
|
expect(triggerDescription("service")).toBe("Service auto-start");
|
|
242
268
|
expect(triggerDescription("startup")).toBe("On daemon start");
|
|
243
269
|
});
|
|
@@ -249,7 +275,7 @@ describe("triggerDescription", () => {
|
|
|
249
275
|
|
|
250
276
|
describe("FILTERABLE_TRIGGERS", () => {
|
|
251
277
|
it("offers every trigger except cloud", () => {
|
|
252
|
-
expect(FILTERABLE_TRIGGERS).toEqual(["cron", "api", "service", "startup"]);
|
|
278
|
+
expect(FILTERABLE_TRIGGERS).toEqual(["cron", "api", "ui", "cli", "service", "startup"]);
|
|
253
279
|
expect(FILTERABLE_TRIGGERS).not.toContain("cloud");
|
|
254
280
|
});
|
|
255
281
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { TRIGGERS, type Trigger } from "@runwisp/common";
|
|
5
5
|
|
|
6
6
|
export type RunsListSortDirection = "asc" | "desc" | "";
|
|
7
7
|
|
|
@@ -42,12 +42,23 @@ export function emptyRunFilters(): RunsListFilters {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* Reserved `status` token the "Failed" browse filter sends instead of a fixed
|
|
46
|
+
* end-reason list. The server (splitFailureToken in api_types.go) strips it and
|
|
47
|
+
* matches on each run's persisted `isFailure` bit, so the browse filter agrees
|
|
48
|
+
* with the attention badges, the failed stat, and notifications — all of which
|
|
49
|
+
* follow the per-task `failures` policy. It is not a real status/end-reason, so
|
|
50
|
+
* it OR-combines with the other buckets. Must match `failureStatusToken` in the
|
|
51
|
+
* daemon.
|
|
49
52
|
*/
|
|
50
|
-
export const
|
|
53
|
+
export const FAILURE_STATUS_TOKEN = "failure";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The status set backing the "Failed" browse filter and the one-click preset
|
|
57
|
+
* that serves Prime Directive #1 — nothing silently fails. Just the failure
|
|
58
|
+
* sentinel: the server resolves it to the per-task `isFailure` classification
|
|
59
|
+
* rather than a fixed end-reason list.
|
|
60
|
+
*/
|
|
61
|
+
export const NEEDS_ATTENTION_STATUSES: readonly string[] = [FAILURE_STATUS_TOKEN];
|
|
51
62
|
|
|
52
63
|
/** True when `statuses` is exactly the needs-attention set (order-insensitive). */
|
|
53
64
|
export function isNeedsAttention(statuses: string[]): boolean {
|
|
@@ -214,8 +225,10 @@ export function humanizeStatus(status: string): string {
|
|
|
214
225
|
|
|
215
226
|
/**
|
|
216
227
|
* Descriptive label for a run's trigger source — fuller than the one-word row
|
|
217
|
-
* badge (`formatTriggeredByLabel`)
|
|
218
|
-
* extra words disambiguate (e.g. "
|
|
228
|
+
* badge (`formatTriggeredByLabel`) for the "cron"/"cloud"/"service"/"startup"
|
|
229
|
+
* sources, where the extra words disambiguate (e.g. "REST API" vs a bare
|
|
230
|
+
* "API"). "ui" and "cli" already read fine as their bare badge word, so those
|
|
231
|
+
* two match `formatTriggeredByLabel` exactly.
|
|
219
232
|
*
|
|
220
233
|
* Note `cron` covers both on-time schedule firings and catch-up for missed
|
|
221
234
|
* runs, and `startup` is specifically `run_on_start` (not catch-up).
|
|
@@ -225,7 +238,11 @@ export function triggerDescription(trigger: string): string {
|
|
|
225
238
|
case "cron":
|
|
226
239
|
return "Scheduled (cron)";
|
|
227
240
|
case "api":
|
|
228
|
-
return "
|
|
241
|
+
return "REST API";
|
|
242
|
+
case "ui":
|
|
243
|
+
return "UI";
|
|
244
|
+
case "cli":
|
|
245
|
+
return "CLI";
|
|
229
246
|
case "cloud":
|
|
230
247
|
return "Control plane";
|
|
231
248
|
case "service":
|
|
@@ -107,6 +107,8 @@ describe("runStartDelay", () => {
|
|
|
107
107
|
describe("formatTriggeredByLabel", () => {
|
|
108
108
|
it("humanizes each trigger source", () => {
|
|
109
109
|
expect(formatTriggeredByLabel("api")).toBe("API");
|
|
110
|
+
expect(formatTriggeredByLabel("ui")).toBe("UI");
|
|
111
|
+
expect(formatTriggeredByLabel("cli")).toBe("CLI");
|
|
110
112
|
expect(formatTriggeredByLabel("cron")).toBe("Cron");
|
|
111
113
|
expect(formatTriggeredByLabel("service")).toBe("Service");
|
|
112
114
|
expect(formatTriggeredByLabel("startup")).toBe("Startup");
|
|
@@ -85,6 +85,8 @@ export function instanceSuffix(instanceIndex: number, instanceCount: number): st
|
|
|
85
85
|
/** Human label for why a run fired (the `triggeredBy` source). */
|
|
86
86
|
export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string {
|
|
87
87
|
if (triggeredBy === "api") return "API";
|
|
88
|
+
if (triggeredBy === "ui") return "UI";
|
|
89
|
+
if (triggeredBy === "cli") return "CLI";
|
|
88
90
|
if (triggeredBy === "cron") return "Cron";
|
|
89
91
|
if (triggeredBy === "service") return "Service";
|
|
90
92
|
if (triggeredBy === "startup") return "Startup";
|
|
@@ -151,7 +151,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
151
151
|
dot: "bg-danger-surface",
|
|
152
152
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
153
153
|
description:
|
|
154
|
-
"The service kept failing
|
|
154
|
+
"The run (or service instance) kept failing; after restart_attempts consecutive failures, RunWisp gave up restarting it automatically.",
|
|
155
155
|
},
|
|
156
156
|
pending: {
|
|
157
157
|
icon: Clock,
|
package/src/lib/index.ts
CHANGED
|
@@ -84,6 +84,7 @@ export { default as RunFilterPopover } from "./components/dashboard/RunFilterPop
|
|
|
84
84
|
export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
|
|
85
85
|
export {
|
|
86
86
|
emptyRunFilters,
|
|
87
|
+
FAILURE_STATUS_TOKEN,
|
|
87
88
|
NEEDS_ATTENTION_STATUSES,
|
|
88
89
|
isNeedsAttention,
|
|
89
90
|
activeFilterCount,
|