@runwisp/ui 1.3.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 +5 -6
- 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 +37 -34
- 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/Skeleton.svelte +6 -6
- 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 +141 -67
- package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
- package/src/lib/components/dashboard/RunsList.svelte +57 -37
- 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/Button.stories.svelte +2 -2
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +2 -7
- package/src/stories/Dropdown.stories.svelte +3 -3
- 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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
</script>
|
|
11
11
|
|
|
12
12
|
<script lang="ts">
|
|
13
|
-
import { Clock, ArrowUpDown, X, Square,
|
|
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";
|
|
@@ -19,9 +19,8 @@
|
|
|
19
19
|
import type { RunMotion } from "../../utils/run-motion.js";
|
|
20
20
|
import EmptyState from "../EmptyState.svelte";
|
|
21
21
|
import RunFilterPopover from "./RunFilterPopover.svelte";
|
|
22
|
-
import type
|
|
23
|
-
import
|
|
24
|
-
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
22
|
+
import { displayStatus, type Run, type RunSelector, type RunStatus } from "@runwisp/common";
|
|
23
|
+
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
25
24
|
import {
|
|
26
25
|
activeDimensions,
|
|
27
26
|
clearDimension,
|
|
@@ -71,7 +70,6 @@
|
|
|
71
70
|
onBulkRerun,
|
|
72
71
|
getInstanceCount = () => 1,
|
|
73
72
|
motion,
|
|
74
|
-
flush = false,
|
|
75
73
|
outputSearch = false,
|
|
76
74
|
outputQuery = "",
|
|
77
75
|
outputMatches = null,
|
|
@@ -105,11 +103,6 @@
|
|
|
105
103
|
// Which runs arrived or were removed live moments ago: their rows animate
|
|
106
104
|
// in or out. Page loads, scrolling and filtering never do.
|
|
107
105
|
motion?: RunMotion;
|
|
108
|
-
// Flush rail mode (task detail page): render as a borderless rail that
|
|
109
|
-
// fills its column and is divided from the detail panel by a single right
|
|
110
|
-
// border — no card chrome of its own. Default renders the standalone card
|
|
111
|
-
// the cross-task /runs grid expects.
|
|
112
|
-
flush?: boolean;
|
|
113
106
|
// Output search (history rail): filters runs by what they printed. The
|
|
114
107
|
// search box lives in the app header now; the parent owns the box, the
|
|
115
108
|
// query, and the async log search. This component just renders the
|
|
@@ -392,17 +385,15 @@
|
|
|
392
385
|
});
|
|
393
386
|
</script>
|
|
394
387
|
|
|
388
|
+
<!-- A borderless rail that fills its column, divided from the detail panel by
|
|
389
|
+
a single right border. -->
|
|
395
390
|
<div
|
|
396
|
-
class=
|
|
397
|
-
? "flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
|
|
398
|
-
: "flex flex-col overflow-hidden rounded-[4px] border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
|
|
391
|
+
class="flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
|
|
399
392
|
>
|
|
400
393
|
<div
|
|
401
394
|
class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
|
|
402
395
|
? 'border-outline-faint bg-primary-soft/40'
|
|
403
|
-
:
|
|
404
|
-
? 'border-transparent bg-surface'
|
|
405
|
-
: 'border-outline-faint bg-surface-sunken'}"
|
|
396
|
+
: 'border-transparent bg-surface'}"
|
|
406
397
|
>
|
|
407
398
|
{#if bulkActions}
|
|
408
399
|
<label
|
|
@@ -459,7 +450,7 @@
|
|
|
459
450
|
)}
|
|
460
451
|
title="Delete run{selectionCount === 1 ? '' : 's'}"
|
|
461
452
|
>
|
|
462
|
-
{#snippet icon()}<
|
|
453
|
+
{#snippet icon()}<Trash size={14} />{/snippet}
|
|
463
454
|
</Button>
|
|
464
455
|
{/if}
|
|
465
456
|
</div>
|
|
@@ -474,6 +465,8 @@
|
|
|
474
465
|
>
|
|
475
466
|
{#if outputSearchActive}
|
|
476
467
|
{matchedRuns.length} of {items.length}
|
|
468
|
+
{:else if loading && items.length === 0}
|
|
469
|
+
<!-- Count unknown until the first page lands. -->
|
|
477
470
|
{:else}
|
|
478
471
|
{total}
|
|
479
472
|
{total === 1 ? "run" : "runs"}
|
|
@@ -481,7 +474,7 @@
|
|
|
481
474
|
</span>
|
|
482
475
|
<div class="ml-auto flex items-center gap-1">
|
|
483
476
|
{#if showFilters}
|
|
484
|
-
<RunFilterPopover bind:filters {showTask} {tasks}
|
|
477
|
+
<RunFilterPopover bind:filters {showTask} {tasks} />
|
|
485
478
|
{/if}
|
|
486
479
|
<Button
|
|
487
480
|
variant="ghost"
|
|
@@ -533,22 +526,7 @@
|
|
|
533
526
|
<!-- Output-search results: the rail filters to runs that printed the
|
|
534
527
|
query, each annotated with the matching line. -->
|
|
535
528
|
{#if outputSearchPending}
|
|
536
|
-
|
|
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>
|
|
529
|
+
{@render skeletonRows("Searching output")}
|
|
552
530
|
{:else if matchedRuns.length === 0}
|
|
553
531
|
<div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
|
|
554
532
|
No output matches
|
|
@@ -571,7 +549,9 @@
|
|
|
571
549
|
{/each}
|
|
572
550
|
</div>
|
|
573
551
|
{/if}
|
|
574
|
-
{:else if items.length === 0 &&
|
|
552
|
+
{:else if items.length === 0 && loading}
|
|
553
|
+
{@render skeletonRows("Loading runs")}
|
|
554
|
+
{:else if items.length === 0}
|
|
575
555
|
<EmptyState
|
|
576
556
|
title={emptyText}
|
|
577
557
|
description={emptyDescription}
|
|
@@ -622,6 +602,46 @@
|
|
|
622
602
|
|
|
623
603
|
<!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
|
|
624
604
|
selection exists — so the row checkbox can take its place over it. -->
|
|
605
|
+
<!-- Placeholders shaped like this list's run rows (same padding, dot, text
|
|
606
|
+
and right readout), while a search or the first page is in flight. -->
|
|
607
|
+
{#snippet skeletonRows(label: string)}
|
|
608
|
+
<div class="flex flex-col gap-0.5" role="status" aria-busy="true" aria-label={label}>
|
|
609
|
+
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
|
610
|
+
<div
|
|
611
|
+
class="rounded-[3px] border border-transparent {showTaskName
|
|
612
|
+
? 'p-3'
|
|
613
|
+
: 'px-3 py-[11px]'}"
|
|
614
|
+
>
|
|
615
|
+
<div class="flex items-center {showTaskName ? 'gap-2.5' : 'gap-[11px]'}">
|
|
616
|
+
<span class="size-[9px] shrink-0 animate-pulse rounded-full bg-outline-hover"
|
|
617
|
+
></span>
|
|
618
|
+
{#if showTaskName}
|
|
619
|
+
<span class="flex min-w-0 flex-1 flex-col gap-1.5 py-0.5">
|
|
620
|
+
<span
|
|
621
|
+
class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
|
|
622
|
+
style:width="{62 - (i % 3) * 12}%"
|
|
623
|
+
></span>
|
|
624
|
+
<span class="h-2 w-2/5 animate-pulse rounded-[3px] bg-outline-hover"
|
|
625
|
+
></span>
|
|
626
|
+
</span>
|
|
627
|
+
<span
|
|
628
|
+
class="h-2.5 w-9 shrink-0 animate-pulse self-start rounded-[3px] bg-outline-hover"
|
|
629
|
+
></span>
|
|
630
|
+
{:else}
|
|
631
|
+
<span
|
|
632
|
+
class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
|
|
633
|
+
style:width="{58 - (i % 3) * 8}%"
|
|
634
|
+
></span>
|
|
635
|
+
<span
|
|
636
|
+
class="ml-auto h-2.5 w-9 shrink-0 animate-pulse rounded-[3px] bg-outline-hover"
|
|
637
|
+
></span>
|
|
638
|
+
{/if}
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
{/each}
|
|
642
|
+
</div>
|
|
643
|
+
{/snippet}
|
|
644
|
+
|
|
625
645
|
{#snippet statusDot(colorClass: string, running: boolean)}
|
|
626
646
|
<span
|
|
627
647
|
class="{colorClass} size-[9px] shrink-0 rounded-full bg-current ring-[3px] ring-current/20 {running
|
|
@@ -659,8 +679,8 @@
|
|
|
659
679
|
{/snippet}
|
|
660
680
|
|
|
661
681
|
{#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
|
|
662
|
-
{@const dstatus =
|
|
663
|
-
{@const config =
|
|
682
|
+
{@const dstatus = displayStatus(run.status, run.endReason)}
|
|
683
|
+
{@const config = RUN_STATUS_CONFIG[dstatus]}
|
|
664
684
|
{@const running = run.status === "running"}
|
|
665
685
|
{@const spine = config.dot.replace(" animate-pulse", "")}
|
|
666
686
|
{@const startedAt = run.startedAt ?? run.createdAt}
|
|
@@ -6,13 +6,10 @@ import { END_REASONS } from "@runwisp/common";
|
|
|
6
6
|
import {
|
|
7
7
|
emptyRunFilters,
|
|
8
8
|
FAILURE_STATUS_TOKEN,
|
|
9
|
-
NEEDS_ATTENTION_STATUSES,
|
|
10
|
-
isNeedsAttention,
|
|
11
9
|
dimensionActive,
|
|
12
10
|
activeDimensions,
|
|
13
11
|
activeFilterCount,
|
|
14
12
|
clearDimension,
|
|
15
|
-
clearPopoverFilters,
|
|
16
13
|
humanizeStatus,
|
|
17
14
|
triggerDescription,
|
|
18
15
|
FILTERABLE_TRIGGERS,
|
|
@@ -29,7 +26,6 @@ import {
|
|
|
29
26
|
exitCodeRangeActive,
|
|
30
27
|
isExitCodeExprValid,
|
|
31
28
|
exitCodeChipLabel,
|
|
32
|
-
exitCodePropChanged,
|
|
33
29
|
type StatusBucket,
|
|
34
30
|
type RunsListFilters,
|
|
35
31
|
} from "./run-filters.js";
|
|
@@ -54,23 +50,6 @@ describe("emptyRunFilters", () => {
|
|
|
54
50
|
});
|
|
55
51
|
});
|
|
56
52
|
|
|
57
|
-
describe("isNeedsAttention", () => {
|
|
58
|
-
it("is true for exactly the attention set, order-insensitive", () => {
|
|
59
|
-
expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES])).toBe(true);
|
|
60
|
-
expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES].reverse())).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("is exactly the failure sentinel (server resolves it to is_failure)", () => {
|
|
64
|
-
expect(NEEDS_ATTENTION_STATUSES).toEqual([FAILURE_STATUS_TOKEN]);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("is false for a raw end reason or a different set", () => {
|
|
68
|
-
expect(isNeedsAttention(["failed"])).toBe(false);
|
|
69
|
-
expect(isNeedsAttention(["succeeded"])).toBe(false);
|
|
70
|
-
expect(isNeedsAttention([])).toBe(false);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
53
|
describe("dimensionActive / activeDimensions", () => {
|
|
75
54
|
it("detects each dimension independently", () => {
|
|
76
55
|
expect(dimensionActive(base({ statuses: ["failed"] }), "status")).toBe(true);
|
|
@@ -125,25 +104,6 @@ describe("clearDimension", () => {
|
|
|
125
104
|
});
|
|
126
105
|
});
|
|
127
106
|
|
|
128
|
-
describe("clearPopoverFilters", () => {
|
|
129
|
-
it("clears every popover dimension but keeps search and sort", () => {
|
|
130
|
-
const f = base({
|
|
131
|
-
search: "nightly",
|
|
132
|
-
sortDirection: "asc",
|
|
133
|
-
statuses: ["failed"],
|
|
134
|
-
taskName: "backup",
|
|
135
|
-
triggeredBy: "cron",
|
|
136
|
-
exitCode: "1",
|
|
137
|
-
retriesOnly: true,
|
|
138
|
-
createdAfter: "2026-01-01T00:00:00Z",
|
|
139
|
-
});
|
|
140
|
-
const cleared = clearPopoverFilters(f);
|
|
141
|
-
expect(activeFilterCount(cleared)).toBe(0);
|
|
142
|
-
expect(cleared.search).toBe("nightly");
|
|
143
|
-
expect(cleared.sortDirection).toBe("asc");
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
107
|
describe("humanizeStatus", () => {
|
|
148
108
|
it("title-cases and de-snakes a status token", () => {
|
|
149
109
|
expect(humanizeStatus("log_overflow")).toBe("Log overflow");
|
|
@@ -154,8 +114,8 @@ describe("humanizeStatus", () => {
|
|
|
154
114
|
|
|
155
115
|
describe("statusChipLabel", () => {
|
|
156
116
|
it("names a bucket when the selection matches one exactly", () => {
|
|
157
|
-
// The Failed bucket is the
|
|
158
|
-
expect(statusChipLabel([
|
|
117
|
+
// The Failed bucket is the failure sentinel.
|
|
118
|
+
expect(statusChipLabel([FAILURE_STATUS_TOKEN])).toBe("Failed");
|
|
159
119
|
expect(statusChipLabel(["pending", "running"])).toBe("Running");
|
|
160
120
|
expect(statusChipLabel(["succeeded"])).toBe("Succeeded");
|
|
161
121
|
});
|
|
@@ -332,19 +292,3 @@ describe("exit-code expression", () => {
|
|
|
332
292
|
expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
|
|
333
293
|
});
|
|
334
294
|
});
|
|
335
|
-
|
|
336
|
-
describe("exitCodePropChanged", () => {
|
|
337
|
-
it("is false when the committed value is unchanged (unrelated filters update)", () => {
|
|
338
|
-
expect(exitCodePropChanged("137", "137")).toBe(false);
|
|
339
|
-
expect(exitCodePropChanged(undefined, undefined)).toBe(false);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("is true when the exit-code filter was cleared externally (chip removed)", () => {
|
|
343
|
-
expect(exitCodePropChanged("137", undefined)).toBe(true);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it("is true when the exit-code filter was set to a new committed value", () => {
|
|
347
|
-
expect(exitCodePropChanged(undefined, "200")).toBe(true);
|
|
348
|
-
expect(exitCodePropChanged("137", "200")).toBe(true);
|
|
349
|
-
});
|
|
350
|
-
});
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
import { TRIGGERS, type Trigger } from "@runwisp/common";
|
|
5
5
|
|
|
6
|
-
export type RunsListSortDirection = "asc" | "desc" | "";
|
|
7
|
-
|
|
8
6
|
/**
|
|
9
7
|
* The single filter shape shared by the runs list, the filter popover, the
|
|
10
8
|
* SSE-merge source, and the bulk selector. Every field beyond `search` and
|
|
@@ -19,7 +17,7 @@ export type RunsListSortDirection = "asc" | "desc" | "";
|
|
|
19
17
|
export interface RunsListFilters {
|
|
20
18
|
search: string;
|
|
21
19
|
statuses: string[];
|
|
22
|
-
sortDirection:
|
|
20
|
+
sortDirection: "asc" | "desc" | "";
|
|
23
21
|
// Optional dimensions explicitly admit `undefined` so a dimension can be
|
|
24
22
|
// cleared by reassignment (`{ ...f, x: undefined }`) under the project's
|
|
25
23
|
// exactOptionalPropertyTypes — a fresh object reference is what re-triggers
|
|
@@ -52,21 +50,6 @@ export function emptyRunFilters(): RunsListFilters {
|
|
|
52
50
|
*/
|
|
53
51
|
export const FAILURE_STATUS_TOKEN = "failure";
|
|
54
52
|
|
|
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];
|
|
62
|
-
|
|
63
|
-
/** True when `statuses` is exactly the needs-attention set (order-insensitive). */
|
|
64
|
-
export function isNeedsAttention(statuses: string[]): boolean {
|
|
65
|
-
if (statuses.length !== NEEDS_ATTENTION_STATUSES.length) return false;
|
|
66
|
-
const set = new Set(statuses);
|
|
67
|
-
return NEEDS_ATTENTION_STATUSES.every((s) => set.has(s));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
53
|
/**
|
|
71
54
|
* Outcome buckets: the 14 individual run statuses collapsed into five
|
|
72
55
|
* plain-language groups, so the common case is a five-item pick instead of a
|
|
@@ -74,8 +57,9 @@ export function isNeedsAttention(statuses: string[]): boolean {
|
|
|
74
57
|
* one adds/removes its members, and the popover's "Advanced" section still
|
|
75
58
|
* exposes the individual statuses for surgical filters (e.g. only `timeout`).
|
|
76
59
|
*
|
|
77
|
-
* Every status appears in exactly one bucket; `Failed` is the
|
|
78
|
-
*
|
|
60
|
+
* Every status appears in exactly one bucket; `Failed` is the failure sentinel,
|
|
61
|
+
* which the server resolves to each run's `isFailure` bit. `dot` mirrors that
|
|
62
|
+
* group's color in
|
|
79
63
|
* RUN_STATUS_CONFIG so the bucket reads at a glance.
|
|
80
64
|
*/
|
|
81
65
|
export interface StatusBucket {
|
|
@@ -93,7 +77,7 @@ export const STATUS_BUCKETS: readonly StatusBucket[] = [
|
|
|
93
77
|
key: "failed",
|
|
94
78
|
label: "Failed",
|
|
95
79
|
dot: "bg-danger-surface",
|
|
96
|
-
statuses:
|
|
80
|
+
statuses: [FAILURE_STATUS_TOKEN],
|
|
97
81
|
},
|
|
98
82
|
{
|
|
99
83
|
key: "skipped",
|
|
@@ -199,23 +183,6 @@ export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsLi
|
|
|
199
183
|
}
|
|
200
184
|
}
|
|
201
185
|
|
|
202
|
-
/**
|
|
203
|
-
* Clear every popover dimension at once, preserving the header search and the
|
|
204
|
-
* sort direction (those are separate toolbar controls, not popover state).
|
|
205
|
-
*/
|
|
206
|
-
export function clearPopoverFilters(f: RunsListFilters): RunsListFilters {
|
|
207
|
-
return {
|
|
208
|
-
...f,
|
|
209
|
-
statuses: [],
|
|
210
|
-
createdAfter: undefined,
|
|
211
|
-
createdBefore: undefined,
|
|
212
|
-
taskName: undefined,
|
|
213
|
-
triggeredBy: undefined,
|
|
214
|
-
exitCode: undefined,
|
|
215
|
-
retriesOnly: undefined,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
186
|
/** Human label for a status value, e.g. `log_overflow` → "Log overflow". */
|
|
220
187
|
export function humanizeStatus(status: string): string {
|
|
221
188
|
if (!status) return status;
|
|
@@ -401,18 +368,3 @@ export function isExitCodeExprValid(expr: string): boolean {
|
|
|
401
368
|
export function exitCodeChipLabel(expr: string | undefined): string {
|
|
402
369
|
return `Exit ${(expr ?? "").trim()}`;
|
|
403
370
|
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Whether the popover's exit-code text buffer must resync to the `filters`
|
|
407
|
-
* prop. True only when the committed `exitCode` value actually moved since
|
|
408
|
-
* the buffer last matched it — e.g. the "Exit code" chip was removed, or
|
|
409
|
-
* "clear filters" ran elsewhere. An unrelated filters update (any other
|
|
410
|
-
* dimension) leaves `exitCode` untouched and must not clobber in-progress
|
|
411
|
-
* typing in the still-open popover.
|
|
412
|
-
*/
|
|
413
|
-
export function exitCodePropChanged(
|
|
414
|
-
previous: string | undefined,
|
|
415
|
-
next: string | undefined,
|
|
416
|
-
): boolean {
|
|
417
|
-
return previous !== next;
|
|
418
|
-
}
|
|
@@ -2,23 +2,17 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { RUN_STATUSES } from "@runwisp/common";
|
|
6
5
|
import {
|
|
7
6
|
runDuration,
|
|
8
7
|
runStartDelay,
|
|
9
8
|
runVerdict,
|
|
10
9
|
formatTriggeredByLabel,
|
|
11
10
|
runRetryLabel,
|
|
11
|
+
runUsageLabel,
|
|
12
12
|
instanceSuffix,
|
|
13
13
|
} from "./run-helpers.js";
|
|
14
14
|
|
|
15
15
|
describe("runVerdict", () => {
|
|
16
|
-
it("covers every run status", () => {
|
|
17
|
-
for (const status of RUN_STATUSES) {
|
|
18
|
-
expect(runVerdict(status).verb, status).toBeTruthy();
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
16
|
it("phrases a timed outcome so a duration reads after it", () => {
|
|
23
17
|
expect(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
|
|
24
18
|
expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
|
|
@@ -116,6 +110,22 @@ describe("formatTriggeredByLabel", () => {
|
|
|
116
110
|
});
|
|
117
111
|
});
|
|
118
112
|
|
|
113
|
+
describe("runUsageLabel", () => {
|
|
114
|
+
it("is undefined for an unmeasured run", () => {
|
|
115
|
+
expect(runUsageLabel({})).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("joins peak memory and CPU time", () => {
|
|
119
|
+
expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 1500 })).toBe(
|
|
120
|
+
"peak 48 MB · CPU 1s",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("shows whichever was measured", () => {
|
|
125
|
+
expect(runUsageLabel({ cpuTimeMs: 0 })).toBe("CPU 0ms");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
119
129
|
describe("runRetryLabel", () => {
|
|
120
130
|
it("returns undefined for a first attempt that is not a retry", () => {
|
|
121
131
|
expect(runRetryLabel({ retryAttempt: 0 })).toBeUndefined();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import type { Run, RunStatus } from "@runwisp/common";
|
|
5
|
-
import { formatDuration } from "../../utils/format.js";
|
|
5
|
+
import { formatBytes, formatDuration } from "../../utils/format.js";
|
|
6
6
|
|
|
7
7
|
export interface RunVerdict {
|
|
8
8
|
/** Verb phrase for the outcome, ending in its preposition when `timed`. */
|
|
@@ -107,6 +107,17 @@ export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">):
|
|
|
107
107
|
return undefined;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* "peak 48 MB · CPU 1s" for a run whose resources were measured (shell runs
|
|
112
|
+
* only), else undefined.
|
|
113
|
+
*/
|
|
114
|
+
export function runUsageLabel(run: Pick<Run, "peakMemoryBytes" | "cpuTimeMs">): string | undefined {
|
|
115
|
+
const parts: string[] = [];
|
|
116
|
+
if (run.peakMemoryBytes !== undefined) parts.push("peak " + formatBytes(run.peakMemoryBytes));
|
|
117
|
+
if (run.cpuTimeMs !== undefined) parts.push("CPU " + formatDuration(run.cpuTimeMs));
|
|
118
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
110
121
|
/**
|
|
111
122
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
112
123
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
|
@@ -16,25 +16,44 @@ import {
|
|
|
16
16
|
HeartCrack,
|
|
17
17
|
} from "@lucide/svelte";
|
|
18
18
|
import type { Component } from "svelte";
|
|
19
|
-
import {
|
|
19
|
+
import type { RunStatus } from "@runwisp/common";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
interface RunStatusConfig {
|
|
22
22
|
icon: Component;
|
|
23
23
|
color: string;
|
|
24
24
|
bg: string;
|
|
25
|
-
border: string;
|
|
26
25
|
dot: string;
|
|
27
26
|
badge: string;
|
|
28
27
|
/** One-sentence explanation of what this status means, for tooltips. */
|
|
29
28
|
description: string;
|
|
30
29
|
}
|
|
31
30
|
|
|
31
|
+
// Full literal class strings (not built from a tone name) so Tailwind's
|
|
32
|
+
// scanner picks them up.
|
|
33
|
+
const DANGER = {
|
|
34
|
+
color: "text-danger-surface",
|
|
35
|
+
bg: "bg-danger-soft",
|
|
36
|
+
dot: "bg-danger-surface",
|
|
37
|
+
badge: "bg-danger-soft text-danger-soft-text",
|
|
38
|
+
};
|
|
39
|
+
const WARNING = {
|
|
40
|
+
color: "text-warning-surface",
|
|
41
|
+
bg: "bg-warning-soft",
|
|
42
|
+
dot: "bg-warning-surface",
|
|
43
|
+
badge: "bg-warning-soft text-warning-soft-text",
|
|
44
|
+
};
|
|
45
|
+
const NEUTRAL = {
|
|
46
|
+
color: "text-on-surface-muted",
|
|
47
|
+
bg: "bg-surface-sunken",
|
|
48
|
+
dot: "bg-on-surface-faint",
|
|
49
|
+
badge: "bg-surface-sunken text-on-surface",
|
|
50
|
+
};
|
|
51
|
+
|
|
32
52
|
export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
33
53
|
running: {
|
|
34
54
|
icon: LoaderCircle,
|
|
35
55
|
color: "text-info-surface",
|
|
36
56
|
bg: "bg-info-soft",
|
|
37
|
-
border: "border-info-soft",
|
|
38
57
|
dot: "bg-info-surface animate-pulse",
|
|
39
58
|
badge: "bg-info-soft text-info-soft-text",
|
|
40
59
|
description: "This run is executing right now.",
|
|
@@ -43,151 +62,87 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
43
62
|
icon: CircleCheck,
|
|
44
63
|
color: "text-success-surface",
|
|
45
64
|
bg: "bg-success-soft",
|
|
46
|
-
border: "border-success-soft",
|
|
47
65
|
dot: "bg-success-surface",
|
|
48
66
|
badge: "bg-success-soft text-success-soft-text",
|
|
49
67
|
description: "The run finished with exit code 0 - everything OK.",
|
|
50
68
|
},
|
|
51
69
|
failed: {
|
|
70
|
+
...DANGER,
|
|
52
71
|
icon: CircleX,
|
|
53
|
-
color: "text-danger-surface",
|
|
54
|
-
bg: "bg-danger-soft",
|
|
55
|
-
border: "border-danger-soft",
|
|
56
|
-
dot: "bg-danger-surface",
|
|
57
|
-
badge: "bg-danger-soft text-danger-soft-text",
|
|
58
72
|
description: "The run exited with a non-zero code.",
|
|
59
73
|
},
|
|
60
74
|
crashed: {
|
|
75
|
+
...DANGER,
|
|
61
76
|
icon: CircleAlert,
|
|
62
|
-
color: "text-danger-surface",
|
|
63
|
-
bg: "bg-danger-soft",
|
|
64
|
-
border: "border-danger-soft",
|
|
65
|
-
dot: "bg-danger-surface",
|
|
66
|
-
badge: "bg-danger-soft text-danger-soft-text",
|
|
67
77
|
description:
|
|
68
78
|
"The process was killed, or the daemon found it still 'running' after a hard crash and marked it crashed. It was not resumed.",
|
|
69
79
|
},
|
|
70
80
|
stopped: {
|
|
81
|
+
...WARNING,
|
|
71
82
|
icon: CircleStop,
|
|
72
|
-
color: "text-warning-surface",
|
|
73
|
-
bg: "bg-warning-soft",
|
|
74
|
-
border: "border-warning-soft",
|
|
75
|
-
dot: "bg-warning-surface",
|
|
76
|
-
badge: "bg-warning-soft text-warning-soft-text",
|
|
77
83
|
description: "A human or an external script manually stopped this run before it finished.",
|
|
78
84
|
},
|
|
79
85
|
timeout: {
|
|
86
|
+
...WARNING,
|
|
80
87
|
icon: TimerOff,
|
|
81
|
-
color: "text-warning-surface",
|
|
82
|
-
bg: "bg-warning-soft",
|
|
83
|
-
border: "border-warning-soft",
|
|
84
|
-
dot: "bg-warning-surface",
|
|
85
|
-
badge: "bg-warning-soft text-warning-soft-text",
|
|
86
88
|
description:
|
|
87
89
|
"The run exceeded its configured timeout and was terminated. Timeout duration can be changed.",
|
|
88
90
|
},
|
|
89
91
|
skipped: {
|
|
92
|
+
...NEUTRAL,
|
|
90
93
|
icon: SkipForward,
|
|
91
|
-
color: "text-on-surface-muted",
|
|
92
|
-
bg: "bg-surface-sunken",
|
|
93
|
-
border: "border-outline-faint",
|
|
94
|
-
dot: "bg-on-surface-faint",
|
|
95
|
-
badge: "bg-surface-sunken text-on-surface",
|
|
96
94
|
description:
|
|
97
95
|
'Skipped by the concurrency policy (on_overlap = "skip") because a previous run was still going.',
|
|
98
96
|
},
|
|
99
97
|
log_overflow: {
|
|
98
|
+
...DANGER,
|
|
100
99
|
icon: FileExclamationPoint,
|
|
101
|
-
color: "text-danger-surface",
|
|
102
|
-
bg: "bg-danger-soft",
|
|
103
|
-
border: "border-danger-soft",
|
|
104
|
-
dot: "bg-danger-surface",
|
|
105
|
-
badge: "bg-danger-soft text-danger-soft-text",
|
|
106
100
|
description: "The run hit log_max_size and was handled because of log_on_full.",
|
|
107
101
|
},
|
|
108
102
|
queue_full: {
|
|
103
|
+
...WARNING,
|
|
109
104
|
icon: SkipForward,
|
|
110
|
-
color: "text-warning-surface",
|
|
111
|
-
bg: "bg-warning-soft",
|
|
112
|
-
border: "border-warning-soft",
|
|
113
|
-
dot: "bg-warning-surface",
|
|
114
|
-
badge: "bg-warning-soft text-warning-soft-text",
|
|
115
105
|
description: "Skipped because the task's queue was already at max_queued.",
|
|
116
106
|
},
|
|
117
107
|
dst_skipped: {
|
|
108
|
+
...NEUTRAL,
|
|
118
109
|
icon: SkipForward,
|
|
119
|
-
color: "text-on-surface-muted",
|
|
120
|
-
bg: "bg-surface-sunken",
|
|
121
|
-
border: "border-outline-faint",
|
|
122
|
-
dot: "bg-on-surface-faint",
|
|
123
|
-
badge: "bg-surface-sunken text-on-surface",
|
|
124
110
|
description:
|
|
125
111
|
"Skipped: this cron tick was the duplicate half of a DST fall-back, so it was recorded but not run.",
|
|
126
112
|
},
|
|
127
113
|
daemon_stopped: {
|
|
114
|
+
...WARNING,
|
|
128
115
|
icon: CircleStop,
|
|
129
|
-
color: "text-warning-surface",
|
|
130
|
-
bg: "bg-warning-soft",
|
|
131
|
-
border: "border-warning-soft",
|
|
132
|
-
dot: "bg-warning-surface",
|
|
133
|
-
badge: "bg-warning-soft text-warning-soft-text",
|
|
134
116
|
description:
|
|
135
117
|
"The daemon shut down while this run was in flight and it exceeded shutdown_timeout; it was not resumed.",
|
|
136
118
|
},
|
|
137
119
|
missed: {
|
|
120
|
+
...DANGER,
|
|
138
121
|
icon: CalendarX,
|
|
139
|
-
color: "text-danger-surface",
|
|
140
|
-
bg: "bg-danger-soft",
|
|
141
|
-
border: "border-danger-soft",
|
|
142
|
-
dot: "bg-danger-surface",
|
|
143
|
-
badge: "bg-danger-soft text-danger-soft-text",
|
|
144
122
|
description:
|
|
145
123
|
"A scheduled run never happened because the daemon was down. Detected and recorded on restart.",
|
|
146
124
|
},
|
|
147
125
|
start_failed: {
|
|
126
|
+
...DANGER,
|
|
148
127
|
icon: OctagonX,
|
|
149
|
-
color: "text-danger-surface",
|
|
150
|
-
bg: "bg-danger-soft",
|
|
151
|
-
border: "border-danger-soft",
|
|
152
|
-
dot: "bg-danger-surface",
|
|
153
|
-
badge: "bg-danger-soft text-danger-soft-text",
|
|
154
128
|
description:
|
|
155
129
|
"The run (or service instance) kept failing; after restart_attempts consecutive failures, RunWisp gave up restarting it automatically.",
|
|
156
130
|
},
|
|
157
131
|
unhealthy: {
|
|
132
|
+
...DANGER,
|
|
158
133
|
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
134
|
description:
|
|
165
135
|
"The service instance kept failing its health_check, so RunWisp stopped it. Its restart policy decides what happens next.",
|
|
166
136
|
},
|
|
167
137
|
pending: {
|
|
138
|
+
...NEUTRAL,
|
|
168
139
|
icon: Clock,
|
|
169
|
-
color: "text-on-surface-muted",
|
|
170
|
-
bg: "bg-surface-sunken",
|
|
171
|
-
border: "border-outline",
|
|
172
|
-
dot: "bg-on-surface-faint",
|
|
173
|
-
badge: "bg-surface-sunken text-on-surface",
|
|
174
140
|
description: "Queued and waiting to start.",
|
|
175
141
|
},
|
|
176
142
|
ended: {
|
|
177
|
-
|
|
143
|
+
...NEUTRAL,
|
|
178
144
|
color: "text-on-surface-faint",
|
|
179
|
-
|
|
180
|
-
border: "border-outline-faint",
|
|
181
|
-
dot: "bg-on-surface-faint",
|
|
182
|
-
badge: "bg-surface-sunken text-on-surface",
|
|
145
|
+
icon: CircleDashed,
|
|
183
146
|
description: "The run finished. No specific end reason was recorded.",
|
|
184
147
|
},
|
|
185
148
|
};
|
|
186
|
-
|
|
187
|
-
export function getRunStatusConfig(status: RunStatus): RunStatusConfig {
|
|
188
|
-
return RUN_STATUS_CONFIG[status];
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function runDisplayStatus(run: Pick<Run, "status" | "endReason">): RunStatus {
|
|
192
|
-
return displayStatus(run.status, run.endReason);
|
|
193
|
-
}
|