@runwisp/ui 0.10.0 → 0.12.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 +779 -213
- package/src/lib/components/dashboard/RunFilterPopover.svelte +299 -0
- package/src/lib/components/dashboard/RunsList.svelte +441 -235
- package/src/lib/components/dashboard/cron-format.ts +4 -1
- 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 +33 -0
- package/src/lib/components/dashboard/run-helpers.ts +20 -2
- package/src/lib/index.ts +21 -4
- package/src/lib/log-console/LogCache.svelte.ts +55 -1
- package/src/lib/log-console/types.ts +17 -0
- package/src/lib/theme-tokens.css +74 -62
- package/src/lib/theme.css +9 -5
- package/src/lib/utils/format.test.ts +38 -0
- package/src/lib/utils/format.ts +36 -0
- package/src/lib/utils/ticking-now.svelte.test.ts +45 -0
- package/src/lib/utils/ticking-now.svelte.ts +32 -0
- package/src/stories/assets/runwisp_c.svg +2 -2
|
@@ -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.replaceAll("_", " ");
|
|
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(Number);
|
|
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
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
runStartDelay,
|
|
8
8
|
formatTriggeredByLabel,
|
|
9
9
|
runRetryLabel,
|
|
10
|
+
instanceSuffix,
|
|
10
11
|
} from "./run-helpers.js";
|
|
11
12
|
|
|
12
13
|
describe("runDuration", () => {
|
|
@@ -39,6 +40,19 @@ describe("runDuration", () => {
|
|
|
39
40
|
expect(runDuration({ start_at: "2024-06-15T12:00:00.000Z" })).toBe("2s");
|
|
40
41
|
});
|
|
41
42
|
});
|
|
43
|
+
|
|
44
|
+
it("counts against an injected now for an in-progress run", () => {
|
|
45
|
+
const start = "2024-06-15T12:00:00.000Z";
|
|
46
|
+
const now = new Date("2024-06-15T12:00:07.000Z").getTime();
|
|
47
|
+
expect(runDuration({ start_at: start }, now)).toBe("7s");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("ignores the injected now once the run has ended", () => {
|
|
51
|
+
const start = "2024-06-15T12:00:00.000Z";
|
|
52
|
+
const end = "2024-06-15T12:00:05.000Z";
|
|
53
|
+
const now = new Date("2024-06-15T12:01:00.000Z").getTime();
|
|
54
|
+
expect(runDuration({ start_at: start, end_at: end }, now)).toBe("5s");
|
|
55
|
+
});
|
|
42
56
|
});
|
|
43
57
|
|
|
44
58
|
describe("runStartDelay", () => {
|
|
@@ -88,3 +102,22 @@ describe("runRetryLabel", () => {
|
|
|
88
102
|
expect(runRetryLabel({ retry_attempt: 1, retry_of_run_id: "01JABC" })).toBe("retry #1");
|
|
89
103
|
});
|
|
90
104
|
});
|
|
105
|
+
|
|
106
|
+
describe("instanceSuffix", () => {
|
|
107
|
+
it("returns no suffix for a single-instance task", () => {
|
|
108
|
+
expect(instanceSuffix(0, 1)).toBe("");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns no suffix for a non-service (count 0)", () => {
|
|
112
|
+
expect(instanceSuffix(0, 0)).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("suffixes slot 0 of a multi-instance service as #1 (1-based)", () => {
|
|
116
|
+
expect(instanceSuffix(0, 3)).toBe("#1");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("maps the stored 0-based slot to a 1-based suffix", () => {
|
|
120
|
+
expect(instanceSuffix(1, 3)).toBe("#2");
|
|
121
|
+
expect(instanceSuffix(2, 3)).toBe("#3");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
import type { Run } from "@runwisp/common";
|
|
5
5
|
import { formatDuration } from "../../utils/format.js";
|
|
6
6
|
|
|
7
|
-
export function runDuration(
|
|
7
|
+
export function runDuration(
|
|
8
|
+
run: Pick<Run, "start_at" | "end_at">,
|
|
9
|
+
now: number = Date.now(),
|
|
10
|
+
): string | undefined {
|
|
8
11
|
if (!run.start_at) return undefined;
|
|
9
12
|
const start = new Date(run.start_at).getTime();
|
|
10
|
-
const end = run.end_at ? new Date(run.end_at).getTime() :
|
|
13
|
+
const end = run.end_at ? new Date(run.end_at).getTime() : now;
|
|
11
14
|
return formatDuration(end - start);
|
|
12
15
|
}
|
|
13
16
|
|
|
@@ -26,6 +29,21 @@ export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string
|
|
|
26
29
|
return formatDuration(delay);
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Display suffix for a run's instance slot. A service configured with more than
|
|
34
|
+
* one instance gets a 1-based suffix (`#1`, `#2`, …) on every one of its runs;
|
|
35
|
+
* a single-instance task (or any non-service, where `instanceCount` is 1)
|
|
36
|
+
* returns an empty string so the bare task name is shown. `instanceIndex` is
|
|
37
|
+
* the stored 0-based slot; `instanceCount` is the task's currently configured
|
|
38
|
+
* instance count.
|
|
39
|
+
*/
|
|
40
|
+
export function instanceSuffix(instanceIndex: number, instanceCount: number): string {
|
|
41
|
+
if (instanceCount > 1) {
|
|
42
|
+
return `#${String(instanceIndex + 1)}`;
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
/** Human label for why a run fired (the `triggered_by` source). */
|
|
30
48
|
export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string {
|
|
31
49
|
if (triggeredBy === "api") return "API";
|
package/src/lib/index.ts
CHANGED
|
@@ -80,10 +80,25 @@ export { LogFetcher } from "./log-console/LogFetcher.svelte.js";
|
|
|
80
80
|
|
|
81
81
|
export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
|
|
82
82
|
export { default as RunsList } from "./components/dashboard/RunsList.svelte";
|
|
83
|
-
export
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
|
|
84
|
+
export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
|
|
85
|
+
export {
|
|
86
|
+
emptyRunFilters,
|
|
87
|
+
NEEDS_ATTENTION_STATUSES,
|
|
88
|
+
isNeedsAttention,
|
|
89
|
+
activeFilterCount,
|
|
90
|
+
activeDimensions,
|
|
91
|
+
dimensionActive,
|
|
92
|
+
clearDimension,
|
|
93
|
+
clearPopoverFilters,
|
|
94
|
+
humanizeStatus,
|
|
95
|
+
statusChipLabel,
|
|
96
|
+
exitCodeRange,
|
|
97
|
+
type ExitCodeRange,
|
|
98
|
+
type RunsListFilters,
|
|
99
|
+
type RunsListSortDirection,
|
|
100
|
+
type FilterDimension,
|
|
101
|
+
} from "./components/dashboard/run-filters.js";
|
|
87
102
|
export {
|
|
88
103
|
getRunStatusConfig,
|
|
89
104
|
runDisplayStatus,
|
|
@@ -96,6 +111,7 @@ export {
|
|
|
96
111
|
runPhaseOrder,
|
|
97
112
|
formatTriggeredByLabel,
|
|
98
113
|
runRetryLabel,
|
|
114
|
+
instanceSuffix,
|
|
99
115
|
} from "./components/dashboard/run-helpers.js";
|
|
100
116
|
export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
|
|
101
117
|
export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
|
|
@@ -111,3 +127,4 @@ export {
|
|
|
111
127
|
formatFullDateTime,
|
|
112
128
|
} from "./utils/format.js";
|
|
113
129
|
export { formatShortId } from "./utils/id.js";
|
|
130
|
+
export { TickingNow } from "./utils/ticking-now.svelte.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import type { LogSlice, LogEvent } from "./types.js";
|
|
4
|
+
import type { LogSlice, LogEvent, RegionUpdate } from "./types.js";
|
|
5
5
|
import { SvelteMap } from "svelte/reactivity";
|
|
6
6
|
import { visibleColumns } from "./ansi.js";
|
|
7
7
|
|
|
@@ -12,8 +12,21 @@ import { visibleColumns } from "./ansi.js";
|
|
|
12
12
|
* the cache must aggressively prune data that is far from the current viewport.
|
|
13
13
|
* It uses a SvelteMap for reactive, efficient line lookups and updates.
|
|
14
14
|
*/
|
|
15
|
+
// Streams pinned to the front of the overlay, in render order. The companion
|
|
16
|
+
// Set backs the membership test in `overlayRows`. Both are module-level,
|
|
17
|
+
// immutable, and non-reactive — a fixed lookup table, not component state.
|
|
18
|
+
const PREFERRED_STREAM_ORDER = ["stdout", "stderr"] as const;
|
|
19
|
+
const PREFERRED_STREAMS: ReadonlySet<string> = new Set(PREFERRED_STREAM_ORDER);
|
|
20
|
+
|
|
15
21
|
export class LogCache {
|
|
16
22
|
lines = $state(new SvelteMap<number, string>());
|
|
23
|
+
// Live-region overlay, keyed by stream (stdout/stderr). Each stream has at
|
|
24
|
+
// most one active region; the frame is replaced wholesale on each update and
|
|
25
|
+
// never persisted. Rendered below the committed lines as an in-place tail.
|
|
26
|
+
regions = $state(new SvelteMap<string, { epoch: number; rows: string[] }>());
|
|
27
|
+
// Per-line frame-history counts. A non-zero entry marks a settled progress
|
|
28
|
+
// bar / multi-line redraw whose prior frames can be fetched on click.
|
|
29
|
+
frameCounts = $state(new SvelteMap<number, number>());
|
|
17
30
|
totalLines = $state(0);
|
|
18
31
|
totalBytes = $state(0);
|
|
19
32
|
finished = $state(false);
|
|
@@ -50,13 +63,52 @@ export class LogCache {
|
|
|
50
63
|
return { touched, min, max };
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
// Flattened overlay rows for rendering, stdout before stderr (then any
|
|
67
|
+
// other streams, sorted) for stable ordering. Reactive: reads the regions
|
|
68
|
+
// map so it recomputes on update.
|
|
69
|
+
get overlayRows(): string[] {
|
|
70
|
+
const rest = [...this.regions.keys()]
|
|
71
|
+
.filter((s) => !PREFERRED_STREAMS.has(s))
|
|
72
|
+
.sort((a, b) => a.localeCompare(b));
|
|
73
|
+
const out: string[] = [];
|
|
74
|
+
for (const stream of [...PREFERRED_STREAM_ORDER, ...rest]) {
|
|
75
|
+
const region = this.regions.get(stream);
|
|
76
|
+
if (region) out.push(...region.rows);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
applyRegion(update: RegionUpdate) {
|
|
82
|
+
if (update.rows.length === 0) {
|
|
83
|
+
this.regions.delete(update.stream);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.regions.set(update.stream, { epoch: update.epoch, rows: update.rows });
|
|
87
|
+
for (const row of update.rows) {
|
|
88
|
+
this.maxLineColumns = Math.max(this.maxLineColumns, visibleColumns(row));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
applyEvent(event: LogEvent) {
|
|
93
|
+
if (event.region) {
|
|
94
|
+
this.applyRegion(event.region);
|
|
95
|
+
return { touched: false, min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY };
|
|
96
|
+
}
|
|
54
97
|
this.totalLines = Math.max(this.totalLines, Math.floor(event.sizeLines));
|
|
55
98
|
this.totalBytes = Math.max(this.totalBytes, Math.floor(event.sizeBytes ?? 0));
|
|
56
99
|
this.finished = event.finished;
|
|
100
|
+
// A finished run has no live region; drop any lingering overlay so a
|
|
101
|
+
// dropped clear-frame can't leave a stale tail painted forever.
|
|
102
|
+
if (event.finished && this.regions.size > 0) this.regions.clear();
|
|
57
103
|
if (event.firstAvailableLine !== undefined && event.firstAvailableLine > 0) {
|
|
58
104
|
this.firstAvailableLine = Math.max(this.firstAvailableLine, event.firstAvailableLine);
|
|
59
105
|
}
|
|
106
|
+
if (event.frameCounts) {
|
|
107
|
+
for (const [k, v] of Object.entries(event.frameCounts)) {
|
|
108
|
+
const line = Number(k);
|
|
109
|
+
if (Number.isFinite(line) && line >= 0 && v > 0) this.frameCounts.set(line, v);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
60
112
|
|
|
61
113
|
return this.mergeSlice(event.lines);
|
|
62
114
|
}
|
|
@@ -78,6 +130,8 @@ export class LogCache {
|
|
|
78
130
|
|
|
79
131
|
reset() {
|
|
80
132
|
this.lines.clear();
|
|
133
|
+
this.regions.clear();
|
|
134
|
+
this.frameCounts.clear();
|
|
81
135
|
this.totalLines = 0;
|
|
82
136
|
this.totalBytes = 0;
|
|
83
137
|
this.finished = false;
|
|
@@ -3,12 +3,29 @@
|
|
|
3
3
|
|
|
4
4
|
export type LogSlice = Record<number, string>;
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* RegionUpdate is a live snapshot of a still-animating output region (a `\r`
|
|
8
|
+
* progress bar or a multi-line ANSI redraw). It is never persisted: the cache
|
|
9
|
+
* holds it in a transient overlay keyed by stream, replacing the whole frame on
|
|
10
|
+
* each update. Empty `rows` clears the overlay for that stream.
|
|
11
|
+
*/
|
|
12
|
+
export type RegionUpdate = {
|
|
13
|
+
stream: string;
|
|
14
|
+
epoch: number;
|
|
15
|
+
rows: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
6
18
|
export type LogEvent = {
|
|
7
19
|
lines: LogSlice;
|
|
8
20
|
sizeLines: number;
|
|
9
21
|
sizeBytes?: number;
|
|
10
22
|
finished: boolean;
|
|
11
23
|
firstAvailableLine?: number;
|
|
24
|
+
region?: RegionUpdate;
|
|
25
|
+
// Per-line count of recorded prior frames, keyed by line number. Present only
|
|
26
|
+
// for settled progress-bar / multi-line-redraw anchor lines, marking them as
|
|
27
|
+
// clickable to rewind through earlier states. Plain lines are absent.
|
|
28
|
+
frameCounts?: Record<number, number>;
|
|
12
29
|
};
|
|
13
30
|
|
|
14
31
|
export function isLogEvent(value: unknown): value is LogEvent {
|