@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 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.16.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.16.0",
53
- "@runwisp/typescript-config": "0.16.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.16.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[focusable.length - 1];
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;
@@ -292,7 +292,7 @@
292
292
  </td>
293
293
  </tr>
294
294
  {:else}
295
- {#each pagedData as row, idx (idx)}
295
+ {#each pagedData as row, idx (row[rowKey])}
296
296
  <tr
297
297
  class="
298
298
  border-b border-outline last:border-b-0
@@ -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
- // Recompute row counts for every loaded line whenever wrapping is on and
177
- // either the cache or the available column width moves. Pruned lines keep
178
- // their last-computed count so scroll geometry stays stable.
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
- let changed = false;
184
- for (const [num, text] of lines) {
185
- const r = wrappedRowsFor(text);
186
- const prev = rowCounts.get(num);
187
- if (prev !== r) {
188
- rowCounts.set(num, r);
189
- changed = true;
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
- if (changed) rowCountVersion++;
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 { FAILURE_END_REASONS, END_REASONS } from "@runwisp/common";
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("includes every failure reason plus missed", () => {
63
- for (const reason of FAILURE_END_REASONS) {
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 strict subset or a different set", () => {
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
- it("covers every filterable status exactly once", () => {
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); // no status in two buckets
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("Failed bucket is the needs-attention set", () => {
182
- expect(new Set(bucketByKey("failed").statuses)).toEqual(new Set(NEEDS_ATTENTION_STATUSES));
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 / partial / on", () => {
207
+ it("reads off / on for the single-token Failed bucket", () => {
190
208
  expect(bucketState([], failed)).toBe("off");
191
- expect(bucketState(["failed"], failed)).toBe("partial");
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(), failed);
197
- expect(new Set(fromOff.statuses)).toEqual(new Set(failed.statuses));
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: ["failed"] }), failed);
200
- expect(new Set(fromPartial.statuses)).toEqual(new Set(failed.statuses));
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("Manual (UI or API)");
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 { FAILURE_END_REASONS, TRIGGERS, type Trigger } from "@runwisp/common";
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
- * Statuses worth an operator's attention: every end reason the retry policy
46
- * treats as a failure, plus `missed` (a scheduled run the daemon was down
47
- * for). Backs the one-click preset that serves Prime Directive #1 — nothing
48
- * silently fails.
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 NEEDS_ATTENTION_STATUSES: readonly string[] = [...FAILURE_END_REASONS, "missed"];
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`), for the filter dropdown and chip where the
218
- * extra words disambiguate (e.g. "Manual (UI or API)" vs a bare "API").
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 "Manual (UI or API)";
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 to stay up for healthy_after; after restart_attempts the supervisor gave up (FATAL) and stopped restarting it.",
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,