@runwisp/ui 1.4.0 → 1.5.1
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 -5
- package/src/lib/actions/floating.ts +73 -0
- package/src/lib/actions/focusTrap.ts +3 -3
- 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/Badge.svelte +1 -1
- package/src/lib/components/CodeBlock.svelte +84 -35
- package/src/lib/components/CronInput.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +100 -138
- 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/FilterBar.svelte +1 -1
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -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 +40 -42
- 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/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +16 -47
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/StatusDot.svelte +1 -1
- 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/Tooltip.svelte +1 -1
- package/src/lib/components/accordion.svelte.ts +19 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
- package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
- package/src/lib/components/dashboard/RunsList.svelte +17 -26
- package/src/lib/components/dashboard/cron-format.test.ts +1 -1
- package/src/lib/components/dashboard/cron-format.ts +3 -3
- package/src/lib/components/dashboard/run-filters.test.ts +4 -60
- package/src/lib/components/dashboard/run-filters.ts +29 -144
- package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
- package/src/lib/components/dashboard/run-helpers.ts +40 -39
- package/src/lib/components/dashboard/status-config.ts +38 -83
- package/src/lib/components/dashboard/types.ts +0 -26
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/components/radio-group.ts +11 -0
- package/src/lib/index.ts +8 -43
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
- package/src/lib/log-console/ansi.ts +1 -1
- 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-tokens.css +21 -21
- package/src/lib/theme.css +22 -16
- package/src/lib/utils/clipboard.svelte.test.ts +92 -0
- package/src/lib/utils/clipboard.svelte.ts +73 -0
- package/src/lib/utils/format.test.ts +3 -3
- 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 +5 -14
- package/src/stories/Accordion.stories.svelte +61 -0
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +0 -5
- 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/LogConsole.stories.svelte +2 -2
- 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
|
@@ -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
|
|
@@ -121,7 +114,7 @@
|
|
|
121
114
|
// null = no active search; the full list shows.
|
|
122
115
|
outputMatches?: Map<string, RunOutputMatch> | null;
|
|
123
116
|
// True while a query is typed but its results aren't in yet (debounce
|
|
124
|
-
// window or request in flight)
|
|
117
|
+
// window or request in flight), the rail shows its searching shimmer.
|
|
125
118
|
outputSearchPending?: boolean;
|
|
126
119
|
} = $props();
|
|
127
120
|
|
|
@@ -131,7 +124,7 @@
|
|
|
131
124
|
const OVERSCAN = 8;
|
|
132
125
|
const LOAD_AHEAD = 10;
|
|
133
126
|
|
|
134
|
-
// Selection model: two modes
|
|
127
|
+
// Selection model: two modes,
|
|
135
128
|
// 1. explicit: user picked specific rows. explicitIds holds them.
|
|
136
129
|
// 2. selectAll: "all matching the current filter". exceptIds holds opt-outs.
|
|
137
130
|
let selectAllMode = $state(false);
|
|
@@ -146,13 +139,13 @@
|
|
|
146
139
|
|
|
147
140
|
// Loaded runs that matched the output search, in list order. A match in a
|
|
148
141
|
// not-yet-loaded run can't be shown until the rail scrolls far enough to
|
|
149
|
-
// load it
|
|
142
|
+
// load it, the count reflects what's loaded, not the whole history.
|
|
150
143
|
const matchedRuns = $derived(
|
|
151
144
|
outputMatches ? items.filter((r: Run) => outputMatches.has(r.id)) : [],
|
|
152
145
|
);
|
|
153
146
|
|
|
154
147
|
// Split the matching line into [before, match, after] around the first
|
|
155
|
-
// occurrence of the query, windowed to keep the match in view
|
|
148
|
+
// occurrence of the query, windowed to keep the match in view, mirrors the
|
|
156
149
|
// artifact's snippet. Rendered as plain text spans (Svelte auto-escapes), so
|
|
157
150
|
// no untrusted HTML ever reaches the DOM.
|
|
158
151
|
interface HighlightParts {
|
|
@@ -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
|
|
@@ -483,7 +474,7 @@
|
|
|
483
474
|
</span>
|
|
484
475
|
<div class="ml-auto flex items-center gap-1">
|
|
485
476
|
{#if showFilters}
|
|
486
|
-
<RunFilterPopover bind:filters {showTask} {tasks}
|
|
477
|
+
<RunFilterPopover bind:filters {showTask} {tasks} />
|
|
487
478
|
{/if}
|
|
488
479
|
<Button
|
|
489
480
|
variant="ghost"
|
|
@@ -609,8 +600,8 @@
|
|
|
609
600
|
</div>
|
|
610
601
|
</div>
|
|
611
602
|
|
|
612
|
-
<!-- Status dot. With bulk actions on it fades out
|
|
613
|
-
selection exists
|
|
603
|
+
<!-- Status dot. With bulk actions on it fades out, on row hover, or whenever a
|
|
604
|
+
selection exists, so the row checkbox can take its place over it. -->
|
|
614
605
|
<!-- Placeholders shaped like this list's run rows (same padding, dot, text
|
|
615
606
|
and right readout), while a search or the first page is in flight. -->
|
|
616
607
|
{#snippet skeletonRows(label: string)}
|
|
@@ -688,8 +679,8 @@
|
|
|
688
679
|
{/snippet}
|
|
689
680
|
|
|
690
681
|
{#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
|
|
691
|
-
{@const dstatus =
|
|
692
|
-
{@const config =
|
|
682
|
+
{@const dstatus = displayStatus(run.status, run.endReason)}
|
|
683
|
+
{@const config = RUN_STATUS_CONFIG[dstatus]}
|
|
693
684
|
{@const running = run.status === "running"}
|
|
694
685
|
{@const spine = config.dot.replace(" animate-pulse", "")}
|
|
695
686
|
{@const startedAt = run.startedAt ?? run.createdAt}
|
|
@@ -706,7 +697,7 @@
|
|
|
706
697
|
>
|
|
707
698
|
{#if showTaskName}
|
|
708
699
|
<!-- Cross-task /runs variant: the same readout language as the task
|
|
709
|
-
rail
|
|
700
|
+
rail, status dot, status-colored outcome, mono right readout,
|
|
710
701
|
with the task name carried as the primary. -->
|
|
711
702
|
<div class="flex items-center gap-2.5">
|
|
712
703
|
{@render statusDot(config.color, running)}
|
|
@@ -744,7 +735,7 @@
|
|
|
744
735
|
</span>
|
|
745
736
|
</div>
|
|
746
737
|
{:else}
|
|
747
|
-
<!-- Task-rail variant (artifact ".run"): a single dense line
|
|
738
|
+
<!-- Task-rail variant (artifact ".run"): a single dense line,
|
|
748
739
|
time · date · outcome, with a mono exit/duration readout.
|
|
749
740
|
leading-tight matches the artifact's ~1.2 line-height so the
|
|
750
741
|
(descender-less) text optically centers instead of riding high
|
|
@@ -52,7 +52,7 @@ describe("humanizeCron", () => {
|
|
|
52
52
|
expect(result.isHumanized).toBe(false);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it("falls back to raw for unparseable expressions
|
|
55
|
+
it("falls back to raw for unparseable expressions, never 'Invalid'", () => {
|
|
56
56
|
const result = humanizeCron("not a cron");
|
|
57
57
|
expect(result.humanized).toBe("not a cron");
|
|
58
58
|
expect(result.isHumanized).toBe(false);
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import cronstrue from "cronstrue";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
/** Display text
|
|
5
|
+
interface HumanizedCron {
|
|
6
|
+
/** Display text, humanized when possible, otherwise the raw expression. */
|
|
7
7
|
humanized: string;
|
|
8
8
|
/** The original cron expression, for tooltips. */
|
|
9
9
|
raw: string;
|
|
@@ -51,7 +51,7 @@ function humanizeEvery(duration: string): string | null {
|
|
|
51
51
|
/**
|
|
52
52
|
* humanizeCron renders a cron expression as plain English ("Every 5 minutes").
|
|
53
53
|
* Handles robfig's `@every <duration>` extension (cronstrue throws on it) and
|
|
54
|
-
* falls back to the raw expression on anything unparseable
|
|
54
|
+
* falls back to the raw expression on anything unparseable, never "Invalid".
|
|
55
55
|
*/
|
|
56
56
|
export function humanizeCron(cron: string): HumanizedCron {
|
|
57
57
|
const raw = cron.trim();
|
|
@@ -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";
|
|
@@ -46,7 +42,7 @@ const base = (overrides: Partial<RunsListFilters> = {}): RunsListFilters => ({
|
|
|
46
42
|
});
|
|
47
43
|
|
|
48
44
|
describe("emptyRunFilters", () => {
|
|
49
|
-
it("is a fully-open filter
|
|
45
|
+
it("is a fully-open filter, no dimension active, newest-first", () => {
|
|
50
46
|
const f = emptyRunFilters();
|
|
51
47
|
expect(f.statuses).toEqual([]);
|
|
52
48
|
expect(f.sortDirection).toBe("desc");
|
|
@@ -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
|
});
|
|
@@ -172,7 +132,7 @@ describe("statusChipLabel", () => {
|
|
|
172
132
|
describe("STATUS_BUCKETS", () => {
|
|
173
133
|
// The Failed bucket is the failure sentinel (resolved server-side to the
|
|
174
134
|
// per-task is_failure classification), so the execution-failure reasons are
|
|
175
|
-
// not bucketed
|
|
135
|
+
// not bucketed, they stay reachable via the popover's Advanced expander.
|
|
176
136
|
const FAILURE_REASONS = [
|
|
177
137
|
"failed",
|
|
178
138
|
"crashed",
|
|
@@ -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
|
-
});
|
|
@@ -2,86 +2,51 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { TRIGGERS, type Trigger } from "@runwisp/common";
|
|
5
|
-
|
|
6
|
-
export type RunsListSortDirection = "asc" | "desc" | "";
|
|
5
|
+
import { TRIGGER_LABELS } from "./run-helpers.js";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* value (`emptyRunFilters`) matches every run.
|
|
13
|
-
*
|
|
14
|
-
* `statuses` is a multi-select set joined to a comma-separated string only at
|
|
15
|
-
* the two wire boundaries (the list query and the bulk selector). Time bounds
|
|
16
|
-
* are absolute RFC3339 instants — presets resolve to `now − delta` at apply
|
|
17
|
-
* time so a live SSE row created after the boundary keeps matching.
|
|
8
|
+
* The filter shared by the runs list, the filter popover, the SSE-merge source
|
|
9
|
+
* and the bulk selector. Time bounds are absolute RFC3339 instants, so a live
|
|
10
|
+
* SSE row created after a preset was applied keeps matching.
|
|
18
11
|
*/
|
|
19
12
|
export interface RunsListFilters {
|
|
20
13
|
search: string;
|
|
21
14
|
statuses: string[];
|
|
22
|
-
sortDirection:
|
|
15
|
+
sortDirection: "asc" | "desc" | "";
|
|
23
16
|
// Optional dimensions explicitly admit `undefined` so a dimension can be
|
|
24
17
|
// cleared by reassignment (`{ ...f, x: undefined }`) under the project's
|
|
25
|
-
// exactOptionalPropertyTypes
|
|
18
|
+
// exactOptionalPropertyTypes, a fresh object reference is what re-triggers
|
|
26
19
|
// the parent's fetch effect through several levels of `bind:`.
|
|
27
20
|
taskName?: string | undefined;
|
|
28
21
|
createdAfter?: string | undefined;
|
|
29
22
|
createdBefore?: string | undefined;
|
|
30
23
|
triggeredBy?: string | undefined;
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
|
|
34
|
-
// server only ever sees `exitCodeMin` / `exitCodeMax`.
|
|
24
|
+
// An exit-code expression such as `137`, `>100` or `>100 <150`; see
|
|
25
|
+
// parseExitCodeRange.
|
|
35
26
|
exitCode?: string | undefined;
|
|
36
27
|
retriesOnly?: boolean | undefined;
|
|
37
28
|
}
|
|
38
29
|
|
|
39
|
-
/** A fresh default filter
|
|
30
|
+
/** A fresh default filter, no dimensions active, newest-first. */
|
|
40
31
|
export function emptyRunFilters(): RunsListFilters {
|
|
41
32
|
return { search: "", statuses: [], sortDirection: "desc" };
|
|
42
33
|
}
|
|
43
34
|
|
|
44
35
|
/**
|
|
45
|
-
* Reserved `status` token the "Failed"
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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.
|
|
36
|
+
* Reserved `status` token for the "Failed" filter. The daemon matches it on each
|
|
37
|
+
* run's `isFailure` bit (per-task `failures` policy) instead of a fixed
|
|
38
|
+
* end-reason list. Must match `failureStatusToken` in the daemon.
|
|
52
39
|
*/
|
|
53
40
|
export const FAILURE_STATUS_TOKEN = "failure";
|
|
54
41
|
|
|
55
42
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
/**
|
|
71
|
-
* Outcome buckets: the 14 individual run statuses collapsed into five
|
|
72
|
-
* plain-language groups, so the common case is a five-item pick instead of a
|
|
73
|
-
* flat checklist. Each bucket is purely a UI grouping over `statuses` — toggling
|
|
74
|
-
* one adds/removes its members, and the popover's "Advanced" section still
|
|
75
|
-
* exposes the individual statuses for surgical filters (e.g. only `timeout`).
|
|
76
|
-
*
|
|
77
|
-
* Every status appears in exactly one bucket; `Failed` is the needs-attention
|
|
78
|
-
* set (Prime Directive #1). `dot` mirrors that group's color in
|
|
79
|
-
* RUN_STATUS_CONFIG so the bucket reads at a glance.
|
|
43
|
+
* Outcome buckets: a UI grouping of the individual run statuses into five
|
|
44
|
+
* plain-language picks. The popover's "Advanced" section still exposes the
|
|
45
|
+
* individual statuses. `dot` mirrors the group's color in RUN_STATUS_CONFIG.
|
|
80
46
|
*/
|
|
81
47
|
export interface StatusBucket {
|
|
82
48
|
key: string;
|
|
83
49
|
label: string;
|
|
84
|
-
/** Tailwind background class for the bucket's status dot. */
|
|
85
50
|
dot: string;
|
|
86
51
|
statuses: readonly string[];
|
|
87
52
|
}
|
|
@@ -93,7 +58,7 @@ export const STATUS_BUCKETS: readonly StatusBucket[] = [
|
|
|
93
58
|
key: "failed",
|
|
94
59
|
label: "Failed",
|
|
95
60
|
dot: "bg-danger-surface",
|
|
96
|
-
statuses:
|
|
61
|
+
statuses: [FAILURE_STATUS_TOKEN],
|
|
97
62
|
},
|
|
98
63
|
{
|
|
99
64
|
key: "skipped",
|
|
@@ -111,7 +76,6 @@ export const STATUS_BUCKETS: readonly StatusBucket[] = [
|
|
|
111
76
|
|
|
112
77
|
export type BucketState = "on" | "partial" | "off";
|
|
113
78
|
|
|
114
|
-
/** Whether none, some, or all of a bucket's statuses are currently selected. */
|
|
115
79
|
export function bucketState(statuses: string[], bucket: StatusBucket): BucketState {
|
|
116
80
|
const set = new Set(statuses);
|
|
117
81
|
const present = bucket.statuses.filter((s) => set.has(s)).length;
|
|
@@ -138,7 +102,7 @@ function exactBucket(statuses: string[]): StatusBucket | undefined {
|
|
|
138
102
|
|
|
139
103
|
/**
|
|
140
104
|
* The popover-managed dimensions, in display (most→least useful) order.
|
|
141
|
-
* `task` only ever applies on the cross-task /runs view
|
|
105
|
+
* `task` only ever applies on the cross-task /runs view, on a single task's
|
|
142
106
|
* page the task name is the page scope (injected at fetch time), never a
|
|
143
107
|
* popover-set filter, so it stays absent from those filters and is not counted.
|
|
144
108
|
*/
|
|
@@ -153,7 +117,6 @@ const DIMENSION_ORDER: FilterDimension[] = [
|
|
|
153
117
|
"retries",
|
|
154
118
|
];
|
|
155
119
|
|
|
156
|
-
/** Whether a single dimension is currently constraining the result set. */
|
|
157
120
|
export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boolean {
|
|
158
121
|
switch (dim) {
|
|
159
122
|
case "status":
|
|
@@ -171,17 +134,16 @@ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boole
|
|
|
171
134
|
}
|
|
172
135
|
}
|
|
173
136
|
|
|
174
|
-
/** The active dimensions, in display order
|
|
137
|
+
/** The active dimensions, in display order, drives the chip row. */
|
|
175
138
|
export function activeDimensions(f: RunsListFilters): FilterDimension[] {
|
|
176
139
|
return DIMENSION_ORDER.filter((dim) => dimensionActive(f, dim));
|
|
177
140
|
}
|
|
178
141
|
|
|
179
|
-
/** How many dimensions are active
|
|
142
|
+
/** How many dimensions are active, the count badge on the Filter button. */
|
|
180
143
|
export function activeFilterCount(f: RunsListFilters): number {
|
|
181
144
|
return activeDimensions(f).length;
|
|
182
145
|
}
|
|
183
146
|
|
|
184
|
-
/** Reset one dimension, leaving the others (and search/sort) untouched. */
|
|
185
147
|
export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsListFilters {
|
|
186
148
|
switch (dim) {
|
|
187
149
|
case "status":
|
|
@@ -199,23 +161,6 @@ export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsLi
|
|
|
199
161
|
}
|
|
200
162
|
}
|
|
201
163
|
|
|
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
164
|
/** Human label for a status value, e.g. `log_overflow` → "Log overflow". */
|
|
220
165
|
export function humanizeStatus(status: string): string {
|
|
221
166
|
if (!status) return status;
|
|
@@ -223,47 +168,20 @@ export function humanizeStatus(status: string): string {
|
|
|
223
168
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
224
169
|
}
|
|
225
170
|
|
|
226
|
-
/**
|
|
227
|
-
* Descriptive label for a run's trigger source — fuller than the one-word row
|
|
228
|
-
* badge (`formatTriggeredByLabel`) for the "cron"/"station"/"service"/"startup"
|
|
229
|
-
* sources, where the extra words disambiguate (e.g. "REST API" vs a bare
|
|
230
|
-
* "API"). "ui", "cli" and "hook" already read fine as their bare badge word,
|
|
231
|
-
* so those three match `formatTriggeredByLabel` exactly.
|
|
232
|
-
*
|
|
233
|
-
* Note `cron` covers both on-time schedule firings and catch-up for missed
|
|
234
|
-
* runs, and `startup` is specifically `run_on_start` (not catch-up).
|
|
235
|
-
*/
|
|
171
|
+
/** Descriptive label for a run's trigger source (see {@link TRIGGER_LABELS}). */
|
|
236
172
|
export function triggerDescription(trigger: string): string {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return "Scheduled (cron)";
|
|
240
|
-
case "api":
|
|
241
|
-
return "REST API";
|
|
242
|
-
case "ui":
|
|
243
|
-
return "UI";
|
|
244
|
-
case "cli":
|
|
245
|
-
return "CLI";
|
|
246
|
-
case "station":
|
|
247
|
-
return "Control plane";
|
|
248
|
-
case "service":
|
|
249
|
-
return "Service auto-start";
|
|
250
|
-
case "startup":
|
|
251
|
-
return "On daemon start";
|
|
252
|
-
case "hook":
|
|
253
|
-
return "Hook";
|
|
254
|
-
default:
|
|
255
|
-
return humanizeStatus(trigger);
|
|
256
|
-
}
|
|
173
|
+
const known = TRIGGERS.find((t) => t === trigger);
|
|
174
|
+
return known ? TRIGGER_LABELS[known].long : humanizeStatus(trigger);
|
|
257
175
|
}
|
|
258
176
|
|
|
259
177
|
/**
|
|
260
178
|
* The triggers offered in the filter dropdown. `station` is intentionally
|
|
261
|
-
* excluded for now
|
|
179
|
+
* excluded for now, control-plane runs still carry the `station` trigger, but
|
|
262
180
|
* it isn't a selectable filter dimension.
|
|
263
181
|
*/
|
|
264
182
|
export const FILTERABLE_TRIGGERS: readonly Trigger[] = TRIGGERS.filter((t) => t !== "station");
|
|
265
183
|
|
|
266
|
-
/** Chip label for the status dimension
|
|
184
|
+
/** Chip label for the status dimension, names a whole bucket when it matches. */
|
|
267
185
|
export function statusChipLabel(statuses: string[]): string {
|
|
268
186
|
const bucket = exactBucket(statuses);
|
|
269
187
|
if (bucket) return bucket.label;
|
|
@@ -272,15 +190,6 @@ export function statusChipLabel(statuses: string[]): string {
|
|
|
272
190
|
return `${String(statuses.length)} statuses`;
|
|
273
191
|
}
|
|
274
192
|
|
|
275
|
-
// --- Time helpers --------------------------------------------------------
|
|
276
|
-
//
|
|
277
|
-
// A bare calendar day (`YYYY-MM-DD` from a native date input) maps to a local
|
|
278
|
-
// instant: the "from" edge is that day's 00:00, the "to" edge is its end
|
|
279
|
-
// (23:59:59.999) so picking the same day for both bounds — or the one-click
|
|
280
|
-
// "on day" shortcut — captures the whole day and round-trips back to the same
|
|
281
|
-
// date in the inputs. Bounds are stored as absolute RFC3339 (UTC) instants, the
|
|
282
|
-
// shape the list query and SSE-merge filter already expect.
|
|
283
|
-
|
|
284
193
|
/** Parse `YYYY-MM-DD` into the local Date for that day, or null if malformed. */
|
|
285
194
|
function localDay(dateStr: string): Date | null {
|
|
286
195
|
const parts = dateStr.split("-");
|
|
@@ -328,14 +237,6 @@ export function isWholeDay(after?: string, before?: string): boolean {
|
|
|
328
237
|
);
|
|
329
238
|
}
|
|
330
239
|
|
|
331
|
-
// --- Exit-code expression ------------------------------------------------
|
|
332
|
-
//
|
|
333
|
-
// The popover takes a free-form exit-code expression and normalizes it to an
|
|
334
|
-
// inclusive integer [min, max] range — the shape the server gates on. Because
|
|
335
|
-
// exit codes are integers, a strict comparison collapses to an inclusive
|
|
336
|
-
// bound: `>100` is min 101, `<150` is max 149. Tokens combine (`>100 <150`),
|
|
337
|
-
// and a bare number is an exact match (min = max = n).
|
|
338
|
-
|
|
339
240
|
export interface ExitCodeRange {
|
|
340
241
|
min?: number;
|
|
341
242
|
max?: number;
|
|
@@ -344,10 +245,10 @@ export interface ExitCodeRange {
|
|
|
344
245
|
const EXIT_CODE_TOKEN = /^(>=|<=|>|<)?(-?\d+)$/;
|
|
345
246
|
|
|
346
247
|
/**
|
|
347
|
-
* Parse an exit-code expression into
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
248
|
+
* Parse an exit-code expression into the inclusive integer range the server
|
|
249
|
+
* filters on: `>100` is min 101, `<150` is max 149, a bare number is an exact
|
|
250
|
+
* match, and tokens combine. Any unparseable token makes the whole expression
|
|
251
|
+
* invalid so the UI can flag the typo. An empty expression is valid and unbounded.
|
|
351
252
|
*/
|
|
352
253
|
export function parseExitCodeRange(expr: string): { range: ExitCodeRange; valid: boolean } {
|
|
353
254
|
const range: ExitCodeRange = {};
|
|
@@ -386,13 +287,12 @@ export function exitCodeRange(expr: string | undefined): ExitCodeRange {
|
|
|
386
287
|
return valid ? range : {};
|
|
387
288
|
}
|
|
388
289
|
|
|
389
|
-
/** True when the expression resolves to at least one bound (a real filter). */
|
|
390
290
|
export function exitCodeRangeActive(expr: string | undefined): boolean {
|
|
391
291
|
const { min, max } = exitCodeRange(expr);
|
|
392
292
|
return min !== undefined || max !== undefined;
|
|
393
293
|
}
|
|
394
294
|
|
|
395
|
-
/** True when the expression is empty or fully parseable
|
|
295
|
+
/** True when the expression is empty or fully parseable, drives input validation. */
|
|
396
296
|
export function isExitCodeExprValid(expr: string): boolean {
|
|
397
297
|
return parseExitCodeRange(expr).valid;
|
|
398
298
|
}
|
|
@@ -401,18 +301,3 @@ export function isExitCodeExprValid(expr: string): boolean {
|
|
|
401
301
|
export function exitCodeChipLabel(expr: string | undefined): string {
|
|
402
302
|
return `Exit ${(expr ?? "").trim()}`;
|
|
403
303
|
}
|
|
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,26 @@
|
|
|
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
|
+
runLogDownloadUrl,
|
|
13
14
|
} from "./run-helpers.js";
|
|
14
15
|
|
|
15
|
-
describe("
|
|
16
|
-
it("
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
describe("runLogDownloadUrl", () => {
|
|
17
|
+
it("points at the daemon's raw log route", () => {
|
|
18
|
+
expect(runLogDownloadUrl("01J0000000000000000000000A")).toBe(
|
|
19
|
+
"/api/runs/01J0000000000000000000000A/log/raw",
|
|
20
|
+
);
|
|
20
21
|
});
|
|
22
|
+
});
|
|
21
23
|
|
|
24
|
+
describe("runVerdict", () => {
|
|
22
25
|
it("phrases a timed outcome so a duration reads after it", () => {
|
|
23
26
|
expect(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
|
|
24
27
|
expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
|
|
@@ -116,6 +119,22 @@ describe("formatTriggeredByLabel", () => {
|
|
|
116
119
|
});
|
|
117
120
|
});
|
|
118
121
|
|
|
122
|
+
describe("runUsageLabel", () => {
|
|
123
|
+
it("is undefined for an unmeasured run", () => {
|
|
124
|
+
expect(runUsageLabel({})).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("joins peak memory and CPU time", () => {
|
|
128
|
+
expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 1500 })).toBe(
|
|
129
|
+
"peak 48 MB · CPU 1s",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("shows whichever was measured", () => {
|
|
134
|
+
expect(runUsageLabel({ cpuTimeMs: 0 })).toBe("CPU 0ms");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
119
138
|
describe("runRetryLabel", () => {
|
|
120
139
|
it("returns undefined for a first attempt that is not a retry", () => {
|
|
121
140
|
expect(runRetryLabel({ retryAttempt: 0 })).toBeUndefined();
|