@runwisp/ui 1.5.0 → 1.7.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 -7
- package/src/lib/actions/focusTrap.ts +3 -3
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +4 -25
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/DataGrid.svelte +5 -5
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +3 -7
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +101 -424
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +3 -2
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusDot.svelte +1 -1
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/Tooltip.svelte +1 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +137 -725
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +9 -10
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +267 -545
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +10 -1
- package/src/lib/components/dashboard/cron-format.ts +7 -3
- package/src/lib/components/dashboard/run-filters.test.ts +62 -2
- package/src/lib/components/dashboard/run-filters.ts +104 -99
- package/src/lib/components/dashboard/run-helpers.test.ts +131 -12
- package/src/lib/components/dashboard/run-helpers.ts +150 -44
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/ansi.ts +1 -1
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/theme-tokens.css +21 -21
- package/src/lib/theme.css +13 -13
- package/src/lib/utils/format.test.ts +125 -3
- package/src/lib/utils/format.ts +101 -8
- package/src/lib/utils/toast.svelte.ts +2 -3
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/LogConsole.stories.svelte +2 -2
- package/src/stories/Tabs.stories.svelte +17 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { BulkSelection } from "./bulk-selection.svelte.js";
|
|
6
|
+
|
|
7
|
+
describe("BulkSelection", () => {
|
|
8
|
+
it("tracks explicitly picked rows", () => {
|
|
9
|
+
const sel = new BulkSelection();
|
|
10
|
+
sel.toggle("a");
|
|
11
|
+
sel.toggle("b");
|
|
12
|
+
sel.toggle("a");
|
|
13
|
+
expect(sel.isSelected("a")).toBe(false);
|
|
14
|
+
expect(sel.isSelected("b")).toBe(true);
|
|
15
|
+
expect(sel.allSelected).toBe(false);
|
|
16
|
+
expect(sel.selector({}, ["b"])).toEqual({ matchAll: false, ids: ["b"] });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("selects everything except opt-outs in all mode", () => {
|
|
20
|
+
const sel = new BulkSelection();
|
|
21
|
+
sel.toggle("x");
|
|
22
|
+
sel.selectAll();
|
|
23
|
+
expect(sel.isSelected("x")).toBe(true);
|
|
24
|
+
expect(sel.allSelected).toBe(true);
|
|
25
|
+
sel.toggle("x");
|
|
26
|
+
expect(sel.isSelected("x")).toBe(false);
|
|
27
|
+
expect(sel.allSelected).toBe(false);
|
|
28
|
+
expect(sel.selector({ taskName: "t" }, ["ignored"])).toEqual({
|
|
29
|
+
matchAll: true,
|
|
30
|
+
filter: { taskName: "t" },
|
|
31
|
+
exceptIds: ["x"],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("clears back to nothing selected", () => {
|
|
36
|
+
const sel = new BulkSelection();
|
|
37
|
+
sel.selectAll();
|
|
38
|
+
sel.clear();
|
|
39
|
+
expect(sel.isSelected("a")).toBe(false);
|
|
40
|
+
expect(sel.allSelected).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
5
|
+
import type { RunSelector } from "@runwisp/common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which runs are ticked in the list. Two modes:
|
|
9
|
+
* 1. explicit: the operator picked specific rows; `#explicit` holds them.
|
|
10
|
+
* 2. all: "everything matching the current filter"; `#except` holds opt-outs.
|
|
11
|
+
*/
|
|
12
|
+
export class BulkSelection {
|
|
13
|
+
#all = $state(false);
|
|
14
|
+
readonly #explicit = new SvelteSet<string>();
|
|
15
|
+
readonly #except = new SvelteSet<string>();
|
|
16
|
+
|
|
17
|
+
isSelected(id: string): boolean {
|
|
18
|
+
return this.#all ? !this.#except.has(id) : this.#explicit.has(id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
toggle(id: string): void {
|
|
22
|
+
const ids = this.#all ? this.#except : this.#explicit;
|
|
23
|
+
if (!ids.delete(id)) ids.add(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
clear(): void {
|
|
27
|
+
this.#all = false;
|
|
28
|
+
this.#explicit.clear();
|
|
29
|
+
this.#except.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
selectAll(): void {
|
|
33
|
+
this.clear();
|
|
34
|
+
this.#all = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every matching run is selected, with no opt-outs. */
|
|
38
|
+
get allSelected(): boolean {
|
|
39
|
+
return this.#all && this.#except.size === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The server-side selector for a bulk operation. In "all" mode it targets
|
|
44
|
+
* everything matching `filter`; in explicit mode just `affectedIds`.
|
|
45
|
+
*/
|
|
46
|
+
selector(filter: NonNullable<RunSelector["filter"]>, affectedIds: string[]): RunSelector {
|
|
47
|
+
if (!this.#all) return { matchAll: false, ids: affectedIds };
|
|
48
|
+
return { matchAll: true, filter, exceptIds: [...this.#except] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -17,6 +17,15 @@ describe("humanizeCron", () => {
|
|
|
17
17
|
expect(result.isHumanized).toBe(true);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
it("describes the nth and last weekday of the month", () => {
|
|
21
|
+
expect(humanizeCron("0 3 * * FRI#3").humanized).toBe(
|
|
22
|
+
"At 03:00 AM, on the third Friday of the month",
|
|
23
|
+
);
|
|
24
|
+
const last = humanizeCron("0 3 * * FRI#L");
|
|
25
|
+
expect(last.humanized).toBe("At 03:00 AM, on the last Friday of the month");
|
|
26
|
+
expect(last.raw).toBe("0 3 * * FRI#L");
|
|
27
|
+
});
|
|
28
|
+
|
|
20
29
|
it("humanizes @hourly via cronstrue", () => {
|
|
21
30
|
const result = humanizeCron("@hourly");
|
|
22
31
|
expect(result.humanized).toBe("Every hour");
|
|
@@ -52,7 +61,7 @@ describe("humanizeCron", () => {
|
|
|
52
61
|
expect(result.isHumanized).toBe(false);
|
|
53
62
|
});
|
|
54
63
|
|
|
55
|
-
it("falls back to raw for unparseable expressions
|
|
64
|
+
it("falls back to raw for unparseable expressions, never 'Invalid'", () => {
|
|
56
65
|
const result = humanizeCron("not a cron");
|
|
57
66
|
expect(result.humanized).toBe("not a cron");
|
|
58
67
|
expect(result.isHumanized).toBe(false);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import cronstrue from "cronstrue";
|
|
4
4
|
|
|
5
5
|
interface HumanizedCron {
|
|
6
|
-
/** Display text
|
|
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();
|
|
@@ -66,7 +66,11 @@ export function humanizeCron(cron: string): HumanizedCron {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
|
-
|
|
69
|
+
// cronstrue reads a last weekday as Quartz's "5L"; "5#L" would come out as
|
|
70
|
+
// "on the null Friday of the month".
|
|
71
|
+
const humanized = cronstrue.toString(raw.replace(/#L/gi, "L"), {
|
|
72
|
+
throwExceptionOnParseError: true,
|
|
73
|
+
});
|
|
70
74
|
return { humanized, raw, isHumanized: true };
|
|
71
75
|
} catch {
|
|
72
76
|
return { humanized: raw, raw, isHumanized: false };
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
exitCodeRangeActive,
|
|
27
27
|
isExitCodeExprValid,
|
|
28
28
|
exitCodeChipLabel,
|
|
29
|
+
runFilterParams,
|
|
30
|
+
filterChipLabel,
|
|
29
31
|
type StatusBucket,
|
|
30
32
|
type RunsListFilters,
|
|
31
33
|
} from "./run-filters.js";
|
|
@@ -42,7 +44,7 @@ const base = (overrides: Partial<RunsListFilters> = {}): RunsListFilters => ({
|
|
|
42
44
|
});
|
|
43
45
|
|
|
44
46
|
describe("emptyRunFilters", () => {
|
|
45
|
-
it("is a fully-open filter
|
|
47
|
+
it("is a fully-open filter, no dimension active, newest-first", () => {
|
|
46
48
|
const f = emptyRunFilters();
|
|
47
49
|
expect(f.statuses).toEqual([]);
|
|
48
50
|
expect(f.sortDirection).toBe("desc");
|
|
@@ -132,7 +134,7 @@ describe("statusChipLabel", () => {
|
|
|
132
134
|
describe("STATUS_BUCKETS", () => {
|
|
133
135
|
// The Failed bucket is the failure sentinel (resolved server-side to the
|
|
134
136
|
// per-task is_failure classification), so the execution-failure reasons are
|
|
135
|
-
// not bucketed
|
|
137
|
+
// not bucketed, they stay reachable via the popover's Advanced expander.
|
|
136
138
|
const FAILURE_REASONS = [
|
|
137
139
|
"failed",
|
|
138
140
|
"crashed",
|
|
@@ -292,3 +294,61 @@ describe("exit-code expression", () => {
|
|
|
292
294
|
expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
|
|
293
295
|
});
|
|
294
296
|
});
|
|
297
|
+
|
|
298
|
+
describe("runFilterParams", () => {
|
|
299
|
+
it("omits every inactive dimension", () => {
|
|
300
|
+
expect(runFilterParams(emptyRunFilters())).toEqual({});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("maps each active dimension to its server field", () => {
|
|
304
|
+
expect(
|
|
305
|
+
runFilterParams({
|
|
306
|
+
search: " nightly ",
|
|
307
|
+
statuses: ["failed", "timeout"],
|
|
308
|
+
sortDirection: "asc",
|
|
309
|
+
taskName: "backup",
|
|
310
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
311
|
+
createdBefore: "2026-01-02T00:00:00Z",
|
|
312
|
+
triggeredBy: "cron",
|
|
313
|
+
exitCode: ">100 <150",
|
|
314
|
+
retriesOnly: true,
|
|
315
|
+
}),
|
|
316
|
+
).toEqual({
|
|
317
|
+
taskName: "backup",
|
|
318
|
+
search: "nightly",
|
|
319
|
+
status: "failed,timeout",
|
|
320
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
321
|
+
createdBefore: "2026-01-02T00:00:00Z",
|
|
322
|
+
triggeredBy: "cron",
|
|
323
|
+
exitCodeMin: 101,
|
|
324
|
+
exitCodeMax: 149,
|
|
325
|
+
retriesOnly: true,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("ignores an unparseable exit-code expression", () => {
|
|
330
|
+
expect(runFilterParams({ ...emptyRunFilters(), exitCode: "12a" })).toEqual({});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("filterChipLabel", () => {
|
|
335
|
+
it("labels each dimension", () => {
|
|
336
|
+
const f = base({
|
|
337
|
+
statuses: ["failed"],
|
|
338
|
+
taskName: "backup",
|
|
339
|
+
triggeredBy: "cron",
|
|
340
|
+
exitCode: ">100",
|
|
341
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
342
|
+
});
|
|
343
|
+
expect(filterChipLabel(f, "status")).toBe(statusChipLabel(["failed"]));
|
|
344
|
+
expect(filterChipLabel(f, "task")).toBe("backup");
|
|
345
|
+
expect(filterChipLabel(f, "triggeredBy")).toBe("Trigger: " + triggerDescription("cron"));
|
|
346
|
+
expect(filterChipLabel(f, "exitCode")).toBe("Exit >100");
|
|
347
|
+
expect(filterChipLabel(f, "retries")).toBe("Retries only");
|
|
348
|
+
expect(filterChipLabel(f, "time")).toMatch(/^Since /);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("has no time label without bounds", () => {
|
|
352
|
+
expect(filterChipLabel(base(), "time")).toBe("");
|
|
353
|
+
});
|
|
354
|
+
});
|
|
@@ -2,17 +2,14 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { TRIGGERS, type Trigger } from "@runwisp/common";
|
|
5
|
+
import { formatCalendarDate, formatDateTime } from "../../utils/format.js";
|
|
6
|
+
import { TRIGGER_LABELS } from "./run-helpers.js";
|
|
7
|
+
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* value (`emptyRunFilters`) matches every run.
|
|
11
|
-
*
|
|
12
|
-
* `statuses` is a multi-select set joined to a comma-separated string only at
|
|
13
|
-
* the two wire boundaries (the list query and the bulk selector). Time bounds
|
|
14
|
-
* are absolute RFC3339 instants — presets resolve to `now − delta` at apply
|
|
15
|
-
* time so a live SSE row created after the boundary keeps matching.
|
|
10
|
+
* The filter shared by the runs list, the filter popover, the SSE-merge source
|
|
11
|
+
* and the bulk selector. Time bounds are absolute RFC3339 instants, so a live
|
|
12
|
+
* SSE row created after a preset was applied keeps matching.
|
|
16
13
|
*/
|
|
17
14
|
export interface RunsListFilters {
|
|
18
15
|
search: string;
|
|
@@ -20,82 +17,77 @@ export interface RunsListFilters {
|
|
|
20
17
|
sortDirection: "asc" | "desc" | "";
|
|
21
18
|
// Optional dimensions explicitly admit `undefined` so a dimension can be
|
|
22
19
|
// cleared by reassignment (`{ ...f, x: undefined }`) under the project's
|
|
23
|
-
// exactOptionalPropertyTypes
|
|
20
|
+
// exactOptionalPropertyTypes, a fresh object reference is what re-triggers
|
|
24
21
|
// the parent's fetch effect through several levels of `bind:`.
|
|
25
22
|
taskName?: string | undefined;
|
|
26
23
|
createdAfter?: string | undefined;
|
|
27
24
|
createdBefore?: string | undefined;
|
|
28
25
|
triggeredBy?: string | undefined;
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
|
|
32
|
-
// server only ever sees `exitCodeMin` / `exitCodeMax`.
|
|
26
|
+
// An exit-code expression such as `137`, `>100` or `>100 <150`; see
|
|
27
|
+
// parseExitCodeRange.
|
|
33
28
|
exitCode?: string | undefined;
|
|
34
29
|
retriesOnly?: boolean | undefined;
|
|
35
30
|
}
|
|
36
31
|
|
|
37
|
-
/** A fresh default filter
|
|
32
|
+
/** A fresh default filter, no dimensions active, newest-first. */
|
|
38
33
|
export function emptyRunFilters(): RunsListFilters {
|
|
39
34
|
return { search: "", statuses: [], sortDirection: "desc" };
|
|
40
35
|
}
|
|
41
36
|
|
|
42
37
|
/**
|
|
43
|
-
* Reserved `status` token the "Failed"
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* with the attention badges, the failed stat, and notifications — all of which
|
|
47
|
-
* follow the per-task `failures` policy. It is not a real status/end-reason, so
|
|
48
|
-
* it OR-combines with the other buckets. Must match `failureStatusToken` in the
|
|
49
|
-
* daemon.
|
|
38
|
+
* Reserved `status` token for the "Failed" filter. The daemon matches it on each
|
|
39
|
+
* run's `isFailure` bit (per-task `failures` policy) instead of a fixed
|
|
40
|
+
* end-reason list. Must match `failureStatusToken` in the daemon.
|
|
50
41
|
*/
|
|
51
42
|
export const FAILURE_STATUS_TOKEN = "failure";
|
|
52
43
|
|
|
53
44
|
/**
|
|
54
|
-
* Outcome buckets: the
|
|
55
|
-
* plain-language
|
|
56
|
-
*
|
|
57
|
-
* one adds/removes its members, and the popover's "Advanced" section still
|
|
58
|
-
* exposes the individual statuses for surgical filters (e.g. only `timeout`).
|
|
59
|
-
*
|
|
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
|
|
63
|
-
* RUN_STATUS_CONFIG so the bucket reads at a glance.
|
|
45
|
+
* Outcome buckets: a UI grouping of the individual run statuses into five
|
|
46
|
+
* plain-language picks. The popover's "Advanced" section still exposes the
|
|
47
|
+
* individual statuses.
|
|
64
48
|
*/
|
|
65
49
|
export interface StatusBucket {
|
|
66
50
|
key: string;
|
|
67
51
|
label: string;
|
|
68
|
-
/** Tailwind background class for the bucket's status dot. */
|
|
69
52
|
dot: string;
|
|
70
53
|
statuses: readonly string[];
|
|
71
54
|
}
|
|
72
55
|
|
|
73
56
|
export const STATUS_BUCKETS: readonly StatusBucket[] = [
|
|
74
|
-
{
|
|
75
|
-
|
|
57
|
+
{
|
|
58
|
+
key: "running",
|
|
59
|
+
label: "Running",
|
|
60
|
+
dot: RUN_STATUS_CONFIG.running.solidDot,
|
|
61
|
+
statuses: ["pending", "running"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: "succeeded",
|
|
65
|
+
label: "Succeeded",
|
|
66
|
+
dot: RUN_STATUS_CONFIG.succeeded.solidDot,
|
|
67
|
+
statuses: ["succeeded"],
|
|
68
|
+
},
|
|
76
69
|
{
|
|
77
70
|
key: "failed",
|
|
78
71
|
label: "Failed",
|
|
79
|
-
dot:
|
|
72
|
+
dot: RUN_STATUS_CONFIG.failed.solidDot,
|
|
80
73
|
statuses: [FAILURE_STATUS_TOKEN],
|
|
81
74
|
},
|
|
82
75
|
{
|
|
83
76
|
key: "skipped",
|
|
84
77
|
label: "Skipped",
|
|
85
|
-
dot:
|
|
78
|
+
dot: RUN_STATUS_CONFIG.skipped.solidDot,
|
|
86
79
|
statuses: ["skipped", "dst_skipped", "queue_full"],
|
|
87
80
|
},
|
|
88
81
|
{
|
|
89
82
|
key: "stopped",
|
|
90
83
|
label: "Stopped",
|
|
91
|
-
dot:
|
|
84
|
+
dot: RUN_STATUS_CONFIG.stopped.solidDot,
|
|
92
85
|
statuses: ["stopped", "daemon_stopped"],
|
|
93
86
|
},
|
|
94
87
|
];
|
|
95
88
|
|
|
96
89
|
export type BucketState = "on" | "partial" | "off";
|
|
97
90
|
|
|
98
|
-
/** Whether none, some, or all of a bucket's statuses are currently selected. */
|
|
99
91
|
export function bucketState(statuses: string[], bucket: StatusBucket): BucketState {
|
|
100
92
|
const set = new Set(statuses);
|
|
101
93
|
const present = bucket.statuses.filter((s) => set.has(s)).length;
|
|
@@ -122,7 +114,7 @@ function exactBucket(statuses: string[]): StatusBucket | undefined {
|
|
|
122
114
|
|
|
123
115
|
/**
|
|
124
116
|
* The popover-managed dimensions, in display (most→least useful) order.
|
|
125
|
-
* `task` only ever applies on the cross-task /runs view
|
|
117
|
+
* `task` only ever applies on the cross-task /runs view, on a single task's
|
|
126
118
|
* page the task name is the page scope (injected at fetch time), never a
|
|
127
119
|
* popover-set filter, so it stays absent from those filters and is not counted.
|
|
128
120
|
*/
|
|
@@ -137,7 +129,6 @@ const DIMENSION_ORDER: FilterDimension[] = [
|
|
|
137
129
|
"retries",
|
|
138
130
|
];
|
|
139
131
|
|
|
140
|
-
/** Whether a single dimension is currently constraining the result set. */
|
|
141
132
|
export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boolean {
|
|
142
133
|
switch (dim) {
|
|
143
134
|
case "status":
|
|
@@ -155,17 +146,16 @@ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boole
|
|
|
155
146
|
}
|
|
156
147
|
}
|
|
157
148
|
|
|
158
|
-
/** The active dimensions, in display order
|
|
149
|
+
/** The active dimensions, in display order, drives the chip row. */
|
|
159
150
|
export function activeDimensions(f: RunsListFilters): FilterDimension[] {
|
|
160
151
|
return DIMENSION_ORDER.filter((dim) => dimensionActive(f, dim));
|
|
161
152
|
}
|
|
162
153
|
|
|
163
|
-
/** How many dimensions are active
|
|
154
|
+
/** How many dimensions are active, the count badge on the Filter button. */
|
|
164
155
|
export function activeFilterCount(f: RunsListFilters): number {
|
|
165
156
|
return activeDimensions(f).length;
|
|
166
157
|
}
|
|
167
158
|
|
|
168
|
-
/** Reset one dimension, leaving the others (and search/sort) untouched. */
|
|
169
159
|
export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsListFilters {
|
|
170
160
|
switch (dim) {
|
|
171
161
|
case "status":
|
|
@@ -190,47 +180,20 @@ export function humanizeStatus(status: string): string {
|
|
|
190
180
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
191
181
|
}
|
|
192
182
|
|
|
193
|
-
/**
|
|
194
|
-
* Descriptive label for a run's trigger source — fuller than the one-word row
|
|
195
|
-
* badge (`formatTriggeredByLabel`) for the "cron"/"station"/"service"/"startup"
|
|
196
|
-
* sources, where the extra words disambiguate (e.g. "REST API" vs a bare
|
|
197
|
-
* "API"). "ui", "cli" and "hook" already read fine as their bare badge word,
|
|
198
|
-
* so those three match `formatTriggeredByLabel` exactly.
|
|
199
|
-
*
|
|
200
|
-
* Note `cron` covers both on-time schedule firings and catch-up for missed
|
|
201
|
-
* runs, and `startup` is specifically `run_on_start` (not catch-up).
|
|
202
|
-
*/
|
|
183
|
+
/** Descriptive label for a run's trigger source (see {@link TRIGGER_LABELS}). */
|
|
203
184
|
export function triggerDescription(trigger: string): string {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return "Scheduled (cron)";
|
|
207
|
-
case "api":
|
|
208
|
-
return "REST API";
|
|
209
|
-
case "ui":
|
|
210
|
-
return "UI";
|
|
211
|
-
case "cli":
|
|
212
|
-
return "CLI";
|
|
213
|
-
case "station":
|
|
214
|
-
return "Control plane";
|
|
215
|
-
case "service":
|
|
216
|
-
return "Service auto-start";
|
|
217
|
-
case "startup":
|
|
218
|
-
return "On daemon start";
|
|
219
|
-
case "hook":
|
|
220
|
-
return "Hook";
|
|
221
|
-
default:
|
|
222
|
-
return humanizeStatus(trigger);
|
|
223
|
-
}
|
|
185
|
+
const known = TRIGGERS.find((t) => t === trigger);
|
|
186
|
+
return known ? TRIGGER_LABELS[known].long : humanizeStatus(trigger);
|
|
224
187
|
}
|
|
225
188
|
|
|
226
189
|
/**
|
|
227
190
|
* The triggers offered in the filter dropdown. `station` is intentionally
|
|
228
|
-
* excluded for now
|
|
191
|
+
* excluded for now, control-plane runs still carry the `station` trigger, but
|
|
229
192
|
* it isn't a selectable filter dimension.
|
|
230
193
|
*/
|
|
231
194
|
export const FILTERABLE_TRIGGERS: readonly Trigger[] = TRIGGERS.filter((t) => t !== "station");
|
|
232
195
|
|
|
233
|
-
/** Chip label for the status dimension
|
|
196
|
+
/** Chip label for the status dimension, names a whole bucket when it matches. */
|
|
234
197
|
export function statusChipLabel(statuses: string[]): string {
|
|
235
198
|
const bucket = exactBucket(statuses);
|
|
236
199
|
if (bucket) return bucket.label;
|
|
@@ -239,15 +202,6 @@ export function statusChipLabel(statuses: string[]): string {
|
|
|
239
202
|
return `${String(statuses.length)} statuses`;
|
|
240
203
|
}
|
|
241
204
|
|
|
242
|
-
// --- Time helpers --------------------------------------------------------
|
|
243
|
-
//
|
|
244
|
-
// A bare calendar day (`YYYY-MM-DD` from a native date input) maps to a local
|
|
245
|
-
// instant: the "from" edge is that day's 00:00, the "to" edge is its end
|
|
246
|
-
// (23:59:59.999) so picking the same day for both bounds — or the one-click
|
|
247
|
-
// "on day" shortcut — captures the whole day and round-trips back to the same
|
|
248
|
-
// date in the inputs. Bounds are stored as absolute RFC3339 (UTC) instants, the
|
|
249
|
-
// shape the list query and SSE-merge filter already expect.
|
|
250
|
-
|
|
251
205
|
/** Parse `YYYY-MM-DD` into the local Date for that day, or null if malformed. */
|
|
252
206
|
function localDay(dateStr: string): Date | null {
|
|
253
207
|
const parts = dateStr.split("-");
|
|
@@ -295,14 +249,6 @@ export function isWholeDay(after?: string, before?: string): boolean {
|
|
|
295
249
|
);
|
|
296
250
|
}
|
|
297
251
|
|
|
298
|
-
// --- Exit-code expression ------------------------------------------------
|
|
299
|
-
//
|
|
300
|
-
// The popover takes a free-form exit-code expression and normalizes it to an
|
|
301
|
-
// inclusive integer [min, max] range — the shape the server gates on. Because
|
|
302
|
-
// exit codes are integers, a strict comparison collapses to an inclusive
|
|
303
|
-
// bound: `>100` is min 101, `<150` is max 149. Tokens combine (`>100 <150`),
|
|
304
|
-
// and a bare number is an exact match (min = max = n).
|
|
305
|
-
|
|
306
252
|
export interface ExitCodeRange {
|
|
307
253
|
min?: number;
|
|
308
254
|
max?: number;
|
|
@@ -311,10 +257,10 @@ export interface ExitCodeRange {
|
|
|
311
257
|
const EXIT_CODE_TOKEN = /^(>=|<=|>|<)?(-?\d+)$/;
|
|
312
258
|
|
|
313
259
|
/**
|
|
314
|
-
* Parse an exit-code expression into
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
260
|
+
* Parse an exit-code expression into the inclusive integer range the server
|
|
261
|
+
* filters on: `>100` is min 101, `<150` is max 149, a bare number is an exact
|
|
262
|
+
* match, and tokens combine. Any unparseable token makes the whole expression
|
|
263
|
+
* invalid so the UI can flag the typo. An empty expression is valid and unbounded.
|
|
318
264
|
*/
|
|
319
265
|
export function parseExitCodeRange(expr: string): { range: ExitCodeRange; valid: boolean } {
|
|
320
266
|
const range: ExitCodeRange = {};
|
|
@@ -353,18 +299,77 @@ export function exitCodeRange(expr: string | undefined): ExitCodeRange {
|
|
|
353
299
|
return valid ? range : {};
|
|
354
300
|
}
|
|
355
301
|
|
|
356
|
-
/** True when the expression resolves to at least one bound (a real filter). */
|
|
357
302
|
export function exitCodeRangeActive(expr: string | undefined): boolean {
|
|
358
303
|
const { min, max } = exitCodeRange(expr);
|
|
359
304
|
return min !== undefined || max !== undefined;
|
|
360
305
|
}
|
|
361
306
|
|
|
362
|
-
/** True when the expression is empty or fully parseable
|
|
307
|
+
/** True when the expression is empty or fully parseable, drives input validation. */
|
|
363
308
|
export function isExitCodeExprValid(expr: string): boolean {
|
|
364
309
|
return parseExitCodeRange(expr).valid;
|
|
365
310
|
}
|
|
366
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The server-side filter fields for a filter state. The list query and the
|
|
314
|
+
* bulk selector both build on it, so "select all matching" targets exactly the
|
|
315
|
+
* rows on screen. Dimensions that are off are omitted.
|
|
316
|
+
*/
|
|
317
|
+
export interface RunFilterParams {
|
|
318
|
+
taskName?: string;
|
|
319
|
+
search?: string;
|
|
320
|
+
status?: string;
|
|
321
|
+
createdAfter?: string;
|
|
322
|
+
createdBefore?: string;
|
|
323
|
+
triggeredBy?: string;
|
|
324
|
+
exitCodeMin?: number;
|
|
325
|
+
exitCodeMax?: number;
|
|
326
|
+
retriesOnly?: true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function runFilterParams(f: RunsListFilters): RunFilterParams {
|
|
330
|
+
const params: RunFilterParams = {};
|
|
331
|
+
if (f.taskName) params.taskName = f.taskName;
|
|
332
|
+
const search = f.search.trim();
|
|
333
|
+
if (search) params.search = search;
|
|
334
|
+
if (f.statuses.length > 0) params.status = f.statuses.join(",");
|
|
335
|
+
if (f.createdAfter) params.createdAfter = f.createdAfter;
|
|
336
|
+
if (f.createdBefore) params.createdBefore = f.createdBefore;
|
|
337
|
+
if (f.triggeredBy) params.triggeredBy = f.triggeredBy;
|
|
338
|
+
const exit = exitCodeRange(f.exitCode);
|
|
339
|
+
if (exit.min !== undefined) params.exitCodeMin = exit.min;
|
|
340
|
+
if (exit.max !== undefined) params.exitCodeMax = exit.max;
|
|
341
|
+
if (f.retriesOnly === true) params.retriesOnly = true;
|
|
342
|
+
return params;
|
|
343
|
+
}
|
|
344
|
+
|
|
367
345
|
/** Chip label for an active exit-code filter, e.g. `Exit >100 <150`. */
|
|
368
346
|
export function exitCodeChipLabel(expr: string | undefined): string {
|
|
369
347
|
return `Exit ${(expr ?? "").trim()}`;
|
|
370
348
|
}
|
|
349
|
+
|
|
350
|
+
function timeChipLabel(f: RunsListFilters): string {
|
|
351
|
+
const { createdAfter: after, createdBefore: before } = f;
|
|
352
|
+
if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
|
|
353
|
+
if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
|
|
354
|
+
if (after) return `Since ${formatDateTime(after)}`;
|
|
355
|
+
if (before) return `Before ${formatDateTime(before)}`;
|
|
356
|
+
return "";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Label of the chip shown for an active filter dimension. */
|
|
360
|
+
export function filterChipLabel(f: RunsListFilters, dim: FilterDimension): string {
|
|
361
|
+
switch (dim) {
|
|
362
|
+
case "status":
|
|
363
|
+
return statusChipLabel(f.statuses);
|
|
364
|
+
case "time":
|
|
365
|
+
return timeChipLabel(f);
|
|
366
|
+
case "task":
|
|
367
|
+
return f.taskName ?? "";
|
|
368
|
+
case "triggeredBy":
|
|
369
|
+
return "Trigger: " + triggerDescription(f.triggeredBy ?? "");
|
|
370
|
+
case "exitCode":
|
|
371
|
+
return exitCodeChipLabel(f.exitCode);
|
|
372
|
+
case "retries":
|
|
373
|
+
return "Retries only";
|
|
374
|
+
}
|
|
375
|
+
}
|