@runwisp/ui 0.13.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 +39 -47
- package/src/astro/CTABand.astro +19 -12
- package/src/astro/Hero.astro +3 -3
- package/src/astro/SiteFooter.astro +4 -4
- package/src/astro/SiteHeader.astro +4 -4
- package/src/lib/actions/portal.ts +0 -3
- package/src/lib/assets/runwisp-logo.svg +1 -8
- package/src/lib/components/AccordionItem.svelte +4 -10
- package/src/lib/components/Alert.svelte +11 -11
- package/src/lib/components/Avatar.svelte +1 -1
- package/src/lib/components/Badge.svelte +13 -13
- package/src/lib/components/Breadcrumb.svelte +3 -3
- package/src/lib/components/Button.svelte +2 -2
- package/src/lib/components/Card.svelte +4 -4
- package/src/lib/components/Checkbox.svelte +12 -10
- package/src/lib/components/CodeBlock.svelte +7 -13
- package/src/lib/components/CronInput.svelte +12 -15
- package/src/lib/components/DataGrid.svelte +14 -13
- package/src/lib/components/Drawer.svelte +9 -9
- package/src/lib/components/Dropdown.svelte +12 -20
- package/src/lib/components/DurationInput.svelte +10 -14
- package/src/lib/components/EmptyState.svelte +1 -1
- package/src/lib/components/ErrorState.svelte +4 -4
- package/src/lib/components/FeatureCard.svelte +4 -4
- package/src/lib/components/FormField.svelte +4 -6
- package/src/lib/components/Heading.svelte +4 -1
- package/src/lib/components/Input.svelte +10 -12
- package/src/lib/components/Kbd.svelte +1 -1
- package/src/lib/components/Link.svelte +2 -3
- package/src/lib/components/LinkButton.svelte +2 -2
- package/src/lib/components/LogConsole.svelte +22 -21
- package/src/lib/components/Logo.svelte +3 -1
- package/src/lib/components/Modal.svelte +9 -11
- package/src/lib/components/PageContainer.svelte +1 -1
- package/src/lib/components/PageHeader.svelte +1 -1
- package/src/lib/components/Pagination.svelte +4 -4
- package/src/lib/components/Popover.svelte +5 -7
- package/src/lib/components/Progress.svelte +6 -4
- package/src/lib/components/Prose.svelte +4 -4
- package/src/lib/components/Radio.svelte +11 -10
- package/src/lib/components/RadioGroup.svelte +3 -5
- package/src/lib/components/Select.svelte +23 -36
- package/src/lib/components/Separator.svelte +4 -1
- package/src/lib/components/Skeleton.svelte +6 -6
- package/src/lib/components/StatusIndicator.svelte +7 -18
- package/src/lib/components/Table.svelte +1 -1
- package/src/lib/components/TableCell.svelte +1 -1
- package/src/lib/components/TableRow.svelte +1 -5
- package/src/lib/components/Tabs.svelte +10 -13
- package/src/lib/components/TaskCard.svelte +7 -4
- package/src/lib/components/Textarea.svelte +10 -11
- package/src/lib/components/ThemeToggle.svelte +1 -1
- package/src/lib/components/ToastContainer.svelte +3 -8
- package/src/lib/components/Toggle.svelte +2 -3
- package/src/lib/components/Tooltip.svelte +12 -10
- package/src/lib/components/button-styles.ts +17 -18
- package/src/lib/components/dashboard/RunDetailPanel.svelte +160 -249
- package/src/lib/components/dashboard/RunFilterPopover.svelte +31 -25
- package/src/lib/components/dashboard/RunsList.svelte +55 -50
- 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/theme-tokens.css +179 -114
- package/src/lib/theme.css +49 -35
- 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
|
@@ -47,10 +47,13 @@
|
|
|
47
47
|
filters = $bindable(),
|
|
48
48
|
showTask = false,
|
|
49
49
|
tasks = [],
|
|
50
|
+
compact = false,
|
|
50
51
|
}: {
|
|
51
52
|
filters: RunsListFilters;
|
|
52
53
|
showTask?: boolean;
|
|
53
54
|
tasks?: { name: string }[];
|
|
55
|
+
// Icon-only trigger for narrow rails where a "Filter" label wouldn't fit.
|
|
56
|
+
compact?: boolean;
|
|
54
57
|
} = $props();
|
|
55
58
|
|
|
56
59
|
// The individual statuses behind the "Advanced" expander, grouped the way
|
|
@@ -62,12 +65,13 @@
|
|
|
62
65
|
|
|
63
66
|
let advancedOpen = $state(false);
|
|
64
67
|
|
|
65
|
-
const count = $derived(activeFilterCount(filters) + (showTask && filters.
|
|
68
|
+
const count = $derived(activeFilterCount(filters) + (showTask && filters.taskName ? 1 : 0));
|
|
66
69
|
|
|
67
|
-
const headerClass =
|
|
68
|
-
|
|
70
|
+
const headerClass =
|
|
71
|
+
"font-mono text-xs font-semibold tracking-[0.14em] text-on-surface-muted uppercase";
|
|
72
|
+
const labelClass = "font-mono text-2xs tracking-[0.12em] text-on-surface-faint uppercase";
|
|
69
73
|
const selectClass =
|
|
70
|
-
"h-9 w-full appearance-none rounded-
|
|
74
|
+
"h-9 w-full appearance-none rounded-[3px] border border-outline bg-surface-raised px-2.5 font-mono text-sm text-on-surface focus:border-ring focus:outline-none";
|
|
71
75
|
|
|
72
76
|
function statusDot(status: RunStatus): string {
|
|
73
77
|
return RUN_STATUS_CONFIG[status].dot.replace(" animate-pulse", "");
|
|
@@ -99,51 +103,53 @@
|
|
|
99
103
|
// both captures the whole day.
|
|
100
104
|
|
|
101
105
|
function setFrom(value: string) {
|
|
102
|
-
filters = { ...filters,
|
|
106
|
+
filters = { ...filters, createdAfter: value ? dayStartIso(value) : undefined };
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
function setTo(value: string) {
|
|
106
|
-
filters = { ...filters,
|
|
110
|
+
filters = { ...filters, createdBefore: value ? dayEndIso(value) : undefined };
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
// --- Other dimensions -------------------------------------------------
|
|
110
114
|
|
|
111
115
|
function setTask(value: string) {
|
|
112
|
-
filters = { ...filters,
|
|
116
|
+
filters = { ...filters, taskName: value || undefined };
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
function setTrigger(value: string) {
|
|
116
|
-
filters = { ...filters,
|
|
120
|
+
filters = { ...filters, triggeredBy: value || undefined };
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
// Exit code: a free-form expression (`137`, `>100`, `>100 <150`) normalized
|
|
120
124
|
// to an inclusive range at the wire. Edited in a local buffer for smooth
|
|
121
125
|
// typing and committed on change; the popover content remounts on each open,
|
|
122
126
|
// so the buffer re-seeds from `filters` every time it opens.
|
|
123
|
-
let exitCodeInput = $state(filters.
|
|
127
|
+
let exitCodeInput = $state(filters.exitCode ?? "");
|
|
124
128
|
const exitCodeValid = $derived(isExitCodeExprValid(exitCodeInput));
|
|
125
129
|
|
|
126
130
|
function commitExitCode() {
|
|
127
131
|
const trimmed = exitCodeInput.trim();
|
|
128
|
-
filters = { ...filters,
|
|
132
|
+
filters = { ...filters, exitCode: trimmed || undefined };
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
function setRetriesOnly(checked: boolean) {
|
|
132
|
-
filters = { ...filters,
|
|
136
|
+
filters = { ...filters, retriesOnly: checked ? true : undefined };
|
|
133
137
|
}
|
|
134
138
|
</script>
|
|
135
139
|
|
|
136
140
|
<Popover placement="right-start" mobileSheet>
|
|
137
141
|
{#snippet trigger()}
|
|
138
142
|
<span
|
|
139
|
-
class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-
|
|
140
|
-
0
|
|
141
|
-
? '
|
|
142
|
-
: '
|
|
143
|
+
class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[3px] border font-mono text-xs font-medium {compact &&
|
|
144
|
+
count === 0
|
|
145
|
+
? 'w-7 justify-center px-0'
|
|
146
|
+
: 'px-2'} {count > 0
|
|
147
|
+
? 'border-primary-soft-border bg-primary-soft text-primary-soft-text'
|
|
148
|
+
: 'border-transparent text-on-surface-muted hover:border-outline-hover hover:bg-surface-sunken hover:text-primary'}"
|
|
143
149
|
title="Filter runs"
|
|
144
150
|
>
|
|
145
151
|
<Funnel size={13} />
|
|
146
|
-
Filter
|
|
152
|
+
{#if !compact}Filter{/if}
|
|
147
153
|
{#if count > 0}
|
|
148
154
|
<Badge variant="primary" size="sm" class="px-1.5 py-0">{count}</Badge>
|
|
149
155
|
{/if}
|
|
@@ -167,14 +173,14 @@
|
|
|
167
173
|
class="size-4 shrink-0 cursor-pointer rounded border-outline accent-primary"
|
|
168
174
|
/>
|
|
169
175
|
<span class="size-2.5 shrink-0 rounded-full {bucket.dot}"></span>
|
|
170
|
-
<span class="text-sm text-on-surface">{bucket.label}</span>
|
|
176
|
+
<span class="font-mono text-sm text-on-surface">{bucket.label}</span>
|
|
171
177
|
</label>
|
|
172
178
|
{/each}
|
|
173
179
|
</div>
|
|
174
180
|
|
|
175
181
|
<details class="mt-0.5" bind:open={advancedOpen}>
|
|
176
182
|
<summary
|
|
177
|
-
class="flex cursor-pointer items-center gap-1 text-2xs font-medium text-on-surface-muted
|
|
183
|
+
class="flex cursor-pointer items-center gap-1 font-mono text-2xs font-medium text-on-surface-muted select-none marker:content-none hover:text-on-surface [&::-webkit-details-marker]:hidden"
|
|
178
184
|
>
|
|
179
185
|
<ChevronRight size={12} class={advancedOpen ? "rotate-90" : ""} />
|
|
180
186
|
Advanced — pick exact statuses
|
|
@@ -185,7 +191,7 @@
|
|
|
185
191
|
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
|
|
186
192
|
{#each group.values as status (status)}
|
|
187
193
|
<label
|
|
188
|
-
class="flex cursor-pointer items-center gap-1.5 truncate text-xs"
|
|
194
|
+
class="flex cursor-pointer items-center gap-1.5 truncate font-mono text-xs"
|
|
189
195
|
>
|
|
190
196
|
<input
|
|
191
197
|
type="checkbox"
|
|
@@ -206,7 +212,7 @@
|
|
|
206
212
|
</details>
|
|
207
213
|
</section>
|
|
208
214
|
|
|
209
|
-
<!-- Date range (filters on
|
|
215
|
+
<!-- Date range (filters on createdAt). Independent From/To bounds: a
|
|
210
216
|
bare date is that day's 00:00 (From) or its end (To), so either edge
|
|
211
217
|
applies on its own and the same date in both captures the day. -->
|
|
212
218
|
<section class="flex flex-col gap-2">
|
|
@@ -216,7 +222,7 @@
|
|
|
216
222
|
<span class={labelClass}>From</span>
|
|
217
223
|
<input
|
|
218
224
|
type="date"
|
|
219
|
-
value={filters.
|
|
225
|
+
value={filters.createdAfter ? isoToDayInput(filters.createdAfter) : ""}
|
|
220
226
|
onchange={(e) => setFrom(e.currentTarget.value)}
|
|
221
227
|
class={selectClass}
|
|
222
228
|
/>
|
|
@@ -225,7 +231,7 @@
|
|
|
225
231
|
<span class={labelClass}>To</span>
|
|
226
232
|
<input
|
|
227
233
|
type="date"
|
|
228
|
-
value={filters.
|
|
234
|
+
value={filters.createdBefore ? isoToDayInput(filters.createdBefore) : ""}
|
|
229
235
|
onchange={(e) => setTo(e.currentTarget.value)}
|
|
230
236
|
class={selectClass}
|
|
231
237
|
/>
|
|
@@ -239,7 +245,7 @@
|
|
|
239
245
|
<span class={headerClass}>Task</span>
|
|
240
246
|
<select
|
|
241
247
|
class={selectClass}
|
|
242
|
-
value={filters.
|
|
248
|
+
value={filters.taskName ?? ""}
|
|
243
249
|
onchange={(e) => setTask(e.currentTarget.value)}
|
|
244
250
|
>
|
|
245
251
|
<option value="">Any task</option>
|
|
@@ -255,7 +261,7 @@
|
|
|
255
261
|
<span class={headerClass}>Triggered by</span>
|
|
256
262
|
<select
|
|
257
263
|
class={selectClass}
|
|
258
|
-
value={filters.
|
|
264
|
+
value={filters.triggeredBy ?? ""}
|
|
259
265
|
onchange={(e) => setTrigger(e.currentTarget.value)}
|
|
260
266
|
>
|
|
261
267
|
<option value="">Any trigger</option>
|
|
@@ -290,7 +296,7 @@
|
|
|
290
296
|
<span class={headerClass}>Retries</span>
|
|
291
297
|
<Toggle
|
|
292
298
|
size="sm"
|
|
293
|
-
checked={Boolean(filters.
|
|
299
|
+
checked={Boolean(filters.retriesOnly)}
|
|
294
300
|
label="Only retried runs"
|
|
295
301
|
onchange={setRetriesOnly}
|
|
296
302
|
/>
|
|
@@ -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
|
|
|
@@ -382,7 +382,7 @@
|
|
|
382
382
|
<div
|
|
383
383
|
class={flush
|
|
384
384
|
? "flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
|
|
385
|
-
: "flex flex-col overflow-hidden rounded-
|
|
385
|
+
: "flex flex-col overflow-hidden rounded-[4px] border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
|
|
386
386
|
>
|
|
387
387
|
<div
|
|
388
388
|
class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
|
|
@@ -408,7 +408,7 @@
|
|
|
408
408
|
{/if}
|
|
409
409
|
|
|
410
410
|
{#if hasSelection}
|
|
411
|
-
<span class="text-xs font-medium text-on-surface">
|
|
411
|
+
<span class="font-mono text-xs font-medium text-on-surface tabular-nums">
|
|
412
412
|
{selectionCount} selected
|
|
413
413
|
</span>
|
|
414
414
|
<div class="ml-auto flex items-center gap-1">
|
|
@@ -451,10 +451,14 @@
|
|
|
451
451
|
{/if}
|
|
452
452
|
</div>
|
|
453
453
|
{:else}
|
|
454
|
-
<span
|
|
454
|
+
<span
|
|
455
|
+
class="shrink-0 font-mono text-xs font-semibold tracking-[0.14em] whitespace-nowrap text-on-surface-muted uppercase"
|
|
456
|
+
>
|
|
455
457
|
{headerLabel}
|
|
456
458
|
</span>
|
|
457
|
-
<span
|
|
459
|
+
<span
|
|
460
|
+
class="shrink-0 font-mono text-xs whitespace-nowrap text-on-surface-faint tabular-nums"
|
|
461
|
+
>
|
|
458
462
|
{#if outputSearchActive}
|
|
459
463
|
{matchedRuns.length} of {items.length}
|
|
460
464
|
{:else}
|
|
@@ -464,7 +468,7 @@
|
|
|
464
468
|
</span>
|
|
465
469
|
<div class="ml-auto flex items-center gap-1">
|
|
466
470
|
{#if showFilters}
|
|
467
|
-
<RunFilterPopover bind:filters {showTask} {tasks} />
|
|
471
|
+
<RunFilterPopover bind:filters {showTask} {tasks} compact={flush} />
|
|
468
472
|
{/if}
|
|
469
473
|
<Button
|
|
470
474
|
variant="ghost"
|
|
@@ -476,7 +480,7 @@
|
|
|
476
480
|
{#snippet icon()}
|
|
477
481
|
<ArrowUpDown
|
|
478
482
|
size={14}
|
|
479
|
-
class="text-on-surface-muted {filters.
|
|
483
|
+
class="text-on-surface-muted {filters.sortDirection === 'asc'
|
|
480
484
|
? 'rotate-180'
|
|
481
485
|
: ''}"
|
|
482
486
|
/>
|
|
@@ -495,13 +499,13 @@
|
|
|
495
499
|
>
|
|
496
500
|
{#each filterChips as chip (chip.dimension)}
|
|
497
501
|
<span
|
|
498
|
-
class="inline-flex items-center gap-1 rounded-
|
|
502
|
+
class="inline-flex items-center gap-1 rounded-[3px] border border-primary-soft-border bg-primary-soft py-0.5 pr-1 pl-2 font-mono text-2xs font-medium text-primary-soft-text"
|
|
499
503
|
>
|
|
500
504
|
{chip.label}
|
|
501
505
|
<button
|
|
502
506
|
type="button"
|
|
503
507
|
onclick={() => removeChip(chip.dimension)}
|
|
504
|
-
class="flex size-3.5 items-center justify-center rounded-
|
|
508
|
+
class="flex size-3.5 items-center justify-center rounded-[3px] text-primary-soft-text/70 hover:bg-primary/15 hover:text-primary-soft-text"
|
|
505
509
|
aria-label="Remove {chip.label} filter"
|
|
506
510
|
>
|
|
507
511
|
<X size={11} />
|
|
@@ -519,7 +523,7 @@
|
|
|
519
523
|
<!-- Searching: shimmer placeholders shaped like result rows. -->
|
|
520
524
|
<div class="flex flex-col gap-0.5" aria-busy="true" aria-label="Searching output">
|
|
521
525
|
{#each [0, 1, 2, 3, 4] as i (i)}
|
|
522
|
-
<div class="flex items-center gap-2.5 rounded-[
|
|
526
|
+
<div class="flex items-center gap-2.5 rounded-[3px] px-3 py-2.5">
|
|
523
527
|
<span
|
|
524
528
|
class="size-[9px] shrink-0 animate-pulse rounded-full bg-surface-sunken"
|
|
525
529
|
></span>
|
|
@@ -535,7 +539,7 @@
|
|
|
535
539
|
{:else if matchedRuns.length === 0}
|
|
536
540
|
<div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
|
|
537
541
|
No output matches
|
|
538
|
-
<b class="text-on-surface">“{outputQuery.trim()}”</b>.<br />
|
|
542
|
+
<b class="font-mono text-on-surface">“{outputQuery.trim()}”</b>.<br />
|
|
539
543
|
Try a different term.
|
|
540
544
|
</div>
|
|
541
545
|
{:else}
|
|
@@ -601,7 +605,7 @@
|
|
|
601
605
|
: ''} {bulkActions
|
|
602
606
|
? selectionActive
|
|
603
607
|
? 'opacity-0'
|
|
604
|
-
: '
|
|
608
|
+
: 'group-hover/row:opacity-0'
|
|
605
609
|
: ''}"
|
|
606
610
|
aria-hidden="true"
|
|
607
611
|
></span>
|
|
@@ -622,8 +626,8 @@
|
|
|
622
626
|
checked={isRowSelected(run.id)}
|
|
623
627
|
onchange={() => toggleRow(run.id)}
|
|
624
628
|
onclick={(e) => e.stopPropagation()}
|
|
625
|
-
aria-label={`Select run from ${formatDateTime(run.
|
|
626
|
-
class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0
|
|
629
|
+
aria-label={`Select run from ${formatDateTime(run.startAt ?? run.createdAt)}`}
|
|
630
|
+
class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 {selectionActive
|
|
627
631
|
? 'opacity-100'
|
|
628
632
|
: 'group-hover/row:opacity-100'}"
|
|
629
633
|
/>
|
|
@@ -635,15 +639,15 @@
|
|
|
635
639
|
{@const config = getRunStatusConfig(dstatus)}
|
|
636
640
|
{@const running = run.status === "running"}
|
|
637
641
|
{@const spine = config.dot.replace(" animate-pulse", "")}
|
|
638
|
-
{@const startedAt = run.
|
|
642
|
+
{@const startedAt = run.startAt ?? run.createdAt}
|
|
639
643
|
{@const retry = runRetryLabel(run)}
|
|
640
|
-
{@const suffix = instanceSuffix(run.
|
|
644
|
+
{@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
|
|
641
645
|
<button
|
|
642
|
-
class="btn-scale group relative w-full rounded-[
|
|
646
|
+
class="btn-scale group relative w-full rounded-[3px] border text-left select-none {showTaskName
|
|
643
647
|
? 'p-3'
|
|
644
648
|
: 'px-3 py-[11px]'} {isActive
|
|
645
649
|
? 'border-outline bg-surface-raised shadow-sm'
|
|
646
|
-
: 'border-transparent hover:bg-surface-sunken'}"
|
|
650
|
+
: 'border-transparent hover:border-outline-hover hover:bg-surface-sunken'}"
|
|
647
651
|
onclick={() => selectRun(run.id)}
|
|
648
652
|
onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
|
|
649
653
|
>
|
|
@@ -655,22 +659,23 @@
|
|
|
655
659
|
{@render statusDot(config.color, running)}
|
|
656
660
|
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
657
661
|
<span class="flex items-center gap-1.5">
|
|
658
|
-
<span class="truncate text-[13px] font-semibold text-on-surface">
|
|
659
|
-
{run.
|
|
662
|
+
<span class="truncate font-mono text-[13px] font-semibold text-on-surface">
|
|
663
|
+
{run.taskName}{#if suffix}<span class="text-on-surface-muted"
|
|
660
664
|
>{suffix}</span
|
|
661
665
|
>{/if}
|
|
662
666
|
</span>
|
|
663
667
|
<span class="shrink-0 text-on-surface-faint">·</span>
|
|
664
|
-
<span
|
|
668
|
+
<span
|
|
669
|
+
class="shrink-0 font-mono text-[12px] font-semibold capitalize {config.color}"
|
|
665
670
|
>{dstatus}</span
|
|
666
671
|
>
|
|
667
672
|
</span>
|
|
668
673
|
<span
|
|
669
|
-
class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-faint"
|
|
674
|
+
class="flex min-w-0 items-center gap-1.5 font-mono text-2xs text-on-surface-faint"
|
|
670
675
|
title={formatFullDateTime(startedAt)}
|
|
671
676
|
>
|
|
672
677
|
<span class="truncate">{formatDateTime(startedAt)}</span>
|
|
673
|
-
<span class="shrink-0">· {formatTriggeredByLabel(run.
|
|
678
|
+
<span class="shrink-0">· {formatTriggeredByLabel(run.triggeredBy)}</span>
|
|
674
679
|
{#if retry}
|
|
675
680
|
<span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
|
|
676
681
|
>{retry}</span
|
|
@@ -695,13 +700,13 @@
|
|
|
695
700
|
{@render statusDot(config.color, running)}
|
|
696
701
|
<span class="flex min-w-0 flex-1 items-center gap-1.5 truncate">
|
|
697
702
|
<span
|
|
698
|
-
class="text-[12.5px] font-semibold tracking-tight text-on-surface"
|
|
703
|
+
class="font-mono text-[12.5px] font-semibold tracking-tight text-on-surface tabular-nums"
|
|
699
704
|
title={formatFullDateTime(startedAt)}
|
|
700
705
|
>
|
|
701
706
|
{formatTimeHM(startedAt)} · {formatDayMonth(startedAt)}
|
|
702
707
|
</span>
|
|
703
708
|
<span class="text-on-surface-faint">·</span>
|
|
704
|
-
<span class="text-[12.5px] font-semibold capitalize {config.color}"
|
|
709
|
+
<span class="font-mono text-[12.5px] font-semibold capitalize {config.color}"
|
|
705
710
|
>{dstatus}</span
|
|
706
711
|
>
|
|
707
712
|
{#if suffix}
|
|
@@ -718,10 +723,10 @@
|
|
|
718
723
|
{#if match}
|
|
719
724
|
{@const hl = highlightParts(match.text, outputQuery)}
|
|
720
725
|
<div
|
|
721
|
-
class="mt-1.5 truncate rounded-
|
|
726
|
+
class="mt-1.5 truncate rounded-[3px] border border-outline-faint bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
|
|
722
727
|
>
|
|
723
728
|
{hl.before}<mark
|
|
724
|
-
class="rounded-
|
|
729
|
+
class="rounded-[3px] bg-primary-soft px-0.5 text-primary-soft-text"
|
|
725
730
|
>{hl.match}</mark
|
|
726
731
|
>{hl.after}
|
|
727
732
|
</div>
|
|
@@ -729,7 +734,7 @@
|
|
|
729
734
|
{/if}
|
|
730
735
|
|
|
731
736
|
<div
|
|
732
|
-
class="
|
|
737
|
+
class="absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] {spine} {isActive
|
|
733
738
|
? 'opacity-100'
|
|
734
739
|
: 'opacity-0'}"
|
|
735
740
|
aria-hidden="true"
|
|
@@ -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
|
|