@runwisp/ui 1.2.0 → 1.4.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": "1.2.0",
4
+ "version": "1.4.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": "1.2.0",
53
- "@runwisp/typescript-config": "1.2.0",
52
+ "@runwisp/eslint-config": "1.4.0",
53
+ "@runwisp/typescript-config": "1.4.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",
@@ -92,8 +92,8 @@
92
92
  "@floating-ui/dom": "^1.8.0",
93
93
  "@fontsource-variable/geist-mono": "^5.3.0",
94
94
  "@fontsource/tasa-orbiter": "^5.3.0",
95
- "@lucide/svelte": "^1.28.0",
96
- "@runwisp/common": "1.2.0",
95
+ "@lucide/svelte": "^1.52.0",
96
+ "@runwisp/common": "1.4.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,6 +37,10 @@
37
37
  // run that simply produced no output. Null/undefined renders nothing;
38
38
  // the caller clears it once a subsequent fetch succeeds.
39
39
  error?: string | null;
40
+ // The caller is still fetching this console's first output (e.g. the
41
+ // run panel seeding its tail). Shows the loading state rather than
42
+ // "No output yet" until that output arrives.
43
+ loading?: boolean;
40
44
  // Wrap long lines instead of horizontally scrolling them. When on, each
41
45
  // rendered row's height becomes a multiple of `lineHeight` (one per
42
46
  // wrapped visual row), so the virtualizer switches from the fixed-height
@@ -55,6 +59,7 @@
55
59
  endLabel = "end of output",
56
60
  endTone = "muted",
57
61
  error = null,
62
+ loading = false,
58
63
  wrap = $bindable(false),
59
64
  }: Props = $props();
60
65
 
@@ -798,7 +803,7 @@
798
803
  {/each}
799
804
  {/if}
800
805
 
801
- {#if cache.totalLines === 0 && overlayRows.length === 0 && !fetcher?.isFetching}
806
+ {#if cache.totalLines === 0 && overlayRows.length === 0 && !loading && !fetcher?.isFetching}
802
807
  <div
803
808
  class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
804
809
  >
@@ -809,7 +814,7 @@
809
814
  </div>
810
815
  {/if}
811
816
 
812
- {#if cache.totalLines === 0 && fetcher?.isFetching}
817
+ {#if cache.totalLines === 0 && (loading || fetcher?.isFetching)}
813
818
  <div
814
819
  class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
815
820
  >
@@ -30,7 +30,7 @@
30
30
 
31
31
  {#if shape === "circle"}
32
32
  <div
33
- class="animate-pulse rounded-full bg-surface-sunken {className}"
33
+ class="animate-pulse rounded-full bg-outline-hover {className}"
34
34
  style="width: {width ?? '2.5rem'}; height: {height ?? width ?? '2.5rem'}"
35
35
  role="status"
36
36
  aria-label="Loading"
@@ -39,7 +39,7 @@
39
39
  </div>
40
40
  {:else if shape === "rect"}
41
41
  <div
42
- class="animate-pulse rounded-[4px] bg-surface-sunken {className}"
42
+ class="animate-pulse rounded-[4px] bg-outline-hover {className}"
43
43
  style="width: {width ?? '100%'}; height: {height ?? '2rem'}"
44
44
  role="status"
45
45
  aria-label="Loading"
@@ -50,8 +50,8 @@
50
50
  <div class="animate-pulse space-y-6 p-6 {className}" role="status" aria-label="Loading content">
51
51
  {#if header}
52
52
  <div class="space-y-3">
53
- <div class="h-7 w-48 rounded-[4px] bg-surface-sunken"></div>
54
- <div class="h-4 w-72 rounded-[3px] bg-surface-sunken"></div>
53
+ <div class="h-7 w-48 rounded-[4px] bg-outline-hover"></div>
54
+ <div class="h-4 w-72 rounded-[3px] bg-outline-hover"></div>
55
55
  </div>
56
56
  {/if}
57
57
 
@@ -59,11 +59,11 @@
59
59
  {#each Array.from({ length: rows }, (_, i) => i) as i (i)}
60
60
  <div class="space-y-2">
61
61
  <div
62
- class="h-4 rounded-[3px] bg-surface-sunken"
62
+ class="h-4 rounded-[3px] bg-outline-hover"
63
63
  style="width: {70 + ((i * 17) % 30)}%"
64
64
  ></div>
65
65
  <div
66
- class="h-4 rounded-[3px] bg-surface-sunken"
66
+ class="h-4 rounded-[3px] bg-outline-hover"
67
67
  style="width: {40 + ((i * 23) % 40)}%"
68
68
  ></div>
69
69
  </div>
@@ -8,7 +8,7 @@
8
8
  Check,
9
9
  Terminal as TerminalIcon,
10
10
  Download,
11
- Trash2,
11
+ Trash,
12
12
  Play,
13
13
  Square,
14
14
  MousePointerClick,
@@ -67,6 +67,7 @@
67
67
  getInstanceCount = () => 1,
68
68
  motion,
69
69
  notFound = false,
70
+ loading = false,
70
71
  }: {
71
72
  run: Run | undefined;
72
73
  fetchLogs: (
@@ -121,6 +122,9 @@
121
122
  // or never existed). The empty state then says so plainly instead of the
122
123
  // generic "Select a run" — the caller must not silently substitute another.
123
124
  notFound?: boolean;
125
+ // True while there is no run to show *yet* (the run list or a deep-linked
126
+ // run is still loading), so the empty state doesn't claim "No runs yet".
127
+ loading?: boolean;
124
128
  } = $props();
125
129
 
126
130
  function easeInFresh(node: HTMLElement, runId: string) {
@@ -170,6 +174,10 @@
170
174
  // changes, not on every run object reference swap from SSE array updates.
171
175
  let runId = $derived(run?.id);
172
176
 
177
+ // The run whose seed fetch has settled. Until it matches the shown run the
178
+ // console says it's loading, not "No output yet".
179
+ let seededRunId = $state<string | null>(null);
180
+
173
181
  // Fetches the batched tail page a run's console opens on (see the effect
174
182
  // below) and paints it into the console. Resolves the line the live
175
183
  // stream should resume from, or null when there's nothing left to stream
@@ -201,7 +209,10 @@
201
209
  if (!id) return;
202
210
 
203
211
  const stream = streamLogs;
204
- if (!stream) return;
212
+ if (!stream) {
213
+ seededRunId = id;
214
+ return;
215
+ }
205
216
 
206
217
  logFetchError = null;
207
218
 
@@ -217,7 +228,9 @@
217
228
  // (its disk backfill from that anchor closes the fetch↔stream
218
229
  // race, and the daemon dedupes anything already shown).
219
230
  const seeded = await seedConsoleTail(id, () => cancelled);
220
- if (cancelled || !seeded) return;
231
+ if (cancelled) return;
232
+ seededRunId = id;
233
+ if (!seeded) return;
221
234
  cleanup = stream(
222
235
  id,
223
236
  (event: LogEvent) => {
@@ -278,7 +291,8 @@
278
291
  displayed === "crashed" ||
279
292
  displayed === "log_overflow" ||
280
293
  displayed === "missed" ||
281
- displayed === "start_failed"
294
+ displayed === "start_failed" ||
295
+ displayed === "unhealthy"
282
296
  )
283
297
  return "var(--color-danger-surface)";
284
298
  if (displayed === "timeout" || displayed === "daemon_stopped" || displayed === "queue_full")
@@ -690,7 +704,7 @@
690
704
  title="Delete this run"
691
705
  aria-label="Delete run"
692
706
  >
693
- <Trash2 size={15} />
707
+ <Trash size={15} />
694
708
  </span>
695
709
  {/snippet}
696
710
  <div class="w-60">
@@ -816,10 +830,43 @@
816
830
  {endLabel}
817
831
  {endTone}
818
832
  error={logFetchError}
833
+ loading={seededRunId !== run.id}
819
834
  />
820
835
  {/key}
821
836
  </div>
822
837
  </div>
838
+ {:else if loading && !notFound}
839
+ <!-- Loading: the panel's own frame (verdict header over the console) with
840
+ placeholders where the run's facts go, so nothing jumps when it lands. -->
841
+ <div
842
+ class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
843
+ role="status"
844
+ aria-label="Loading run"
845
+ >
846
+ <div class="shrink-0 border-b border-outline-faint bg-surface-raised">
847
+ <div class="pt-[18px] pr-[22px] pb-[16px] pl-[26px]">
848
+ <div class="flex h-[33px] items-center gap-2.5">
849
+ <span class="size-[18px] animate-pulse rounded-full bg-outline-hover"></span>
850
+ <span class="h-[18px] w-40 animate-pulse rounded-[3px] bg-outline-hover"></span>
851
+ </div>
852
+ <div class="mt-2.5 flex h-[17px] items-center">
853
+ <span class="h-3 w-64 max-w-full animate-pulse rounded-[3px] bg-outline-hover"
854
+ ></span>
855
+ </div>
856
+ </div>
857
+ </div>
858
+ <div
859
+ class="ml-1 flex min-h-[300px] flex-1 flex-col overflow-hidden border-t border-[var(--rw-con-gutter)] bg-[var(--rw-con-bg)]"
860
+ >
861
+ <div
862
+ class="flex shrink-0 items-center gap-2 border-b border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)] px-3.5 py-[9px] font-mono text-[11.5px] font-semibold text-[var(--rw-con-text)]"
863
+ >
864
+ <TerminalIcon size={14} class="opacity-70" />
865
+ Console output
866
+ </div>
867
+ <LogConsole fetchLogs={() => undefined} loading class="min-h-0 flex-1" />
868
+ </div>
869
+ </div>
823
870
  {:else}
824
871
  <div
825
872
  class="flex min-w-0 flex-1 flex-col items-center justify-center gap-4 bg-surface-sunken/30"
@@ -10,7 +10,7 @@
10
10
  </script>
11
11
 
12
12
  <script lang="ts">
13
- import { Clock, ArrowUpDown, X, Square, Trash2, RotateCw } from "@lucide/svelte";
13
+ import { Clock, ArrowUpDown, X, Square, Trash, RotateCw } from "@lucide/svelte";
14
14
  import { untrack } from "svelte";
15
15
  import { SvelteSet } from "svelte/reactivity";
16
16
  import { createVirtualizer } from "@tanstack/svelte-virtual";
@@ -459,7 +459,7 @@
459
459
  )}
460
460
  title="Delete run{selectionCount === 1 ? '' : 's'}"
461
461
  >
462
- {#snippet icon()}<Trash2 size={14} />{/snippet}
462
+ {#snippet icon()}<Trash size={14} />{/snippet}
463
463
  </Button>
464
464
  {/if}
465
465
  </div>
@@ -474,6 +474,8 @@
474
474
  >
475
475
  {#if outputSearchActive}
476
476
  {matchedRuns.length} of {items.length}
477
+ {:else if loading && items.length === 0}
478
+ <!-- Count unknown until the first page lands. -->
477
479
  {:else}
478
480
  {total}
479
481
  {total === 1 ? "run" : "runs"}
@@ -533,22 +535,7 @@
533
535
  <!-- Output-search results: the rail filters to runs that printed the
534
536
  query, each annotated with the matching line. -->
535
537
  {#if outputSearchPending}
536
- <!-- Searching: shimmer placeholders shaped like result rows. -->
537
- <div class="flex flex-col gap-0.5" aria-busy="true" aria-label="Searching output">
538
- {#each [0, 1, 2, 3, 4] as i (i)}
539
- <div class="flex items-center gap-2.5 rounded-[3px] px-3 py-2.5">
540
- <span
541
- class="size-[9px] shrink-0 animate-pulse rounded-full bg-surface-sunken"
542
- ></span>
543
- <span
544
- class="h-3 animate-pulse rounded bg-surface-sunken"
545
- style:width="{70 - i * 8}%"
546
- ></span>
547
- <span class="ml-auto h-3 w-9 animate-pulse rounded bg-surface-sunken"
548
- ></span>
549
- </div>
550
- {/each}
551
- </div>
538
+ {@render skeletonRows("Searching output")}
552
539
  {:else if matchedRuns.length === 0}
553
540
  <div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
554
541
  No output matches
@@ -571,7 +558,9 @@
571
558
  {/each}
572
559
  </div>
573
560
  {/if}
574
- {:else if items.length === 0 && !loading}
561
+ {:else if items.length === 0 && loading}
562
+ {@render skeletonRows("Loading runs")}
563
+ {:else if items.length === 0}
575
564
  <EmptyState
576
565
  title={emptyText}
577
566
  description={emptyDescription}
@@ -622,6 +611,46 @@
622
611
 
623
612
  <!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
624
613
  selection exists — so the row checkbox can take its place over it. -->
614
+ <!-- Placeholders shaped like this list's run rows (same padding, dot, text
615
+ and right readout), while a search or the first page is in flight. -->
616
+ {#snippet skeletonRows(label: string)}
617
+ <div class="flex flex-col gap-0.5" role="status" aria-busy="true" aria-label={label}>
618
+ {#each [0, 1, 2, 3, 4, 5] as i (i)}
619
+ <div
620
+ class="rounded-[3px] border border-transparent {showTaskName
621
+ ? 'p-3'
622
+ : 'px-3 py-[11px]'}"
623
+ >
624
+ <div class="flex items-center {showTaskName ? 'gap-2.5' : 'gap-[11px]'}">
625
+ <span class="size-[9px] shrink-0 animate-pulse rounded-full bg-outline-hover"
626
+ ></span>
627
+ {#if showTaskName}
628
+ <span class="flex min-w-0 flex-1 flex-col gap-1.5 py-0.5">
629
+ <span
630
+ class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
631
+ style:width="{62 - (i % 3) * 12}%"
632
+ ></span>
633
+ <span class="h-2 w-2/5 animate-pulse rounded-[3px] bg-outline-hover"
634
+ ></span>
635
+ </span>
636
+ <span
637
+ class="h-2.5 w-9 shrink-0 animate-pulse self-start rounded-[3px] bg-outline-hover"
638
+ ></span>
639
+ {:else}
640
+ <span
641
+ class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
642
+ style:width="{58 - (i % 3) * 8}%"
643
+ ></span>
644
+ <span
645
+ class="ml-auto h-2.5 w-9 shrink-0 animate-pulse rounded-[3px] bg-outline-hover"
646
+ ></span>
647
+ {/if}
648
+ </div>
649
+ </div>
650
+ {/each}
651
+ </div>
652
+ {/snippet}
653
+
625
654
  {#snippet statusDot(colorClass: string, running: boolean)}
626
655
  <span
627
656
  class="{colorClass} size-[9px] shrink-0 rounded-full bg-current ring-[3px] ring-current/20 {running
@@ -179,6 +179,7 @@ describe("STATUS_BUCKETS", () => {
179
179
  "timeout",
180
180
  "log_overflow",
181
181
  "start_failed",
182
+ "unhealthy",
182
183
  "missed",
183
184
  ];
184
185
 
@@ -29,6 +29,7 @@ const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
29
29
  daemon_stopped: { verb: "cut short after", timed: true },
30
30
  log_overflow: { verb: "killed after", timed: true },
31
31
  start_failed: { verb: "gave up after", timed: true },
32
+ unhealthy: { verb: "killed as unhealthy after", timed: true },
32
33
  ended: { verb: "ended after", timed: true },
33
34
  running: { verb: "running for", timed: true },
34
35
  pending: { verb: "queued", timed: false },
@@ -13,6 +13,7 @@ import {
13
13
  FileExclamationPoint,
14
14
  CalendarX,
15
15
  OctagonX,
16
+ HeartCrack,
16
17
  } from "@lucide/svelte";
17
18
  import type { Component } from "svelte";
18
19
  import { displayStatus, type RunStatus, type Run } from "@runwisp/common";
@@ -153,6 +154,16 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
153
154
  description:
154
155
  "The run (or service instance) kept failing; after restart_attempts consecutive failures, RunWisp gave up restarting it automatically.",
155
156
  },
157
+ unhealthy: {
158
+ icon: HeartCrack,
159
+ color: "text-danger-surface",
160
+ bg: "bg-danger-soft",
161
+ border: "border-danger-soft",
162
+ dot: "bg-danger-surface",
163
+ badge: "bg-danger-soft text-danger-soft-text",
164
+ description:
165
+ "The service instance kept failing its health_check, so RunWisp stopped it. Its restart policy decides what happens next.",
166
+ },
156
167
  pending: {
157
168
  icon: Clock,
158
169
  color: "text-on-surface-muted",
package/src/lib/theme.css CHANGED
@@ -259,7 +259,17 @@ h3 {
259
259
  font-weight: 800;
260
260
  line-height: 1.1;
261
261
  letter-spacing: -0.02em;
262
- text-wrap: balance;
262
+ }
263
+
264
+ /* Layered, unlike the rule above: text-wrap sets the same property as
265
+ white-space, so unlayered it would beat the `truncate` utility and wrap a
266
+ heading that asked to be cut to one line. */
267
+ @layer base {
268
+ h1,
269
+ h2,
270
+ h3 {
271
+ text-wrap: balance;
272
+ }
263
273
  }
264
274
 
265
275
  /* Utility classes for the design system */
@@ -4,7 +4,7 @@
4
4
  <script module>
5
5
  import { defineMeta } from "@storybook/addon-svelte-csf";
6
6
  import Button from "$lib/components/Button.svelte";
7
- import { Play, Plus, ArrowRight, Download, Trash2 } from "@lucide/svelte";
7
+ import { Play, Plus, ArrowRight, Download, Trash } from "@lucide/svelte";
8
8
 
9
9
  const { Story } = defineMeta({
10
10
  title: "Core/Button",
@@ -71,7 +71,7 @@
71
71
  Export
72
72
  </Button>
73
73
  <Button variant="danger">
74
- {#snippet icon()}<Trash2 size={16} />{/snippet}
74
+ {#snippet icon()}<Trash size={16} />{/snippet}
75
75
  Delete
76
76
  </Button>
77
77
  </div>
@@ -9,7 +9,7 @@
9
9
  import FilterBar from "$lib/components/FilterBar.svelte";
10
10
  import { seedValues } from "$lib/components/filter-spec.js";
11
11
  import { applyFilters } from "$lib/utils/filter.js";
12
- import { Play, Pause, Trash2, SquarePen } from "@lucide/svelte";
12
+ import { Play, Pause, Trash, SquarePen } from "@lucide/svelte";
13
13
 
14
14
  const { Story } = defineMeta({
15
15
  title: "Data/DataGrid",
@@ -75,7 +75,7 @@
75
75
  { label: "Run Now", icon: Play },
76
76
  { label: "Pause", icon: Pause },
77
77
  { divider: true },
78
- { label: "Delete", icon: Trash2, danger: true },
78
+ { label: "Delete", icon: Trash, danger: true },
79
79
  ];
80
80
 
81
81
  /** @type {import("$lib/components/filter-spec.js").FilterField[]} */
@@ -4,7 +4,7 @@
4
4
  <script module>
5
5
  import { defineMeta } from "@storybook/addon-svelte-csf";
6
6
  import Dropdown from "$lib/components/Dropdown.svelte";
7
- import { Play, Pause, SquarePen, Copy, Trash2, Settings, ChevronDown } from "@lucide/svelte";
7
+ import { Play, Pause, SquarePen, Copy, Trash, Settings, ChevronDown } from "@lucide/svelte";
8
8
 
9
9
  const { Story } = defineMeta({
10
10
  title: "Core/Dropdown",
@@ -24,7 +24,7 @@
24
24
  { label: "Duplicate", icon: Copy, onClick: () => console.log("Duplicate") },
25
25
  { label: "Pause", icon: Pause, onClick: () => console.log("Pause") },
26
26
  { divider: true },
27
- { label: "Delete", icon: Trash2, danger: true, onClick: () => console.log("Delete") },
27
+ { label: "Delete", icon: Trash, danger: true, onClick: () => console.log("Delete") },
28
28
  ];
29
29
 
30
30
  const settingsMenu = [
@@ -63,7 +63,7 @@
63
63
  { label: "Edit", icon: SquarePen },
64
64
  { label: "Duplicate", icon: Copy, disabled: true },
65
65
  { divider: true },
66
- { label: "Delete", icon: Trash2, danger: true, disabled: true },
66
+ { label: "Delete", icon: Trash, danger: true, disabled: true },
67
67
  ],
68
68
  }}
69
69
  />