@runwisp/ui 0.10.0 → 0.11.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 +4 -4
- package/src/lib/assets/runwisp-logo.svg +2 -2
- package/src/lib/components/LogConsole.svelte +518 -68
- package/src/lib/components/Popover.svelte +64 -31
- package/src/lib/components/dashboard/RunDetailPanel.svelte +753 -212
- package/src/lib/components/dashboard/RunFilterPopover.svelte +299 -0
- package/src/lib/components/dashboard/RunsList.svelte +418 -234
- package/src/lib/components/dashboard/run-filters.test.ts +299 -0
- package/src/lib/components/dashboard/run-filters.ts +384 -0
- package/src/lib/components/dashboard/run-helpers.test.ts +20 -0
- package/src/lib/components/dashboard/run-helpers.ts +15 -0
- package/src/lib/index.ts +20 -4
- package/src/lib/log-console/LogCache.svelte.ts +50 -1
- package/src/lib/log-console/types.ts +17 -0
- package/src/lib/theme-tokens.css +74 -62
- package/src/lib/theme.css +1 -0
- package/src/lib/utils/format.test.ts +38 -0
- package/src/lib/utils/format.ts +36 -0
- package/src/stories/assets/runwisp_c.svg +2 -2
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { FAILURE_END_REASONS, END_REASONS } from "@runwisp/common";
|
|
6
|
+
import {
|
|
7
|
+
emptyRunFilters,
|
|
8
|
+
NEEDS_ATTENTION_STATUSES,
|
|
9
|
+
isNeedsAttention,
|
|
10
|
+
dimensionActive,
|
|
11
|
+
activeDimensions,
|
|
12
|
+
activeFilterCount,
|
|
13
|
+
clearDimension,
|
|
14
|
+
clearPopoverFilters,
|
|
15
|
+
humanizeStatus,
|
|
16
|
+
triggerDescription,
|
|
17
|
+
FILTERABLE_TRIGGERS,
|
|
18
|
+
statusChipLabel,
|
|
19
|
+
STATUS_BUCKETS,
|
|
20
|
+
bucketState,
|
|
21
|
+
toggleBucket,
|
|
22
|
+
dayStartIso,
|
|
23
|
+
dayEndIso,
|
|
24
|
+
isoToDayInput,
|
|
25
|
+
isWholeDay,
|
|
26
|
+
parseExitCodeRange,
|
|
27
|
+
exitCodeRange,
|
|
28
|
+
exitCodeRangeActive,
|
|
29
|
+
isExitCodeExprValid,
|
|
30
|
+
exitCodeChipLabel,
|
|
31
|
+
type StatusBucket,
|
|
32
|
+
type RunsListFilters,
|
|
33
|
+
} from "./run-filters.js";
|
|
34
|
+
|
|
35
|
+
function bucketByKey(key: string): StatusBucket {
|
|
36
|
+
const bucket = STATUS_BUCKETS.find((b) => b.key === key);
|
|
37
|
+
if (!bucket) throw new Error(`no bucket ${key}`);
|
|
38
|
+
return bucket;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const base = (overrides: Partial<RunsListFilters> = {}): RunsListFilters => ({
|
|
42
|
+
...emptyRunFilters(),
|
|
43
|
+
...overrides,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("emptyRunFilters", () => {
|
|
47
|
+
it("is a fully-open filter — no dimension active, newest-first", () => {
|
|
48
|
+
const f = emptyRunFilters();
|
|
49
|
+
expect(f.statuses).toEqual([]);
|
|
50
|
+
expect(f.sort_direction).toBe("desc");
|
|
51
|
+
expect(activeFilterCount(f)).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("isNeedsAttention", () => {
|
|
56
|
+
it("is true for exactly the attention set, order-insensitive", () => {
|
|
57
|
+
expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES])).toBe(true);
|
|
58
|
+
expect(isNeedsAttention([...NEEDS_ATTENTION_STATUSES].reverse())).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("includes every failure reason plus missed", () => {
|
|
62
|
+
for (const reason of FAILURE_END_REASONS) {
|
|
63
|
+
expect(NEEDS_ATTENTION_STATUSES).toContain(reason);
|
|
64
|
+
}
|
|
65
|
+
expect(NEEDS_ATTENTION_STATUSES).toContain("missed");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is false for a strict subset or a different set", () => {
|
|
69
|
+
expect(isNeedsAttention(["failed"])).toBe(false);
|
|
70
|
+
expect(isNeedsAttention(["success"])).toBe(false);
|
|
71
|
+
expect(isNeedsAttention([])).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("dimensionActive / activeDimensions", () => {
|
|
76
|
+
it("detects each dimension independently", () => {
|
|
77
|
+
expect(dimensionActive(base({ statuses: ["failed"] }), "status")).toBe(true);
|
|
78
|
+
expect(dimensionActive(base({ created_after: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
|
|
79
|
+
expect(dimensionActive(base({ created_before: "2026-01-01T00:00:00Z" }), "time")).toBe(
|
|
80
|
+
true,
|
|
81
|
+
);
|
|
82
|
+
expect(dimensionActive(base({ task_name: "backup" }), "task")).toBe(true);
|
|
83
|
+
expect(dimensionActive(base({ triggered_by: "cron" }), "triggered_by")).toBe(true);
|
|
84
|
+
expect(dimensionActive(base({ exit_code: "137" }), "exit_code")).toBe(true);
|
|
85
|
+
expect(dimensionActive(base({ exit_code: ">100" }), "exit_code")).toBe(true);
|
|
86
|
+
expect(dimensionActive(base({ retries_only: true }), "retries")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("treats an exact exit code of 0 as active (distinct from absent)", () => {
|
|
90
|
+
expect(dimensionActive(base({ exit_code: "0" }), "exit_code")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("treats an empty or unparseable exit-code expression as inactive", () => {
|
|
94
|
+
expect(dimensionActive(base({ exit_code: "" }), "exit_code")).toBe(false);
|
|
95
|
+
expect(dimensionActive(base({ exit_code: "nonsense" }), "exit_code")).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("lists active dimensions in display (most→least useful) order", () => {
|
|
99
|
+
const f = base({
|
|
100
|
+
retries_only: true,
|
|
101
|
+
statuses: ["failed"],
|
|
102
|
+
triggered_by: "cron",
|
|
103
|
+
created_after: "2026-01-01T00:00:00Z",
|
|
104
|
+
});
|
|
105
|
+
expect(activeDimensions(f)).toEqual(["status", "time", "triggered_by", "retries"]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("clearDimension", () => {
|
|
110
|
+
it("resets one dimension and leaves the rest untouched", () => {
|
|
111
|
+
const f = base({ statuses: ["failed"], triggered_by: "cron", retries_only: true });
|
|
112
|
+
const cleared = clearDimension(f, "triggered_by");
|
|
113
|
+
expect(cleared.triggered_by).toBeUndefined();
|
|
114
|
+
expect(cleared.statuses).toEqual(["failed"]);
|
|
115
|
+
expect(cleared.retries_only).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("clears the exit-code expression", () => {
|
|
119
|
+
const f = base({ exit_code: ">100 <150" });
|
|
120
|
+
expect(clearDimension(f, "exit_code").exit_code).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("clears both time bounds together", () => {
|
|
124
|
+
const f = base({ created_after: "2026-01-01T00:00:00Z", created_before: "2026-02-01" });
|
|
125
|
+
const cleared = clearDimension(f, "time");
|
|
126
|
+
expect(cleared.created_after).toBeUndefined();
|
|
127
|
+
expect(cleared.created_before).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("clearPopoverFilters", () => {
|
|
132
|
+
it("clears every popover dimension but keeps search and sort", () => {
|
|
133
|
+
const f = base({
|
|
134
|
+
search: "nightly",
|
|
135
|
+
sort_direction: "asc",
|
|
136
|
+
statuses: ["failed"],
|
|
137
|
+
task_name: "backup",
|
|
138
|
+
triggered_by: "cron",
|
|
139
|
+
exit_code: "1",
|
|
140
|
+
retries_only: true,
|
|
141
|
+
created_after: "2026-01-01T00:00:00Z",
|
|
142
|
+
});
|
|
143
|
+
const cleared = clearPopoverFilters(f);
|
|
144
|
+
expect(activeFilterCount(cleared)).toBe(0);
|
|
145
|
+
expect(cleared.search).toBe("nightly");
|
|
146
|
+
expect(cleared.sort_direction).toBe("asc");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("humanizeStatus", () => {
|
|
151
|
+
it("title-cases and de-snakes a status token", () => {
|
|
152
|
+
expect(humanizeStatus("log_overflow")).toBe("Log overflow");
|
|
153
|
+
expect(humanizeStatus("failed")).toBe("Failed");
|
|
154
|
+
expect(humanizeStatus("")).toBe("");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("statusChipLabel", () => {
|
|
159
|
+
it("names a bucket when the selection matches one exactly", () => {
|
|
160
|
+
// The Failed bucket is the needs-attention set.
|
|
161
|
+
expect(statusChipLabel([...NEEDS_ATTENTION_STATUSES])).toBe("Failed");
|
|
162
|
+
expect(statusChipLabel(["pending", "running"])).toBe("Running");
|
|
163
|
+
expect(statusChipLabel(["success"])).toBe("Succeeded");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("humanizes a single status that is not a whole bucket", () => {
|
|
167
|
+
expect(statusChipLabel(["log_overflow"])).toBe("Log overflow");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("counts a multi-status set that matches no bucket", () => {
|
|
171
|
+
expect(statusChipLabel(["failed", "crashed"])).toBe("2 statuses");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("STATUS_BUCKETS", () => {
|
|
176
|
+
it("covers every filterable status exactly once", () => {
|
|
177
|
+
const all = STATUS_BUCKETS.flatMap((b) => [...b.statuses]);
|
|
178
|
+
expect(new Set(all).size).toBe(all.length); // no status in two buckets
|
|
179
|
+
expect(new Set(all)).toEqual(new Set([...END_REASONS, "pending", "running"]));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("Failed bucket is the needs-attention set", () => {
|
|
183
|
+
expect(new Set(bucketByKey("failed").statuses)).toEqual(new Set(NEEDS_ATTENTION_STATUSES));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("bucketState / toggleBucket", () => {
|
|
188
|
+
const failed = bucketByKey("failed");
|
|
189
|
+
|
|
190
|
+
it("reads off / partial / on", () => {
|
|
191
|
+
expect(bucketState([], failed)).toBe("off");
|
|
192
|
+
expect(bucketState(["failed"], failed)).toBe("partial");
|
|
193
|
+
expect(bucketState([...failed.statuses], failed)).toBe("on");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("selects all of an off or partial bucket", () => {
|
|
197
|
+
const fromOff = toggleBucket(base(), failed);
|
|
198
|
+
expect(new Set(fromOff.statuses)).toEqual(new Set(failed.statuses));
|
|
199
|
+
|
|
200
|
+
const fromPartial = toggleBucket(base({ statuses: ["failed"] }), failed);
|
|
201
|
+
expect(new Set(fromPartial.statuses)).toEqual(new Set(failed.statuses));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("clears a fully-selected bucket without touching other statuses", () => {
|
|
205
|
+
const f = base({ statuses: [...failed.statuses, "success"] });
|
|
206
|
+
const cleared = toggleBucket(f, failed);
|
|
207
|
+
expect(cleared.statuses).toEqual(["success"]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("date helpers", () => {
|
|
212
|
+
it("round-trips a day through its start/end bounds in local time", () => {
|
|
213
|
+
const start = dayStartIso("2016-07-10");
|
|
214
|
+
const end = dayEndIso("2016-07-10");
|
|
215
|
+
expect(isoToDayInput(start ?? "")).toBe("2016-07-10");
|
|
216
|
+
expect(isoToDayInput(end ?? "")).toBe("2016-07-10");
|
|
217
|
+
expect(new Date(start ?? "").getTime()).toBeLessThan(new Date(end ?? "").getTime());
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("ignores a malformed date", () => {
|
|
221
|
+
expect(dayStartIso("not-a-date")).toBeUndefined();
|
|
222
|
+
expect(dayEndIso("2016-13")).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("isWholeDay is true when From and To bound the same day", () => {
|
|
226
|
+
// Picking the same date for both ends (start-of-day .. end-of-day) is a
|
|
227
|
+
// whole-day range, so the chip can read "On <date>".
|
|
228
|
+
expect(isWholeDay(dayStartIso("2016-07-10"), dayEndIso("2016-07-10"))).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("isWholeDay is false for partial or multi-day ranges", () => {
|
|
232
|
+
expect(isWholeDay(undefined, undefined)).toBe(false);
|
|
233
|
+
expect(isWholeDay(dayStartIso("2016-07-10"), undefined)).toBe(false);
|
|
234
|
+
expect(isWholeDay(dayStartIso("2016-07-10"), dayEndIso("2016-07-12"))).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("triggerDescription", () => {
|
|
239
|
+
it("gives a fuller label than the row badge", () => {
|
|
240
|
+
expect(triggerDescription("cron")).toBe("Scheduled (cron)");
|
|
241
|
+
expect(triggerDescription("api")).toBe("Manual (UI or API)");
|
|
242
|
+
expect(triggerDescription("service")).toBe("Service auto-start");
|
|
243
|
+
expect(triggerDescription("startup")).toBe("On daemon start");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("falls back to a humanized token for anything unknown", () => {
|
|
247
|
+
expect(triggerDescription("whatever_else")).toBe("Whatever else");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("FILTERABLE_TRIGGERS", () => {
|
|
252
|
+
it("offers every trigger except cloud", () => {
|
|
253
|
+
expect(FILTERABLE_TRIGGERS).toEqual(["cron", "api", "service", "startup"]);
|
|
254
|
+
expect(FILTERABLE_TRIGGERS).not.toContain("cloud");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("exit-code expression", () => {
|
|
259
|
+
it("parses an exact code as an inclusive [n, n] range", () => {
|
|
260
|
+
expect(parseExitCodeRange("137")).toEqual({ range: { min: 137, max: 137 }, valid: true });
|
|
261
|
+
expect(parseExitCodeRange("-2")).toEqual({ range: { min: -2, max: -2 }, valid: true });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("normalizes strict comparisons to inclusive integer bounds", () => {
|
|
265
|
+
expect(exitCodeRange(">100")).toEqual({ min: 101 });
|
|
266
|
+
expect(exitCodeRange(">=100")).toEqual({ min: 100 });
|
|
267
|
+
expect(exitCodeRange("<150")).toEqual({ max: 149 });
|
|
268
|
+
expect(exitCodeRange("<=150")).toEqual({ max: 150 });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("combines space-separated tokens into a window", () => {
|
|
272
|
+
expect(exitCodeRange(">100 <150")).toEqual({ min: 101, max: 149 });
|
|
273
|
+
expect(exitCodeRange(">=1 <=255")).toEqual({ min: 1, max: 255 });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("treats an empty expression as valid with no bounds", () => {
|
|
277
|
+
expect(parseExitCodeRange("")).toEqual({ range: {}, valid: true });
|
|
278
|
+
expect(parseExitCodeRange(" ")).toEqual({ range: {}, valid: true });
|
|
279
|
+
expect(exitCodeRangeActive("")).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("rejects an unparseable token, applying none of it", () => {
|
|
283
|
+
expect(isExitCodeExprValid("12a")).toBe(false);
|
|
284
|
+
expect(isExitCodeExprValid(">>5")).toBe(false);
|
|
285
|
+
expect(exitCodeRange("12a")).toEqual({});
|
|
286
|
+
expect(exitCodeRangeActive("12a")).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("reports activity only when a bound resolves", () => {
|
|
290
|
+
expect(exitCodeRangeActive("137")).toBe(true);
|
|
291
|
+
expect(exitCodeRangeActive(">0")).toBe(true);
|
|
292
|
+
expect(exitCodeRangeActive(undefined)).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("labels the chip with the raw expression", () => {
|
|
296
|
+
expect(exitCodeChipLabel(">100 <150")).toBe("Exit >100 <150");
|
|
297
|
+
expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { FAILURE_END_REASONS, TRIGGERS, type Trigger } from "@runwisp/common";
|
|
5
|
+
|
|
6
|
+
export type RunsListSortDirection = "asc" | "desc" | "";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The single filter shape shared by the runs list, the filter popover, the
|
|
10
|
+
* SSE-merge source, and the bulk selector. Every field beyond `search` and
|
|
11
|
+
* `sort_direction` is an optional dimension applied server-side; the zero
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
export interface RunsListFilters {
|
|
20
|
+
search: string;
|
|
21
|
+
statuses: string[];
|
|
22
|
+
sort_direction: RunsListSortDirection;
|
|
23
|
+
// Optional dimensions explicitly admit `undefined` so a dimension can be
|
|
24
|
+
// cleared by reassignment (`{ ...f, x: undefined }`) under the project's
|
|
25
|
+
// exactOptionalPropertyTypes — a fresh object reference is what re-triggers
|
|
26
|
+
// the parent's fetch effect through several levels of `bind:`.
|
|
27
|
+
task_name?: string | undefined;
|
|
28
|
+
created_after?: string | undefined;
|
|
29
|
+
created_before?: string | undefined;
|
|
30
|
+
triggered_by?: string | undefined;
|
|
31
|
+
// A human exit-code expression — a bare code (`137`), a comparison (`>100`,
|
|
32
|
+
// `<=150`), or a space-separated combination (`>100 <150`). Normalized to an
|
|
33
|
+
// inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
|
|
34
|
+
// server only ever sees `exit_code_min` / `exit_code_max`.
|
|
35
|
+
exit_code?: string | undefined;
|
|
36
|
+
retries_only?: boolean | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A fresh default filter — no dimensions active, newest-first. */
|
|
40
|
+
export function emptyRunFilters(): RunsListFilters {
|
|
41
|
+
return { search: "", statuses: [], sort_direction: "desc" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Statuses worth an operator's attention: every end reason the retry policy
|
|
46
|
+
* treats as a failure, plus `missed` (a scheduled run the daemon was down
|
|
47
|
+
* for). Backs the one-click preset that serves Prime Directive #1 — nothing
|
|
48
|
+
* silently fails.
|
|
49
|
+
*/
|
|
50
|
+
export const NEEDS_ATTENTION_STATUSES: readonly string[] = [...FAILURE_END_REASONS, "missed"];
|
|
51
|
+
|
|
52
|
+
/** True when `statuses` is exactly the needs-attention set (order-insensitive). */
|
|
53
|
+
export function isNeedsAttention(statuses: string[]): boolean {
|
|
54
|
+
if (statuses.length !== NEEDS_ATTENTION_STATUSES.length) return false;
|
|
55
|
+
const set = new Set(statuses);
|
|
56
|
+
return NEEDS_ATTENTION_STATUSES.every((s) => set.has(s));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Outcome buckets: the 14 individual run statuses collapsed into five
|
|
61
|
+
* plain-language groups, so the common case is a five-item pick instead of a
|
|
62
|
+
* flat checklist. Each bucket is purely a UI grouping over `statuses` — toggling
|
|
63
|
+
* one adds/removes its members, and the popover's "Advanced" section still
|
|
64
|
+
* exposes the individual statuses for surgical filters (e.g. only `timeout`).
|
|
65
|
+
*
|
|
66
|
+
* Every status appears in exactly one bucket; `Failed` is the needs-attention
|
|
67
|
+
* set (Prime Directive #1). `dot` mirrors that group's color in
|
|
68
|
+
* RUN_STATUS_CONFIG so the bucket reads at a glance.
|
|
69
|
+
*/
|
|
70
|
+
export interface StatusBucket {
|
|
71
|
+
key: string;
|
|
72
|
+
label: string;
|
|
73
|
+
/** Tailwind background class for the bucket's status dot. */
|
|
74
|
+
dot: string;
|
|
75
|
+
statuses: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const STATUS_BUCKETS: readonly StatusBucket[] = [
|
|
79
|
+
{ key: "running", label: "Running", dot: "bg-info-surface", statuses: ["pending", "running"] },
|
|
80
|
+
{ key: "succeeded", label: "Succeeded", dot: "bg-success-surface", statuses: ["success"] },
|
|
81
|
+
{
|
|
82
|
+
key: "failed",
|
|
83
|
+
label: "Failed",
|
|
84
|
+
dot: "bg-danger-surface",
|
|
85
|
+
statuses: NEEDS_ATTENTION_STATUSES,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "skipped",
|
|
89
|
+
label: "Skipped",
|
|
90
|
+
dot: "bg-on-surface-faint",
|
|
91
|
+
statuses: ["skipped", "dst_skipped", "queue_full"],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "stopped",
|
|
95
|
+
label: "Stopped",
|
|
96
|
+
dot: "bg-warning-surface",
|
|
97
|
+
statuses: ["stopped", "daemon_stopped"],
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
export type BucketState = "on" | "partial" | "off";
|
|
102
|
+
|
|
103
|
+
/** Whether none, some, or all of a bucket's statuses are currently selected. */
|
|
104
|
+
export function bucketState(statuses: string[], bucket: StatusBucket): BucketState {
|
|
105
|
+
const set = new Set(statuses);
|
|
106
|
+
const present = bucket.statuses.filter((s) => set.has(s)).length;
|
|
107
|
+
if (present === 0) return "off";
|
|
108
|
+
if (present === bucket.statuses.length) return "on";
|
|
109
|
+
return "partial";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Toggle a whole bucket: fully selected → clear it; otherwise select all of it. */
|
|
113
|
+
export function toggleBucket(f: RunsListFilters, bucket: StatusBucket): RunsListFilters {
|
|
114
|
+
const members = new Set(bucket.statuses);
|
|
115
|
+
if (bucketState(f.statuses, bucket) === "on") {
|
|
116
|
+
return { ...f, statuses: f.statuses.filter((s) => !members.has(s)) };
|
|
117
|
+
}
|
|
118
|
+
return { ...f, statuses: [...new Set([...f.statuses, ...bucket.statuses])] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The bucket the selection matches exactly (all of it, nothing else), if any. */
|
|
122
|
+
function exactBucket(statuses: string[]): StatusBucket | undefined {
|
|
123
|
+
return STATUS_BUCKETS.find(
|
|
124
|
+
(b) => b.statuses.length === statuses.length && bucketState(statuses, b) === "on",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The popover-managed dimensions, in display (most→least useful) order.
|
|
130
|
+
* `task` only ever applies on the cross-task /runs view — on a single task's
|
|
131
|
+
* page the task name is the page scope (injected at fetch time), never a
|
|
132
|
+
* popover-set filter, so it stays absent from those filters and is not counted.
|
|
133
|
+
*/
|
|
134
|
+
export type FilterDimension = "status" | "time" | "task" | "triggered_by" | "exit_code" | "retries";
|
|
135
|
+
|
|
136
|
+
const DIMENSION_ORDER: FilterDimension[] = [
|
|
137
|
+
"status",
|
|
138
|
+
"time",
|
|
139
|
+
"task",
|
|
140
|
+
"triggered_by",
|
|
141
|
+
"exit_code",
|
|
142
|
+
"retries",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/** Whether a single dimension is currently constraining the result set. */
|
|
146
|
+
export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boolean {
|
|
147
|
+
switch (dim) {
|
|
148
|
+
case "status":
|
|
149
|
+
return f.statuses.length > 0;
|
|
150
|
+
case "time":
|
|
151
|
+
return Boolean(f.created_after) || Boolean(f.created_before);
|
|
152
|
+
case "task":
|
|
153
|
+
return Boolean(f.task_name);
|
|
154
|
+
case "triggered_by":
|
|
155
|
+
return Boolean(f.triggered_by);
|
|
156
|
+
case "exit_code":
|
|
157
|
+
return exitCodeRangeActive(f.exit_code);
|
|
158
|
+
case "retries":
|
|
159
|
+
return Boolean(f.retries_only);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The active dimensions, in display order — drives the chip row. */
|
|
164
|
+
export function activeDimensions(f: RunsListFilters): FilterDimension[] {
|
|
165
|
+
return DIMENSION_ORDER.filter((dim) => dimensionActive(f, dim));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** How many dimensions are active — the count badge on the Filter button. */
|
|
169
|
+
export function activeFilterCount(f: RunsListFilters): number {
|
|
170
|
+
return activeDimensions(f).length;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Reset one dimension, leaving the others (and search/sort) untouched. */
|
|
174
|
+
export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsListFilters {
|
|
175
|
+
switch (dim) {
|
|
176
|
+
case "status":
|
|
177
|
+
return { ...f, statuses: [] };
|
|
178
|
+
case "time":
|
|
179
|
+
return { ...f, created_after: undefined, created_before: undefined };
|
|
180
|
+
case "task":
|
|
181
|
+
return { ...f, task_name: undefined };
|
|
182
|
+
case "triggered_by":
|
|
183
|
+
return { ...f, triggered_by: undefined };
|
|
184
|
+
case "exit_code":
|
|
185
|
+
return { ...f, exit_code: undefined };
|
|
186
|
+
case "retries":
|
|
187
|
+
return { ...f, retries_only: undefined };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Clear every popover dimension at once, preserving the header search and the
|
|
193
|
+
* sort direction (those are separate toolbar controls, not popover state).
|
|
194
|
+
*/
|
|
195
|
+
export function clearPopoverFilters(f: RunsListFilters): RunsListFilters {
|
|
196
|
+
return {
|
|
197
|
+
...f,
|
|
198
|
+
statuses: [],
|
|
199
|
+
created_after: undefined,
|
|
200
|
+
created_before: undefined,
|
|
201
|
+
task_name: undefined,
|
|
202
|
+
triggered_by: undefined,
|
|
203
|
+
exit_code: undefined,
|
|
204
|
+
retries_only: undefined,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Human label for a status value, e.g. `log_overflow` → "Log overflow". */
|
|
209
|
+
export function humanizeStatus(status: string): string {
|
|
210
|
+
if (!status) return status;
|
|
211
|
+
const spaced = status.replace(/_/g, " ");
|
|
212
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Descriptive label for a run's trigger source — fuller than the one-word row
|
|
217
|
+
* badge (`formatTriggeredByLabel`), for the filter dropdown and chip where the
|
|
218
|
+
* extra words disambiguate (e.g. "Manual (UI or API)" vs a bare "API").
|
|
219
|
+
*
|
|
220
|
+
* Note `cron` covers both on-time schedule firings and catch-up for missed
|
|
221
|
+
* runs, and `startup` is specifically `run_on_start` (not catch-up).
|
|
222
|
+
*/
|
|
223
|
+
export function triggerDescription(trigger: string): string {
|
|
224
|
+
switch (trigger) {
|
|
225
|
+
case "cron":
|
|
226
|
+
return "Scheduled (cron)";
|
|
227
|
+
case "api":
|
|
228
|
+
return "Manual (UI or API)";
|
|
229
|
+
case "cloud":
|
|
230
|
+
return "Control plane";
|
|
231
|
+
case "service":
|
|
232
|
+
return "Service auto-start";
|
|
233
|
+
case "startup":
|
|
234
|
+
return "On daemon start";
|
|
235
|
+
default:
|
|
236
|
+
return humanizeStatus(trigger);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The triggers offered in the filter dropdown. `cloud` is intentionally
|
|
242
|
+
* excluded for now — control-plane runs still carry the `cloud` trigger, but
|
|
243
|
+
* it isn't a selectable filter dimension.
|
|
244
|
+
*/
|
|
245
|
+
export const FILTERABLE_TRIGGERS: readonly Trigger[] = TRIGGERS.filter((t) => t !== "cloud");
|
|
246
|
+
|
|
247
|
+
/** Chip label for the status dimension — names a whole bucket when it matches. */
|
|
248
|
+
export function statusChipLabel(statuses: string[]): string {
|
|
249
|
+
const bucket = exactBucket(statuses);
|
|
250
|
+
if (bucket) return bucket.label;
|
|
251
|
+
const [only] = statuses;
|
|
252
|
+
if (statuses.length === 1 && only !== undefined) return humanizeStatus(only);
|
|
253
|
+
return `${String(statuses.length)} statuses`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Time helpers --------------------------------------------------------
|
|
257
|
+
//
|
|
258
|
+
// A bare calendar day (`YYYY-MM-DD` from a native date input) maps to a local
|
|
259
|
+
// instant: the "from" edge is that day's 00:00, the "to" edge is its end
|
|
260
|
+
// (23:59:59.999) so picking the same day for both bounds — or the one-click
|
|
261
|
+
// "on day" shortcut — captures the whole day and round-trips back to the same
|
|
262
|
+
// date in the inputs. Bounds are stored as absolute RFC3339 (UTC) instants, the
|
|
263
|
+
// shape the list query and SSE-merge filter already expect.
|
|
264
|
+
|
|
265
|
+
/** Parse `YYYY-MM-DD` into the local Date for that day, or null if malformed. */
|
|
266
|
+
function localDay(dateStr: string): Date | null {
|
|
267
|
+
const parts = dateStr.split("-");
|
|
268
|
+
if (parts.length !== 3) return null;
|
|
269
|
+
const [y, m, d] = parts.map((p) => Number(p));
|
|
270
|
+
if (y === undefined || m === undefined || d === undefined) return null;
|
|
271
|
+
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return null;
|
|
272
|
+
const date = new Date(y, m - 1, d);
|
|
273
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** `YYYY-MM-DD` → that day's 00:00 local, as an RFC3339 instant. */
|
|
277
|
+
export function dayStartIso(dateStr: string): string | undefined {
|
|
278
|
+
const d = localDay(dateStr);
|
|
279
|
+
return d ? d.toISOString() : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `YYYY-MM-DD` → that day's last millisecond local, as an RFC3339 instant. */
|
|
283
|
+
export function dayEndIso(dateStr: string): string | undefined {
|
|
284
|
+
const d = localDay(dateStr);
|
|
285
|
+
if (!d) return undefined;
|
|
286
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999).toISOString();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** An RFC3339 instant → the local `YYYY-MM-DD` it falls on (for date inputs). */
|
|
290
|
+
export function isoToDayInput(iso: string): string {
|
|
291
|
+
const d = new Date(iso);
|
|
292
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
293
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
294
|
+
return `${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* True when the bounds describe exactly one calendar day (its 00:00 → end of
|
|
299
|
+
* day). Picking the same date for both From and To produces such a range, so
|
|
300
|
+
* the chip can read "On <date>" instead of a from–to pair.
|
|
301
|
+
*/
|
|
302
|
+
export function isWholeDay(after?: string, before?: string): boolean {
|
|
303
|
+
if (!after || !before) return false;
|
|
304
|
+
const a = isoToDayInput(after);
|
|
305
|
+
return (
|
|
306
|
+
a !== "" &&
|
|
307
|
+
a === isoToDayInput(before) &&
|
|
308
|
+
new Date(after).getTime() < new Date(before).getTime()
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Exit-code expression ------------------------------------------------
|
|
313
|
+
//
|
|
314
|
+
// The popover takes a free-form exit-code expression and normalizes it to an
|
|
315
|
+
// inclusive integer [min, max] range — the shape the server gates on. Because
|
|
316
|
+
// exit codes are integers, a strict comparison collapses to an inclusive
|
|
317
|
+
// bound: `>100` is min 101, `<150` is max 149. Tokens combine (`>100 <150`),
|
|
318
|
+
// and a bare number is an exact match (min = max = n).
|
|
319
|
+
|
|
320
|
+
export interface ExitCodeRange {
|
|
321
|
+
min?: number;
|
|
322
|
+
max?: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const EXIT_CODE_TOKEN = /^(>=|<=|>|<)?(-?\d+)$/;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse an exit-code expression into an inclusive [min, max] range. Returns
|
|
329
|
+
* `valid: false` if any token is unparseable, so the UI can flag a typo
|
|
330
|
+
* instead of silently applying half of it. An empty expression is valid with
|
|
331
|
+
* no bounds (no constraint).
|
|
332
|
+
*/
|
|
333
|
+
export function parseExitCodeRange(expr: string): { range: ExitCodeRange; valid: boolean } {
|
|
334
|
+
const range: ExitCodeRange = {};
|
|
335
|
+
const tokens = expr.trim().split(/\s+/).filter(Boolean);
|
|
336
|
+
for (const tok of tokens) {
|
|
337
|
+
const m = EXIT_CODE_TOKEN.exec(tok);
|
|
338
|
+
if (!m) return { range: {}, valid: false };
|
|
339
|
+
const numStr = m[2];
|
|
340
|
+
if (numStr === undefined) return { range: {}, valid: false };
|
|
341
|
+
const n = Number.parseInt(numStr, 10);
|
|
342
|
+
switch (m[1]) {
|
|
343
|
+
case ">":
|
|
344
|
+
range.min = n + 1;
|
|
345
|
+
break;
|
|
346
|
+
case ">=":
|
|
347
|
+
range.min = n;
|
|
348
|
+
break;
|
|
349
|
+
case "<":
|
|
350
|
+
range.max = n - 1;
|
|
351
|
+
break;
|
|
352
|
+
case "<=":
|
|
353
|
+
range.max = n;
|
|
354
|
+
break;
|
|
355
|
+
default:
|
|
356
|
+
range.min = n;
|
|
357
|
+
range.max = n;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { range, valid: true };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The resolved range for an expression, or an empty (unbounded) range if invalid. */
|
|
364
|
+
export function exitCodeRange(expr: string | undefined): ExitCodeRange {
|
|
365
|
+
if (!expr) return {};
|
|
366
|
+
const { range, valid } = parseExitCodeRange(expr);
|
|
367
|
+
return valid ? range : {};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** True when the expression resolves to at least one bound (a real filter). */
|
|
371
|
+
export function exitCodeRangeActive(expr: string | undefined): boolean {
|
|
372
|
+
const { min, max } = exitCodeRange(expr);
|
|
373
|
+
return min !== undefined || max !== undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** True when the expression is empty or fully parseable — drives input validation. */
|
|
377
|
+
export function isExitCodeExprValid(expr: string): boolean {
|
|
378
|
+
return parseExitCodeRange(expr).valid;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Chip label for an active exit-code filter, e.g. `Exit >100 <150`. */
|
|
382
|
+
export function exitCodeChipLabel(expr: string | undefined): string {
|
|
383
|
+
return `Exit ${(expr ?? "").trim()}`;
|
|
384
|
+
}
|