@runwisp/ui 0.14.0 → 0.15.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 -7
- package/src/lib/actions/portal.ts +0 -3
- package/src/lib/components/CronInput.svelte +0 -4
- package/src/lib/components/DataGrid.svelte +1 -3
- package/src/lib/components/Drawer.svelte +0 -3
- package/src/lib/components/Dropdown.svelte +2 -6
- package/src/lib/components/Modal.svelte +0 -3
- package/src/lib/components/Select.svelte +2 -5
- package/src/lib/components/StatusIndicator.svelte +0 -3
- package/src/lib/components/Tabs.svelte +0 -2
- package/src/lib/components/ToastContainer.svelte +0 -5
- package/src/lib/components/Tooltip.svelte +5 -2
- package/src/lib/components/dashboard/RunDetailPanel.svelte +133 -226
- package/src/lib/components/dashboard/RunFilterPopover.svelte +14 -14
- package/src/lib/components/dashboard/RunsList.svelte +29 -29
- package/src/lib/components/dashboard/run-filters.test.ts +31 -33
- package/src/lib/components/dashboard/run-filters.ts +33 -33
- package/src/lib/components/dashboard/run-helpers.test.ts +42 -17
- package/src/lib/components/dashboard/run-helpers.ts +53 -17
- package/src/lib/components/dashboard/status-config.ts +9 -8
- package/src/lib/utils/error.ts +0 -3
- package/src/lib/utils/format.test.ts +2 -6
- package/src/lib/utils/format.ts +28 -3
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
if (run.status === "running") return "live";
|
|
176
176
|
if (run.status === "pending") return "queued";
|
|
177
177
|
if (displayed === "failed" || displayed === "crashed") {
|
|
178
|
-
return "exit " + String(run.
|
|
178
|
+
return "exit " + String(run.exitCode);
|
|
179
179
|
}
|
|
180
180
|
return runDuration(run) ?? "—";
|
|
181
181
|
}
|
|
@@ -277,30 +277,30 @@
|
|
|
277
277
|
// (page-injected or popover-set) always applies.
|
|
278
278
|
function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
|
|
279
279
|
const filter: NonNullable<RunSelector["filter"]> = {};
|
|
280
|
-
if (taskNameFilter) filter.
|
|
281
|
-
else if (filters.
|
|
280
|
+
if (taskNameFilter) filter.taskName = taskNameFilter;
|
|
281
|
+
else if (filters.taskName) filter.taskName = filters.taskName;
|
|
282
282
|
if (!showFilters) return filter;
|
|
283
283
|
if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
|
|
284
284
|
const query = filters.search.trim();
|
|
285
285
|
if (query) filter.search = query;
|
|
286
|
-
if (filters.
|
|
287
|
-
if (filters.
|
|
288
|
-
if (filters.
|
|
289
|
-
const exit = exitCodeRange(filters.
|
|
290
|
-
if (exit.min !== undefined) filter.
|
|
291
|
-
if (exit.max !== undefined) filter.
|
|
292
|
-
if (filters.
|
|
286
|
+
if (filters.createdAfter) filter.createdAfter = filters.createdAfter;
|
|
287
|
+
if (filters.createdBefore) filter.createdBefore = filters.createdBefore;
|
|
288
|
+
if (filters.triggeredBy) filter.triggeredBy = filters.triggeredBy;
|
|
289
|
+
const exit = exitCodeRange(filters.exitCode);
|
|
290
|
+
if (exit.min !== undefined) filter.exitCodeMin = exit.min;
|
|
291
|
+
if (exit.max !== undefined) filter.exitCodeMax = exit.max;
|
|
292
|
+
if (filters.retriesOnly) filter.retriesOnly = true;
|
|
293
293
|
return filter;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
function buildSelector(): RunSelector {
|
|
297
297
|
if (!selectAllMode) {
|
|
298
|
-
return {
|
|
298
|
+
return { matchAll: false, ids: [...explicitIds] };
|
|
299
299
|
}
|
|
300
300
|
return {
|
|
301
|
-
|
|
301
|
+
matchAll: true,
|
|
302
302
|
filter: buildSelectorFilter(),
|
|
303
|
-
|
|
303
|
+
exceptIds: [...exceptIds],
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
306
|
|
|
@@ -319,19 +319,19 @@
|
|
|
319
319
|
case "time":
|
|
320
320
|
return timeChipLabel();
|
|
321
321
|
case "task":
|
|
322
|
-
return filters.
|
|
323
|
-
case "
|
|
324
|
-
return "Trigger: " + triggerDescription(filters.
|
|
325
|
-
case "
|
|
326
|
-
return exitCodeChipLabel(filters.
|
|
322
|
+
return filters.taskName ?? "";
|
|
323
|
+
case "triggeredBy":
|
|
324
|
+
return "Trigger: " + triggerDescription(filters.triggeredBy ?? "");
|
|
325
|
+
case "exitCode":
|
|
326
|
+
return exitCodeChipLabel(filters.exitCode);
|
|
327
327
|
case "retries":
|
|
328
328
|
return "Retries only";
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
function timeChipLabel(): string {
|
|
333
|
-
const after = filters.
|
|
334
|
-
const before = filters.
|
|
333
|
+
const after = filters.createdAfter;
|
|
334
|
+
const before = filters.createdBefore;
|
|
335
335
|
if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
|
|
336
336
|
if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
|
|
337
337
|
if (after) return `Since ${formatDateTime(after)}`;
|
|
@@ -350,9 +350,9 @@
|
|
|
350
350
|
const sel = buildSelector();
|
|
351
351
|
// Narrow the selector to the affected predicate when in explicit mode
|
|
352
352
|
// so we don't ask the server to operate on rows the UI excluded.
|
|
353
|
-
const narrowed: RunSelector = sel.
|
|
353
|
+
const narrowed: RunSelector = sel.matchAll
|
|
354
354
|
? sel
|
|
355
|
-
: {
|
|
355
|
+
: { matchAll: false, ids: affected.map((r) => r.id) };
|
|
356
356
|
handler(narrowed, affected);
|
|
357
357
|
clearSelection();
|
|
358
358
|
}
|
|
@@ -368,7 +368,7 @@
|
|
|
368
368
|
// reliably re-triggers its fetch effect.
|
|
369
369
|
filters = {
|
|
370
370
|
...filters,
|
|
371
|
-
|
|
371
|
+
sortDirection: filters.sortDirection === "asc" ? "desc" : "asc",
|
|
372
372
|
};
|
|
373
373
|
}
|
|
374
374
|
|
|
@@ -480,7 +480,7 @@
|
|
|
480
480
|
{#snippet icon()}
|
|
481
481
|
<ArrowUpDown
|
|
482
482
|
size={14}
|
|
483
|
-
class="text-on-surface-muted {filters.
|
|
483
|
+
class="text-on-surface-muted {filters.sortDirection === 'asc'
|
|
484
484
|
? 'rotate-180'
|
|
485
485
|
: ''}"
|
|
486
486
|
/>
|
|
@@ -626,7 +626,7 @@
|
|
|
626
626
|
checked={isRowSelected(run.id)}
|
|
627
627
|
onchange={() => toggleRow(run.id)}
|
|
628
628
|
onclick={(e) => e.stopPropagation()}
|
|
629
|
-
aria-label={`Select run from ${formatDateTime(run.
|
|
629
|
+
aria-label={`Select run from ${formatDateTime(run.startAt ?? run.createdAt)}`}
|
|
630
630
|
class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 {selectionActive
|
|
631
631
|
? 'opacity-100'
|
|
632
632
|
: 'group-hover/row:opacity-100'}"
|
|
@@ -639,9 +639,9 @@
|
|
|
639
639
|
{@const config = getRunStatusConfig(dstatus)}
|
|
640
640
|
{@const running = run.status === "running"}
|
|
641
641
|
{@const spine = config.dot.replace(" animate-pulse", "")}
|
|
642
|
-
{@const startedAt = run.
|
|
642
|
+
{@const startedAt = run.startAt ?? run.createdAt}
|
|
643
643
|
{@const retry = runRetryLabel(run)}
|
|
644
|
-
{@const suffix = instanceSuffix(run.
|
|
644
|
+
{@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
|
|
645
645
|
<button
|
|
646
646
|
class="btn-scale group relative w-full rounded-[3px] border text-left select-none {showTaskName
|
|
647
647
|
? 'p-3'
|
|
@@ -660,7 +660,7 @@
|
|
|
660
660
|
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
661
661
|
<span class="flex items-center gap-1.5">
|
|
662
662
|
<span class="truncate font-mono text-[13px] font-semibold text-on-surface">
|
|
663
|
-
{run.
|
|
663
|
+
{run.taskName}{#if suffix}<span class="text-on-surface-muted"
|
|
664
664
|
>{suffix}</span
|
|
665
665
|
>{/if}
|
|
666
666
|
</span>
|
|
@@ -675,7 +675,7 @@
|
|
|
675
675
|
title={formatFullDateTime(startedAt)}
|
|
676
676
|
>
|
|
677
677
|
<span class="truncate">{formatDateTime(startedAt)}</span>
|
|
678
|
-
<span class="shrink-0">· {formatTriggeredByLabel(run.
|
|
678
|
+
<span class="shrink-0">· {formatTriggeredByLabel(run.triggeredBy)}</span>
|
|
679
679
|
{#if retry}
|
|
680
680
|
<span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
|
|
681
681
|
>{retry}</span
|
|
@@ -47,7 +47,7 @@ describe("emptyRunFilters", () => {
|
|
|
47
47
|
it("is a fully-open filter — no dimension active, newest-first", () => {
|
|
48
48
|
const f = emptyRunFilters();
|
|
49
49
|
expect(f.statuses).toEqual([]);
|
|
50
|
-
expect(f.
|
|
50
|
+
expect(f.sortDirection).toBe("desc");
|
|
51
51
|
expect(activeFilterCount(f)).toBe(0);
|
|
52
52
|
});
|
|
53
53
|
});
|
|
@@ -75,56 +75,54 @@ describe("isNeedsAttention", () => {
|
|
|
75
75
|
describe("dimensionActive / activeDimensions", () => {
|
|
76
76
|
it("detects each dimension independently", () => {
|
|
77
77
|
expect(dimensionActive(base({ statuses: ["failed"] }), "status")).toBe(true);
|
|
78
|
-
expect(dimensionActive(base({
|
|
79
|
-
expect(dimensionActive(base({
|
|
80
|
-
|
|
81
|
-
);
|
|
82
|
-
expect(dimensionActive(base({
|
|
83
|
-
expect(dimensionActive(base({
|
|
84
|
-
expect(dimensionActive(base({
|
|
85
|
-
expect(dimensionActive(base({ exit_code: ">100" }), "exit_code")).toBe(true);
|
|
86
|
-
expect(dimensionActive(base({ retries_only: true }), "retries")).toBe(true);
|
|
78
|
+
expect(dimensionActive(base({ createdAfter: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
|
|
79
|
+
expect(dimensionActive(base({ createdBefore: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
|
|
80
|
+
expect(dimensionActive(base({ taskName: "backup" }), "task")).toBe(true);
|
|
81
|
+
expect(dimensionActive(base({ triggeredBy: "cron" }), "triggeredBy")).toBe(true);
|
|
82
|
+
expect(dimensionActive(base({ exitCode: "137" }), "exitCode")).toBe(true);
|
|
83
|
+
expect(dimensionActive(base({ exitCode: ">100" }), "exitCode")).toBe(true);
|
|
84
|
+
expect(dimensionActive(base({ retriesOnly: true }), "retries")).toBe(true);
|
|
87
85
|
});
|
|
88
86
|
|
|
89
87
|
it("treats an exact exit code of 0 as active (distinct from absent)", () => {
|
|
90
|
-
expect(dimensionActive(base({
|
|
88
|
+
expect(dimensionActive(base({ exitCode: "0" }), "exitCode")).toBe(true);
|
|
91
89
|
});
|
|
92
90
|
|
|
93
91
|
it("treats an empty or unparseable exit-code expression as inactive", () => {
|
|
94
|
-
expect(dimensionActive(base({
|
|
95
|
-
expect(dimensionActive(base({
|
|
92
|
+
expect(dimensionActive(base({ exitCode: "" }), "exitCode")).toBe(false);
|
|
93
|
+
expect(dimensionActive(base({ exitCode: "nonsense" }), "exitCode")).toBe(false);
|
|
96
94
|
});
|
|
97
95
|
|
|
98
96
|
it("lists active dimensions in display (most→least useful) order", () => {
|
|
99
97
|
const f = base({
|
|
100
|
-
|
|
98
|
+
retriesOnly: true,
|
|
101
99
|
statuses: ["failed"],
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
triggeredBy: "cron",
|
|
101
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
104
102
|
});
|
|
105
|
-
expect(activeDimensions(f)).toEqual(["status", "time", "
|
|
103
|
+
expect(activeDimensions(f)).toEqual(["status", "time", "triggeredBy", "retries"]);
|
|
106
104
|
});
|
|
107
105
|
});
|
|
108
106
|
|
|
109
107
|
describe("clearDimension", () => {
|
|
110
108
|
it("resets one dimension and leaves the rest untouched", () => {
|
|
111
|
-
const f = base({ statuses: ["failed"],
|
|
112
|
-
const cleared = clearDimension(f, "
|
|
113
|
-
expect(cleared.
|
|
109
|
+
const f = base({ statuses: ["failed"], triggeredBy: "cron", retriesOnly: true });
|
|
110
|
+
const cleared = clearDimension(f, "triggeredBy");
|
|
111
|
+
expect(cleared.triggeredBy).toBeUndefined();
|
|
114
112
|
expect(cleared.statuses).toEqual(["failed"]);
|
|
115
|
-
expect(cleared.
|
|
113
|
+
expect(cleared.retriesOnly).toBe(true);
|
|
116
114
|
});
|
|
117
115
|
|
|
118
116
|
it("clears the exit-code expression", () => {
|
|
119
|
-
const f = base({
|
|
120
|
-
expect(clearDimension(f, "
|
|
117
|
+
const f = base({ exitCode: ">100 <150" });
|
|
118
|
+
expect(clearDimension(f, "exitCode").exitCode).toBeUndefined();
|
|
121
119
|
});
|
|
122
120
|
|
|
123
121
|
it("clears both time bounds together", () => {
|
|
124
|
-
const f = base({
|
|
122
|
+
const f = base({ createdAfter: "2026-01-01T00:00:00Z", createdBefore: "2026-02-01" });
|
|
125
123
|
const cleared = clearDimension(f, "time");
|
|
126
|
-
expect(cleared.
|
|
127
|
-
expect(cleared.
|
|
124
|
+
expect(cleared.createdAfter).toBeUndefined();
|
|
125
|
+
expect(cleared.createdBefore).toBeUndefined();
|
|
128
126
|
});
|
|
129
127
|
});
|
|
130
128
|
|
|
@@ -132,18 +130,18 @@ describe("clearPopoverFilters", () => {
|
|
|
132
130
|
it("clears every popover dimension but keeps search and sort", () => {
|
|
133
131
|
const f = base({
|
|
134
132
|
search: "nightly",
|
|
135
|
-
|
|
133
|
+
sortDirection: "asc",
|
|
136
134
|
statuses: ["failed"],
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
taskName: "backup",
|
|
136
|
+
triggeredBy: "cron",
|
|
137
|
+
exitCode: "1",
|
|
138
|
+
retriesOnly: true,
|
|
139
|
+
createdAfter: "2026-01-01T00:00:00Z",
|
|
142
140
|
});
|
|
143
141
|
const cleared = clearPopoverFilters(f);
|
|
144
142
|
expect(activeFilterCount(cleared)).toBe(0);
|
|
145
143
|
expect(cleared.search).toBe("nightly");
|
|
146
|
-
expect(cleared.
|
|
144
|
+
expect(cleared.sortDirection).toBe("asc");
|
|
147
145
|
});
|
|
148
146
|
});
|
|
149
147
|
|
|
@@ -8,7 +8,7 @@ export type RunsListSortDirection = "asc" | "desc" | "";
|
|
|
8
8
|
/**
|
|
9
9
|
* The single filter shape shared by the runs list, the filter popover, the
|
|
10
10
|
* SSE-merge source, and the bulk selector. Every field beyond `search` and
|
|
11
|
-
* `
|
|
11
|
+
* `sortDirection` is an optional dimension applied server-side; the zero
|
|
12
12
|
* value (`emptyRunFilters`) matches every run.
|
|
13
13
|
*
|
|
14
14
|
* `statuses` is a multi-select set joined to a comma-separated string only at
|
|
@@ -19,26 +19,26 @@ export type RunsListSortDirection = "asc" | "desc" | "";
|
|
|
19
19
|
export interface RunsListFilters {
|
|
20
20
|
search: string;
|
|
21
21
|
statuses: string[];
|
|
22
|
-
|
|
22
|
+
sortDirection: RunsListSortDirection;
|
|
23
23
|
// Optional dimensions explicitly admit `undefined` so a dimension can be
|
|
24
24
|
// cleared by reassignment (`{ ...f, x: undefined }`) under the project's
|
|
25
25
|
// exactOptionalPropertyTypes — a fresh object reference is what re-triggers
|
|
26
26
|
// the parent's fetch effect through several levels of `bind:`.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
taskName?: string | undefined;
|
|
28
|
+
createdAfter?: string | undefined;
|
|
29
|
+
createdBefore?: string | undefined;
|
|
30
|
+
triggeredBy?: string | undefined;
|
|
31
31
|
// A human exit-code expression — a bare code (`137`), a comparison (`>100`,
|
|
32
32
|
// `<=150`), or a space-separated combination (`>100 <150`). Normalized to an
|
|
33
33
|
// inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
|
|
34
|
-
// server only ever sees `
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
// server only ever sees `exitCodeMin` / `exitCodeMax`.
|
|
35
|
+
exitCode?: string | undefined;
|
|
36
|
+
retriesOnly?: boolean | undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** A fresh default filter — no dimensions active, newest-first. */
|
|
40
40
|
export function emptyRunFilters(): RunsListFilters {
|
|
41
|
-
return { search: "", statuses: [],
|
|
41
|
+
return { search: "", statuses: [], sortDirection: "desc" };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -131,14 +131,14 @@ function exactBucket(statuses: string[]): StatusBucket | undefined {
|
|
|
131
131
|
* page the task name is the page scope (injected at fetch time), never a
|
|
132
132
|
* popover-set filter, so it stays absent from those filters and is not counted.
|
|
133
133
|
*/
|
|
134
|
-
export type FilterDimension = "status" | "time" | "task" | "
|
|
134
|
+
export type FilterDimension = "status" | "time" | "task" | "triggeredBy" | "exitCode" | "retries";
|
|
135
135
|
|
|
136
136
|
const DIMENSION_ORDER: FilterDimension[] = [
|
|
137
137
|
"status",
|
|
138
138
|
"time",
|
|
139
139
|
"task",
|
|
140
|
-
"
|
|
141
|
-
"
|
|
140
|
+
"triggeredBy",
|
|
141
|
+
"exitCode",
|
|
142
142
|
"retries",
|
|
143
143
|
];
|
|
144
144
|
|
|
@@ -148,15 +148,15 @@ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boole
|
|
|
148
148
|
case "status":
|
|
149
149
|
return f.statuses.length > 0;
|
|
150
150
|
case "time":
|
|
151
|
-
return Boolean(f.
|
|
151
|
+
return Boolean(f.createdAfter) || Boolean(f.createdBefore);
|
|
152
152
|
case "task":
|
|
153
|
-
return Boolean(f.
|
|
154
|
-
case "
|
|
155
|
-
return Boolean(f.
|
|
156
|
-
case "
|
|
157
|
-
return exitCodeRangeActive(f.
|
|
153
|
+
return Boolean(f.taskName);
|
|
154
|
+
case "triggeredBy":
|
|
155
|
+
return Boolean(f.triggeredBy);
|
|
156
|
+
case "exitCode":
|
|
157
|
+
return exitCodeRangeActive(f.exitCode);
|
|
158
158
|
case "retries":
|
|
159
|
-
return Boolean(f.
|
|
159
|
+
return Boolean(f.retriesOnly);
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
@@ -176,15 +176,15 @@ export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsLi
|
|
|
176
176
|
case "status":
|
|
177
177
|
return { ...f, statuses: [] };
|
|
178
178
|
case "time":
|
|
179
|
-
return { ...f,
|
|
179
|
+
return { ...f, createdAfter: undefined, createdBefore: undefined };
|
|
180
180
|
case "task":
|
|
181
|
-
return { ...f,
|
|
182
|
-
case "
|
|
183
|
-
return { ...f,
|
|
184
|
-
case "
|
|
185
|
-
return { ...f,
|
|
181
|
+
return { ...f, taskName: undefined };
|
|
182
|
+
case "triggeredBy":
|
|
183
|
+
return { ...f, triggeredBy: undefined };
|
|
184
|
+
case "exitCode":
|
|
185
|
+
return { ...f, exitCode: undefined };
|
|
186
186
|
case "retries":
|
|
187
|
-
return { ...f,
|
|
187
|
+
return { ...f, retriesOnly: undefined };
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
@@ -196,12 +196,12 @@ export function clearPopoverFilters(f: RunsListFilters): RunsListFilters {
|
|
|
196
196
|
return {
|
|
197
197
|
...f,
|
|
198
198
|
statuses: [],
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
199
|
+
createdAfter: undefined,
|
|
200
|
+
createdBefore: undefined,
|
|
201
|
+
taskName: undefined,
|
|
202
|
+
triggeredBy: undefined,
|
|
203
|
+
exitCode: undefined,
|
|
204
|
+
retriesOnly: undefined,
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
207
|
|
|
@@ -2,29 +2,54 @@
|
|
|
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";
|
|
5
6
|
import {
|
|
6
7
|
runDuration,
|
|
7
8
|
runStartDelay,
|
|
9
|
+
runVerdict,
|
|
8
10
|
formatTriggeredByLabel,
|
|
9
11
|
runRetryLabel,
|
|
10
12
|
instanceSuffix,
|
|
11
13
|
} from "./run-helpers.js";
|
|
12
14
|
|
|
15
|
+
describe("runVerdict", () => {
|
|
16
|
+
it("covers every run status", () => {
|
|
17
|
+
for (const status of RUN_STATUSES) {
|
|
18
|
+
expect(runVerdict(status).verb, status).toBeTruthy();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("phrases a timed outcome so a duration reads after it", () => {
|
|
23
|
+
expect(runVerdict("success")).toEqual({ verb: "succeeded in", timed: true });
|
|
24
|
+
expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("marks statuses that never produced a duration as untimed", () => {
|
|
28
|
+
// These end without ever running, so the caller renders the verb alone
|
|
29
|
+
// rather than "skipped after —".
|
|
30
|
+
expect(runVerdict("missed").timed).toBe(false);
|
|
31
|
+
expect(runVerdict("skipped").timed).toBe(false);
|
|
32
|
+
expect(runVerdict("dst_skipped").timed).toBe(false);
|
|
33
|
+
expect(runVerdict("queue_full").timed).toBe(false);
|
|
34
|
+
expect(runVerdict("pending").timed).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
13
38
|
describe("runDuration", () => {
|
|
14
|
-
it("returns undefined when
|
|
39
|
+
it("returns undefined when startAt is not set", () => {
|
|
15
40
|
expect(runDuration({})).toBeUndefined();
|
|
16
41
|
});
|
|
17
42
|
|
|
18
|
-
it("returns formatted duration when both
|
|
43
|
+
it("returns formatted duration when both startAt and endAt are set", () => {
|
|
19
44
|
const start = "2024-06-15T12:00:00.000Z";
|
|
20
45
|
const end = "2024-06-15T12:00:05.000Z";
|
|
21
|
-
expect(runDuration({
|
|
46
|
+
expect(runDuration({ startAt: start, endAt: end })).toBe("5s");
|
|
22
47
|
});
|
|
23
48
|
|
|
24
49
|
it("returns ms duration for sub-second runs", () => {
|
|
25
50
|
const start = "2024-06-15T12:00:00.000Z";
|
|
26
51
|
const end = "2024-06-15T12:00:00.500Z";
|
|
27
|
-
expect(runDuration({
|
|
52
|
+
expect(runDuration({ startAt: start, endAt: end })).toBe("500ms");
|
|
28
53
|
});
|
|
29
54
|
|
|
30
55
|
describe("with fake clock", () => {
|
|
@@ -36,35 +61,35 @@ describe("runDuration", () => {
|
|
|
36
61
|
vi.useRealTimers();
|
|
37
62
|
});
|
|
38
63
|
|
|
39
|
-
it("uses current time when
|
|
40
|
-
expect(runDuration({
|
|
64
|
+
it("uses current time when endAt is not set (run still in progress)", () => {
|
|
65
|
+
expect(runDuration({ startAt: "2024-06-15T12:00:00.000Z" })).toBe("2s");
|
|
41
66
|
});
|
|
42
67
|
});
|
|
43
68
|
|
|
44
69
|
it("counts against an injected now for an in-progress run", () => {
|
|
45
70
|
const start = "2024-06-15T12:00:00.000Z";
|
|
46
71
|
const now = new Date("2024-06-15T12:00:07.000Z").getTime();
|
|
47
|
-
expect(runDuration({
|
|
72
|
+
expect(runDuration({ startAt: start }, now)).toBe("7s");
|
|
48
73
|
});
|
|
49
74
|
|
|
50
75
|
it("ignores the injected now once the run has ended", () => {
|
|
51
76
|
const start = "2024-06-15T12:00:00.000Z";
|
|
52
77
|
const end = "2024-06-15T12:00:05.000Z";
|
|
53
78
|
const now = new Date("2024-06-15T12:01:00.000Z").getTime();
|
|
54
|
-
expect(runDuration({
|
|
79
|
+
expect(runDuration({ startAt: start, endAt: end }, now)).toBe("5s");
|
|
55
80
|
});
|
|
56
81
|
});
|
|
57
82
|
|
|
58
83
|
describe("runStartDelay", () => {
|
|
59
|
-
it("returns undefined when
|
|
60
|
-
expect(runStartDelay({
|
|
84
|
+
it("returns undefined when startAt is not set", () => {
|
|
85
|
+
expect(runStartDelay({ createdAt: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
|
|
61
86
|
});
|
|
62
87
|
|
|
63
88
|
it("returns undefined when the run started within a second of its tick", () => {
|
|
64
89
|
expect(
|
|
65
90
|
runStartDelay({
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
createdAt: "2024-06-15T12:00:00.000Z",
|
|
92
|
+
startAt: "2024-06-15T12:00:00.300Z",
|
|
68
93
|
}),
|
|
69
94
|
).toBeUndefined();
|
|
70
95
|
});
|
|
@@ -72,8 +97,8 @@ describe("runStartDelay", () => {
|
|
|
72
97
|
it("formats the jitter/queue gap when the run started meaningfully later", () => {
|
|
73
98
|
expect(
|
|
74
99
|
runStartDelay({
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
createdAt: "2024-06-15T03:00:00.000Z",
|
|
101
|
+
startAt: "2024-06-15T03:07:12.000Z",
|
|
77
102
|
}),
|
|
78
103
|
).toBe("7m 12s");
|
|
79
104
|
});
|
|
@@ -91,15 +116,15 @@ describe("formatTriggeredByLabel", () => {
|
|
|
91
116
|
|
|
92
117
|
describe("runRetryLabel", () => {
|
|
93
118
|
it("returns undefined for a first attempt that is not a retry", () => {
|
|
94
|
-
expect(runRetryLabel({
|
|
119
|
+
expect(runRetryLabel({ retryAttempt: 0 })).toBeUndefined();
|
|
95
120
|
});
|
|
96
121
|
|
|
97
122
|
it("labels a run with a positive attempt number", () => {
|
|
98
|
-
expect(runRetryLabel({
|
|
123
|
+
expect(runRetryLabel({ retryAttempt: 2 })).toBe("retry #2");
|
|
99
124
|
});
|
|
100
125
|
|
|
101
126
|
it("labels a run that points back at the run it re-attempts", () => {
|
|
102
|
-
expect(runRetryLabel({
|
|
127
|
+
expect(runRetryLabel({ retryAttempt: 1, retryOfRunId: "01JABC" })).toBe("retry #1");
|
|
103
128
|
});
|
|
104
129
|
});
|
|
105
130
|
|
|
@@ -1,30 +1,68 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import type { Run } from "@runwisp/common";
|
|
4
|
+
import type { Run, RunStatus } from "@runwisp/common";
|
|
5
5
|
import { formatDuration } from "../../utils/format.js";
|
|
6
6
|
|
|
7
|
+
export interface RunVerdict {
|
|
8
|
+
/** Verb phrase for the outcome, ending in its preposition when `timed`. */
|
|
9
|
+
verb: string;
|
|
10
|
+
/**
|
|
11
|
+
* Whether the phrase expects a duration after it. False for statuses that
|
|
12
|
+
* never produced one (nothing ran, or nothing has yet), so the caller
|
|
13
|
+
* renders the verb alone rather than "skipped after —".
|
|
14
|
+
*/
|
|
15
|
+
timed: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The run's outcome as a verb phrase, so a detail view can state it as one
|
|
20
|
+
* sentence ("succeeded in 933ms") instead of a status badge competing with a
|
|
21
|
+
* separate duration readout for the same glance.
|
|
22
|
+
*/
|
|
23
|
+
const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
24
|
+
success: { verb: "succeeded in", timed: true },
|
|
25
|
+
failed: { verb: "failed after", timed: true },
|
|
26
|
+
crashed: { verb: "crashed after", timed: true },
|
|
27
|
+
timeout: { verb: "timed out after", timed: true },
|
|
28
|
+
stopped: { verb: "stopped after", timed: true },
|
|
29
|
+
daemon_stopped: { verb: "cut short after", timed: true },
|
|
30
|
+
log_overflow: { verb: "killed after", timed: true },
|
|
31
|
+
start_failed: { verb: "gave up after", timed: true },
|
|
32
|
+
ended: { verb: "ended after", timed: true },
|
|
33
|
+
running: { verb: "running for", timed: true },
|
|
34
|
+
pending: { verb: "queued", timed: false },
|
|
35
|
+
missed: { verb: "never ran", timed: false },
|
|
36
|
+
skipped: { verb: "skipped", timed: false },
|
|
37
|
+
dst_skipped: { verb: "skipped", timed: false },
|
|
38
|
+
queue_full: { verb: "skipped", timed: false },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function runVerdict(status: RunStatus): RunVerdict {
|
|
42
|
+
return RUN_VERDICTS[status];
|
|
43
|
+
}
|
|
44
|
+
|
|
7
45
|
export function runDuration(
|
|
8
|
-
run: Pick<Run, "
|
|
46
|
+
run: Pick<Run, "startAt" | "endAt">,
|
|
9
47
|
now: number = Date.now(),
|
|
10
48
|
): string | undefined {
|
|
11
|
-
if (!run.
|
|
12
|
-
const start = new Date(run.
|
|
13
|
-
const end = run.
|
|
49
|
+
if (!run.startAt) return undefined;
|
|
50
|
+
const start = new Date(run.startAt).getTime();
|
|
51
|
+
const end = run.endAt ? new Date(run.endAt).getTime() : now;
|
|
14
52
|
return formatDuration(end - start);
|
|
15
53
|
}
|
|
16
54
|
|
|
17
55
|
/**
|
|
18
|
-
* Gap between when a run was scheduled (`
|
|
19
|
-
* it actually started (`
|
|
56
|
+
* Gap between when a run was scheduled (`createdAt`, the cron tick) and when
|
|
57
|
+
* it actually started (`startAt`), formatted — or undefined when the two are
|
|
20
58
|
* within a second of each other. This is the visible face of `jitter`: a
|
|
21
59
|
* jittered run is created at its tick but starts later inside the window, and
|
|
22
60
|
* this is by how much. It also surfaces queue-wait, since a queued run is
|
|
23
61
|
* created when it joins the line and started when the line clears.
|
|
24
62
|
*/
|
|
25
|
-
export function runStartDelay(run: Pick<Run, "
|
|
26
|
-
if (!run.
|
|
27
|
-
const delay = new Date(run.
|
|
63
|
+
export function runStartDelay(run: Pick<Run, "createdAt" | "startAt">): string | undefined {
|
|
64
|
+
if (!run.startAt) return undefined;
|
|
65
|
+
const delay = new Date(run.startAt).getTime() - new Date(run.createdAt).getTime();
|
|
28
66
|
if (delay < 1000) return undefined;
|
|
29
67
|
return formatDuration(delay);
|
|
30
68
|
}
|
|
@@ -44,8 +82,8 @@ export function instanceSuffix(instanceIndex: number, instanceCount: number): st
|
|
|
44
82
|
return "";
|
|
45
83
|
}
|
|
46
84
|
|
|
47
|
-
/** Human label for why a run fired (the `
|
|
48
|
-
export function formatTriggeredByLabel(triggeredBy: Run["
|
|
85
|
+
/** Human label for why a run fired (the `triggeredBy` source). */
|
|
86
|
+
export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string {
|
|
49
87
|
if (triggeredBy === "api") return "API";
|
|
50
88
|
if (triggeredBy === "cron") return "Cron";
|
|
51
89
|
if (triggeredBy === "service") return "Service";
|
|
@@ -58,11 +96,9 @@ export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string
|
|
|
58
96
|
* A run is a retry if it carries a positive attempt number or points back at
|
|
59
97
|
* the run it re-attempts.
|
|
60
98
|
*/
|
|
61
|
-
export function runRetryLabel(
|
|
62
|
-
run
|
|
63
|
-
|
|
64
|
-
if (run.retry_attempt > 0 || run.retry_of_run_id) {
|
|
65
|
-
return `retry #${String(run.retry_attempt)}`;
|
|
99
|
+
export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">): string | undefined {
|
|
100
|
+
if (run.retryAttempt > 0 || run.retryOfRunId) {
|
|
101
|
+
return `retry #${String(run.retryAttempt)}`;
|
|
66
102
|
}
|
|
67
103
|
return undefined;
|
|
68
104
|
}
|