@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
|
@@ -2,37 +2,48 @@
|
|
|
2
2
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
3
|
|
|
4
4
|
<script lang="ts" module>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
status: string;
|
|
10
|
-
sort_direction: RunsListSortDirection;
|
|
5
|
+
/** A single output-search hit surfaced under its run in the history rail. */
|
|
6
|
+
export interface RunOutputMatch {
|
|
7
|
+
line: number;
|
|
8
|
+
text: string;
|
|
11
9
|
}
|
|
12
10
|
</script>
|
|
13
11
|
|
|
14
12
|
<script lang="ts">
|
|
15
|
-
import {
|
|
16
|
-
Clock,
|
|
17
|
-
Timer,
|
|
18
|
-
ArrowUpDown,
|
|
19
|
-
Search,
|
|
20
|
-
Funnel,
|
|
21
|
-
Square,
|
|
22
|
-
Trash2,
|
|
23
|
-
RotateCw,
|
|
24
|
-
} from "@lucide/svelte";
|
|
13
|
+
import { Clock, ArrowUpDown, X, Square, Trash2, RotateCw } from "@lucide/svelte";
|
|
25
14
|
import { untrack } from "svelte";
|
|
26
15
|
import { SvelteSet } from "svelte/reactivity";
|
|
27
16
|
import { createVirtualizer } from "@tanstack/svelte-virtual";
|
|
28
17
|
import Button from "../Button.svelte";
|
|
29
|
-
import Badge from "../Badge.svelte";
|
|
30
18
|
import EmptyState from "../EmptyState.svelte";
|
|
19
|
+
import RunFilterPopover from "./RunFilterPopover.svelte";
|
|
31
20
|
import type { Run } from "./types.js";
|
|
32
|
-
import type { RunSelector } from "@runwisp/common";
|
|
21
|
+
import type { RunSelector, RunStatus } from "@runwisp/common";
|
|
33
22
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
34
|
-
import {
|
|
35
|
-
|
|
23
|
+
import {
|
|
24
|
+
activeDimensions,
|
|
25
|
+
clearDimension,
|
|
26
|
+
statusChipLabel,
|
|
27
|
+
triggerDescription,
|
|
28
|
+
exitCodeRange,
|
|
29
|
+
exitCodeChipLabel,
|
|
30
|
+
isWholeDay,
|
|
31
|
+
type RunsListFilters,
|
|
32
|
+
type FilterDimension,
|
|
33
|
+
} from "./run-filters.js";
|
|
34
|
+
import {
|
|
35
|
+
runDuration,
|
|
36
|
+
formatTriggeredByLabel,
|
|
37
|
+
runRetryLabel,
|
|
38
|
+
instanceSuffix,
|
|
39
|
+
} from "./run-helpers.js";
|
|
40
|
+
import {
|
|
41
|
+
formatDateTime,
|
|
42
|
+
formatFullDateTime,
|
|
43
|
+
formatTimeHM,
|
|
44
|
+
formatDayMonth,
|
|
45
|
+
formatCalendarDate,
|
|
46
|
+
} from "../../utils/format.js";
|
|
36
47
|
|
|
37
48
|
type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
|
|
38
49
|
|
|
@@ -45,6 +56,8 @@
|
|
|
45
56
|
selectedRunId = $bindable(null),
|
|
46
57
|
onselect,
|
|
47
58
|
showFilters = false,
|
|
59
|
+
showTask = false,
|
|
60
|
+
tasks = [],
|
|
48
61
|
showTaskName = false,
|
|
49
62
|
taskNameFilter,
|
|
50
63
|
headerLabel = "Run History",
|
|
@@ -54,6 +67,12 @@
|
|
|
54
67
|
onBulkCancel,
|
|
55
68
|
onBulkDelete,
|
|
56
69
|
onBulkRerun,
|
|
70
|
+
getInstanceCount = () => 1,
|
|
71
|
+
flush = false,
|
|
72
|
+
outputSearch = false,
|
|
73
|
+
outputQuery = "",
|
|
74
|
+
outputMatches = null,
|
|
75
|
+
outputSearchPending = false,
|
|
57
76
|
}: {
|
|
58
77
|
items: Run[];
|
|
59
78
|
total: number;
|
|
@@ -63,6 +82,11 @@
|
|
|
63
82
|
selectedRunId?: string | null;
|
|
64
83
|
onselect?: (runId: string) => void;
|
|
65
84
|
showFilters?: boolean;
|
|
85
|
+
// Cross-task /runs view: lets the filter popover offer a Task select.
|
|
86
|
+
// On a single task's page this stays false (the task is the page scope).
|
|
87
|
+
showTask?: boolean;
|
|
88
|
+
// Task list backing the popover's Task select (cross-task view only).
|
|
89
|
+
tasks?: { name: string }[];
|
|
66
90
|
showTaskName?: boolean;
|
|
67
91
|
taskNameFilter?: string;
|
|
68
92
|
headerLabel?: string;
|
|
@@ -72,12 +96,34 @@
|
|
|
72
96
|
onBulkCancel?: BulkHandler;
|
|
73
97
|
onBulkDelete?: BulkHandler;
|
|
74
98
|
onBulkRerun?: BulkHandler;
|
|
99
|
+
// Resolves a task's currently configured instance count so multi-instance
|
|
100
|
+
// services render a 1-based #N suffix. Defaults to single-instance.
|
|
101
|
+
getInstanceCount?: (taskName: string) => number;
|
|
102
|
+
// Flush rail mode (task detail page): render as a borderless rail that
|
|
103
|
+
// fills its column and is divided from the detail panel by a single right
|
|
104
|
+
// border — no card chrome of its own. Default renders the standalone card
|
|
105
|
+
// the cross-task /runs grid expects.
|
|
106
|
+
flush?: boolean;
|
|
107
|
+
// Output search (history rail): filters runs by what they printed. The
|
|
108
|
+
// search box lives in the app header now; the parent owns the box, the
|
|
109
|
+
// query, and the async log search. This component just renders the
|
|
110
|
+
// matched rows. `outputSearch` enables the mode; `outputQuery` is the
|
|
111
|
+
// live query (drives the active state and snippet highlighting).
|
|
112
|
+
outputSearch?: boolean;
|
|
113
|
+
outputQuery?: string;
|
|
114
|
+
// run id → first matching line, supplied by the parent after a search.
|
|
115
|
+
// null = no active search; the full list shows.
|
|
116
|
+
outputMatches?: Map<string, RunOutputMatch> | null;
|
|
117
|
+
// True while a query is typed but its results aren't in yet (debounce
|
|
118
|
+
// window or request in flight) — the rail shows its searching shimmer.
|
|
119
|
+
outputSearchPending?: boolean;
|
|
75
120
|
} = $props();
|
|
76
121
|
|
|
77
|
-
|
|
122
|
+
// Task-rail rows are one dense line (44px); the cross-task /runs view adds
|
|
123
|
+
// the task name on a second line (64px).
|
|
124
|
+
const rowHeight = $derived(showTaskName ? 64 : 44);
|
|
78
125
|
const OVERSCAN = 8;
|
|
79
126
|
const LOAD_AHEAD = 10;
|
|
80
|
-
const SEARCH_DEBOUNCE_MS = 250;
|
|
81
127
|
|
|
82
128
|
// Selection model: two modes —
|
|
83
129
|
// 1. explicit: user picked specific rows. explicitIds holds them.
|
|
@@ -87,22 +133,57 @@
|
|
|
87
133
|
const exceptIds = new SvelteSet<string>();
|
|
88
134
|
|
|
89
135
|
let scrollElement: HTMLDivElement | undefined = $state();
|
|
90
|
-
let searchInput = $state(filters.search);
|
|
91
136
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
$
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
137
|
+
// Output search filters the rail by what each run printed. The query lives
|
|
138
|
+
// in the app header now; this component only renders against it.
|
|
139
|
+
const outputSearchActive = $derived(outputSearch && outputQuery.trim().length > 0);
|
|
140
|
+
|
|
141
|
+
// Loaded runs that matched the output search, in list order. A match in a
|
|
142
|
+
// not-yet-loaded run can't be shown until the rail scrolls far enough to
|
|
143
|
+
// load it — the count reflects what's loaded, not the whole history.
|
|
144
|
+
const matchedRuns = $derived(
|
|
145
|
+
outputMatches ? items.filter((r: Run) => outputMatches.has(r.id)) : [],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Split the matching line into [before, match, after] around the first
|
|
149
|
+
// occurrence of the query, windowed to keep the match in view — mirrors the
|
|
150
|
+
// artifact's snippet. Rendered as plain text spans (Svelte auto-escapes), so
|
|
151
|
+
// no untrusted HTML ever reaches the DOM.
|
|
152
|
+
interface HighlightParts {
|
|
153
|
+
before: string;
|
|
154
|
+
match: string;
|
|
155
|
+
after: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function highlightParts(text: string, query: string): HighlightParts {
|
|
159
|
+
const q = query.trim();
|
|
160
|
+
const idx = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
|
|
161
|
+
if (idx === -1) return { before: text, match: "", after: "" };
|
|
162
|
+
const start = Math.max(0, idx - 14);
|
|
163
|
+
const lead = start > 0 ? "…" : "";
|
|
164
|
+
const windowed = text.slice(start);
|
|
165
|
+
const fi = windowed.toLowerCase().indexOf(q.toLowerCase());
|
|
166
|
+
return {
|
|
167
|
+
before: lead + windowed.slice(0, fi),
|
|
168
|
+
match: windowed.slice(fi, fi + q.length),
|
|
169
|
+
after: windowed.slice(fi + q.length),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Right-hand mono readout on a task-rail row: live / queued / exit N / dur.
|
|
174
|
+
function rowRightLabel(run: Run, displayed: RunStatus): string {
|
|
175
|
+
if (run.status === "running") return "live";
|
|
176
|
+
if (run.status === "pending") return "queued";
|
|
177
|
+
if (displayed === "failed" || displayed === "crashed") {
|
|
178
|
+
return "exit " + String(run.exit_code);
|
|
179
|
+
}
|
|
180
|
+
return runDuration(run) ?? "—";
|
|
181
|
+
}
|
|
101
182
|
|
|
102
183
|
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
|
103
184
|
count: 0,
|
|
104
185
|
getScrollElement: () => scrollElement ?? null,
|
|
105
|
-
estimateSize: () =>
|
|
186
|
+
estimateSize: () => rowHeight,
|
|
106
187
|
overscan: OVERSCAN,
|
|
107
188
|
});
|
|
108
189
|
|
|
@@ -122,6 +203,29 @@
|
|
|
122
203
|
if (last.index >= items.length - LOAD_AHEAD) onLoadMore?.();
|
|
123
204
|
});
|
|
124
205
|
|
|
206
|
+
// Scroll a programmatically-selected run (deep link, detail-panel click) into
|
|
207
|
+
// view, since its row may be outside the virtual window. Tracked on
|
|
208
|
+
// selectedRunId/items only; the virtualizer access is untracked to avoid the
|
|
209
|
+
// setOptions-style notify loop (effect_update_depth_exceeded, see above).
|
|
210
|
+
let lastScrolled: string | null = null;
|
|
211
|
+
$effect(() => {
|
|
212
|
+
const id = selectedRunId;
|
|
213
|
+
const index = items.findIndex((r: Run) => r.id === id);
|
|
214
|
+
if (!id) {
|
|
215
|
+
lastScrolled = null;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// -1: not loaded yet. The effect re-runs as items grows, so a deep link
|
|
219
|
+
// resolves once its page arrives; we don't chase it with onLoadMore.
|
|
220
|
+
if (index === -1) return;
|
|
221
|
+
if (id === lastScrolled) return; // already handled; don't re-yank on re-render
|
|
222
|
+
if (!scrollElement) return;
|
|
223
|
+
lastScrolled = id;
|
|
224
|
+
// "auto" only scrolls when the row is off-screen, so visible selections
|
|
225
|
+
// (and manual clicks) don't jump.
|
|
226
|
+
untrack(() => $virtualizer.scrollToIndex(index, { align: "auto" }));
|
|
227
|
+
});
|
|
228
|
+
|
|
125
229
|
function isRowSelected(id: string): boolean {
|
|
126
230
|
return selectAllMode ? !exceptIds.has(id) : explicitIds.has(id);
|
|
127
231
|
}
|
|
@@ -156,6 +260,10 @@
|
|
|
156
260
|
let selectedRuns = $derived(items.filter((r: Run) => isRowSelected(r.id)));
|
|
157
261
|
let selectionCount = $derived(selectedRuns.length);
|
|
158
262
|
let hasSelection = $derived(selectionCount > 0);
|
|
263
|
+
// Task-rail rows overlay the checkbox on the status dot (it appears on hover
|
|
264
|
+
// or once a selection exists). selectionActive forces all checkboxes visible
|
|
265
|
+
// so the operator can extend the selection without hunting per-row hovers.
|
|
266
|
+
let selectionActive = $derived(bulkActions && hasSelection);
|
|
159
267
|
let allSelected = $derived(selectAllMode && exceptIds.size === 0);
|
|
160
268
|
|
|
161
269
|
let anyRunning = $derived(selectedRuns.some((r: Run) => r.status === "running"));
|
|
@@ -163,24 +271,78 @@
|
|
|
163
271
|
selectedRuns.some((r: Run) => r.status !== "running" && r.status !== "pending"),
|
|
164
272
|
);
|
|
165
273
|
|
|
274
|
+
// The selector's filter is the same RunFilter the list query uses, so a
|
|
275
|
+
// "select all matching" bulk op targets exactly the rows on screen. The
|
|
276
|
+
// popover dimensions only apply where the popover is shown; the task scope
|
|
277
|
+
// (page-injected or popover-set) always applies.
|
|
278
|
+
function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
|
|
279
|
+
const filter: NonNullable<RunSelector["filter"]> = {};
|
|
280
|
+
if (taskNameFilter) filter.task_name = taskNameFilter;
|
|
281
|
+
else if (filters.task_name) filter.task_name = filters.task_name;
|
|
282
|
+
if (!showFilters) return filter;
|
|
283
|
+
if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
|
|
284
|
+
const query = filters.search.trim();
|
|
285
|
+
if (query) filter.search = query;
|
|
286
|
+
if (filters.created_after) filter.created_after = filters.created_after;
|
|
287
|
+
if (filters.created_before) filter.created_before = filters.created_before;
|
|
288
|
+
if (filters.triggered_by) filter.triggered_by = filters.triggered_by;
|
|
289
|
+
const exit = exitCodeRange(filters.exit_code);
|
|
290
|
+
if (exit.min !== undefined) filter.exit_code_min = exit.min;
|
|
291
|
+
if (exit.max !== undefined) filter.exit_code_max = exit.max;
|
|
292
|
+
if (filters.retries_only) filter.retries_only = true;
|
|
293
|
+
return filter;
|
|
294
|
+
}
|
|
295
|
+
|
|
166
296
|
function buildSelector(): RunSelector {
|
|
167
297
|
if (!selectAllMode) {
|
|
168
298
|
return { match_all: false, ids: [...explicitIds] };
|
|
169
299
|
}
|
|
170
|
-
const filter: { status?: string; task_name?: string; search?: string } = {};
|
|
171
|
-
if (taskNameFilter) filter.task_name = taskNameFilter;
|
|
172
|
-
if (showFilters) {
|
|
173
|
-
if (filters.status && filters.status !== "all") filter.status = filters.status;
|
|
174
|
-
const query = filters.search.trim();
|
|
175
|
-
if (query) filter.search = query;
|
|
176
|
-
}
|
|
177
300
|
return {
|
|
178
301
|
match_all: true,
|
|
179
|
-
filter,
|
|
302
|
+
filter: buildSelectorFilter(),
|
|
180
303
|
except_ids: [...exceptIds],
|
|
181
304
|
};
|
|
182
305
|
}
|
|
183
306
|
|
|
307
|
+
// Active-filter chips for the header row. `task` only chips on the
|
|
308
|
+
// cross-task view; everywhere else the task name is the page scope.
|
|
309
|
+
const filterChips = $derived(
|
|
310
|
+
activeDimensions(filters)
|
|
311
|
+
.filter((dim) => dim !== "task" || showTask)
|
|
312
|
+
.map((dim) => ({ dimension: dim, label: chipLabel(dim) })),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
function chipLabel(dim: FilterDimension): string {
|
|
316
|
+
switch (dim) {
|
|
317
|
+
case "status":
|
|
318
|
+
return statusChipLabel(filters.statuses);
|
|
319
|
+
case "time":
|
|
320
|
+
return timeChipLabel();
|
|
321
|
+
case "task":
|
|
322
|
+
return filters.task_name ?? "";
|
|
323
|
+
case "triggered_by":
|
|
324
|
+
return "Trigger: " + triggerDescription(filters.triggered_by ?? "");
|
|
325
|
+
case "exit_code":
|
|
326
|
+
return exitCodeChipLabel(filters.exit_code);
|
|
327
|
+
case "retries":
|
|
328
|
+
return "Retries only";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function timeChipLabel(): string {
|
|
333
|
+
const after = filters.created_after;
|
|
334
|
+
const before = filters.created_before;
|
|
335
|
+
if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
|
|
336
|
+
if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
|
|
337
|
+
if (after) return `Since ${formatDateTime(after)}`;
|
|
338
|
+
if (before) return `Before ${formatDateTime(before)}`;
|
|
339
|
+
return "";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function removeChip(dim: FilterDimension) {
|
|
343
|
+
filters = clearDimension(filters, dim);
|
|
344
|
+
}
|
|
345
|
+
|
|
184
346
|
function emitBulk(handler: BulkHandler | undefined, predicate: (r: Run) => boolean) {
|
|
185
347
|
if (!handler) return;
|
|
186
348
|
const affected = selectedRuns.filter(predicate);
|
|
@@ -201,7 +363,13 @@
|
|
|
201
363
|
}
|
|
202
364
|
|
|
203
365
|
function toggleSortDirection() {
|
|
204
|
-
|
|
366
|
+
// Reassign the whole object (not just the property): the parent reads
|
|
367
|
+
// `filters` through a multi-level `bind:`, and a fresh reference is what
|
|
368
|
+
// reliably re-triggers its fetch effect.
|
|
369
|
+
filters = {
|
|
370
|
+
...filters,
|
|
371
|
+
sort_direction: filters.sort_direction === "asc" ? "desc" : "asc",
|
|
372
|
+
};
|
|
205
373
|
}
|
|
206
374
|
|
|
207
375
|
let masterCheckboxRef: HTMLInputElement | undefined = $state();
|
|
@@ -212,13 +380,16 @@
|
|
|
212
380
|
</script>
|
|
213
381
|
|
|
214
382
|
<div
|
|
215
|
-
class=
|
|
383
|
+
class={flush
|
|
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-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
|
|
216
386
|
>
|
|
217
|
-
<!-- Inline heading: master checkbox, label, selection count or controls -->
|
|
218
387
|
<div
|
|
219
|
-
class="flex shrink-0 items-center gap-2 border-b
|
|
220
|
-
? 'bg-primary-soft/40'
|
|
221
|
-
:
|
|
388
|
+
class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
|
|
389
|
+
? 'border-outline-faint bg-primary-soft/40'
|
|
390
|
+
: flush
|
|
391
|
+
? 'border-transparent bg-surface'
|
|
392
|
+
: 'border-outline-faint bg-surface-sunken'}"
|
|
222
393
|
>
|
|
223
394
|
{#if bulkActions}
|
|
224
395
|
<label
|
|
@@ -283,8 +454,18 @@
|
|
|
283
454
|
<span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase">
|
|
284
455
|
{headerLabel}
|
|
285
456
|
</span>
|
|
286
|
-
<
|
|
457
|
+
<span class="text-xs text-on-surface-faint">
|
|
458
|
+
{#if outputSearchActive}
|
|
459
|
+
{matchedRuns.length} of {items.length}
|
|
460
|
+
{:else}
|
|
461
|
+
{total}
|
|
462
|
+
{total === 1 ? "run" : "runs"}
|
|
463
|
+
{/if}
|
|
464
|
+
</span>
|
|
287
465
|
<div class="ml-auto flex items-center gap-1">
|
|
466
|
+
{#if showFilters}
|
|
467
|
+
<RunFilterPopover bind:filters {showTask} {tasks} />
|
|
468
|
+
{/if}
|
|
288
469
|
<Button
|
|
289
470
|
variant="ghost"
|
|
290
471
|
size="xs"
|
|
@@ -305,43 +486,75 @@
|
|
|
305
486
|
{/if}
|
|
306
487
|
</div>
|
|
307
488
|
|
|
308
|
-
{#if showFilters}
|
|
309
|
-
<!--
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
<
|
|
317
|
-
|
|
318
|
-
bind:value={searchInput}
|
|
319
|
-
placeholder="Search task or ID..."
|
|
320
|
-
class="h-8 w-full rounded-md border border-outline bg-surface-raised pr-3 pl-8 text-sm transition-all placeholder:text-on-surface-faint focus:border-ring focus:ring-2 focus:ring-ring/20 focus:outline-none"
|
|
321
|
-
/>
|
|
322
|
-
</div>
|
|
323
|
-
<div class="relative">
|
|
324
|
-
<select
|
|
325
|
-
bind:value={filters.status}
|
|
326
|
-
class="h-7 w-full appearance-none rounded-md border border-outline bg-surface-raised pr-6 pl-2 text-xs text-on-surface-muted focus:border-ring focus:outline-none"
|
|
489
|
+
{#if showFilters && filterChips.length > 0}
|
|
490
|
+
<!-- Active-filter chips. Rendered only when filters are set, so the
|
|
491
|
+
header stays clean when empty; each chip's X clears its dimension.
|
|
492
|
+
The filter controls themselves live in the popover above. -->
|
|
493
|
+
<div
|
|
494
|
+
class="flex shrink-0 flex-wrap gap-1 border-b border-outline-faint bg-surface-sunken px-3 py-2"
|
|
495
|
+
>
|
|
496
|
+
{#each filterChips as chip (chip.dimension)}
|
|
497
|
+
<span
|
|
498
|
+
class="inline-flex items-center gap-1 rounded-full bg-surface-raised py-0.5 pr-1 pl-2 text-2xs font-medium text-on-surface-muted ring-1 ring-outline-faint ring-inset"
|
|
327
499
|
>
|
|
328
|
-
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
</div>
|
|
500
|
+
{chip.label}
|
|
501
|
+
<button
|
|
502
|
+
type="button"
|
|
503
|
+
onclick={() => removeChip(chip.dimension)}
|
|
504
|
+
class="flex size-3.5 items-center justify-center rounded-full text-on-surface-faint transition-colors hover:bg-surface-sunken hover:text-on-surface"
|
|
505
|
+
aria-label="Remove {chip.label} filter"
|
|
506
|
+
>
|
|
507
|
+
<X size={11} />
|
|
508
|
+
</button>
|
|
509
|
+
</span>
|
|
510
|
+
{/each}
|
|
340
511
|
</div>
|
|
341
512
|
{/if}
|
|
342
513
|
|
|
343
514
|
<div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
|
|
344
|
-
{#if
|
|
515
|
+
{#if outputSearchActive}
|
|
516
|
+
<!-- Output-search results: the rail filters to runs that printed the
|
|
517
|
+
query, each annotated with the matching line. -->
|
|
518
|
+
{#if outputSearchPending}
|
|
519
|
+
<!-- Searching: shimmer placeholders shaped like result rows. -->
|
|
520
|
+
<div class="flex flex-col gap-0.5" aria-busy="true" aria-label="Searching output">
|
|
521
|
+
{#each [0, 1, 2, 3, 4] as i (i)}
|
|
522
|
+
<div class="flex items-center gap-2.5 rounded-[10px] px-3 py-2.5">
|
|
523
|
+
<span
|
|
524
|
+
class="size-[9px] shrink-0 animate-pulse rounded-full bg-surface-sunken"
|
|
525
|
+
></span>
|
|
526
|
+
<span
|
|
527
|
+
class="h-3 animate-pulse rounded bg-surface-sunken"
|
|
528
|
+
style:width="{70 - i * 8}%"
|
|
529
|
+
></span>
|
|
530
|
+
<span class="ml-auto h-3 w-9 animate-pulse rounded bg-surface-sunken"
|
|
531
|
+
></span>
|
|
532
|
+
</div>
|
|
533
|
+
{/each}
|
|
534
|
+
</div>
|
|
535
|
+
{:else if matchedRuns.length === 0}
|
|
536
|
+
<div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
|
|
537
|
+
No output matches
|
|
538
|
+
<b class="text-on-surface">“{outputQuery.trim()}”</b>.<br />
|
|
539
|
+
Try a different term.
|
|
540
|
+
</div>
|
|
541
|
+
{:else}
|
|
542
|
+
<div class="flex flex-col gap-0.5">
|
|
543
|
+
{#each matchedRuns as run (run.id)}
|
|
544
|
+
<div class="group/row relative flex items-stretch gap-1">
|
|
545
|
+
{#if bulkActions}
|
|
546
|
+
{@render rowCheckboxOverlay(run)}
|
|
547
|
+
{/if}
|
|
548
|
+
{@render runRowButton(
|
|
549
|
+
run,
|
|
550
|
+
selectedRunId === run.id,
|
|
551
|
+
outputMatches?.get(run.id),
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
554
|
+
{/each}
|
|
555
|
+
</div>
|
|
556
|
+
{/if}
|
|
557
|
+
{:else if items.length === 0 && !loading}
|
|
345
558
|
<EmptyState
|
|
346
559
|
title={emptyText}
|
|
347
560
|
description={emptyDescription}
|
|
@@ -358,15 +571,8 @@
|
|
|
358
571
|
{#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
|
|
359
572
|
{@const run = items[row.index]}
|
|
360
573
|
{#if run}
|
|
361
|
-
{@const isActive = selectedRunId === run.id}
|
|
362
|
-
{@const isChecked = isRowSelected(run.id)}
|
|
363
|
-
{@const config = getRunStatusConfig(runDisplayStatus(run))}
|
|
364
|
-
{@const Icon = config.icon}
|
|
365
|
-
{@const duration = runDuration(run)}
|
|
366
|
-
{@const retry = runRetryLabel(run)}
|
|
367
|
-
{@const startedAt = run.start_at ?? run.created_at}
|
|
368
574
|
<div
|
|
369
|
-
class="flex items-
|
|
575
|
+
class="group/row flex items-center gap-1"
|
|
370
576
|
style:position="absolute"
|
|
371
577
|
style:top="0"
|
|
372
578
|
style:left="0"
|
|
@@ -375,154 +581,9 @@
|
|
|
375
581
|
style:transform="translateY({row.start}px)"
|
|
376
582
|
>
|
|
377
583
|
{#if bulkActions}
|
|
378
|
-
|
|
379
|
-
class="flex shrink-0 cursor-pointer items-center px-1.5"
|
|
380
|
-
aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
|
|
381
|
-
>
|
|
382
|
-
<input
|
|
383
|
-
type="checkbox"
|
|
384
|
-
checked={isChecked}
|
|
385
|
-
onchange={() => toggleRow(run.id)}
|
|
386
|
-
onclick={(e) => e.stopPropagation()}
|
|
387
|
-
class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
|
|
388
|
-
/>
|
|
389
|
-
</label>
|
|
584
|
+
{@render rowCheckboxOverlay(run)}
|
|
390
585
|
{/if}
|
|
391
|
-
|
|
392
|
-
class="btn-scale group duration-normal relative w-full rounded-lg border p-3 text-left transition-all select-none
|
|
393
|
-
{isActive
|
|
394
|
-
? 'border-primary-soft bg-primary-soft/50 shadow-sm'
|
|
395
|
-
: 'border-transparent bg-surface-raised hover:border-outline hover:bg-surface-sunken'}"
|
|
396
|
-
onclick={() => selectRun(run.id)}
|
|
397
|
-
onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
|
|
398
|
-
>
|
|
399
|
-
{#if showTaskName}
|
|
400
|
-
<!-- Runs variant: task name as primary, shortId secondary -->
|
|
401
|
-
<div class="mb-1 flex items-center justify-between">
|
|
402
|
-
<div class="flex min-w-0 items-center gap-2">
|
|
403
|
-
<div class="{config.color} shrink-0">
|
|
404
|
-
<Icon
|
|
405
|
-
size={16}
|
|
406
|
-
class={run.status === "running"
|
|
407
|
-
? "animate-spin"
|
|
408
|
-
: ""}
|
|
409
|
-
/>
|
|
410
|
-
</div>
|
|
411
|
-
<span
|
|
412
|
-
class="truncate text-sm font-semibold text-on-surface"
|
|
413
|
-
>
|
|
414
|
-
{run.task_name}{#if run.instance_index > 0}<span
|
|
415
|
-
class="text-on-surface-muted"
|
|
416
|
-
>#{run.instance_index}</span
|
|
417
|
-
>{/if}
|
|
418
|
-
</span>
|
|
419
|
-
</div>
|
|
420
|
-
<span
|
|
421
|
-
class="shrink-0 text-2xs {isActive
|
|
422
|
-
? 'font-medium text-primary'
|
|
423
|
-
: 'text-on-surface-faint'}"
|
|
424
|
-
title={formatFullDateTime(startedAt)}
|
|
425
|
-
>
|
|
426
|
-
{formatRelativeTime(startedAt)}
|
|
427
|
-
</span>
|
|
428
|
-
</div>
|
|
429
|
-
|
|
430
|
-
<div class="flex items-center justify-between pl-6 text-xs">
|
|
431
|
-
<div
|
|
432
|
-
class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-muted"
|
|
433
|
-
>
|
|
434
|
-
<span class="truncate">{formatDateTime(startedAt)}</span
|
|
435
|
-
>
|
|
436
|
-
<span class="text-on-surface-faint"
|
|
437
|
-
>· {formatTriggeredByLabel(run.triggered_by)}</span
|
|
438
|
-
>
|
|
439
|
-
{#if retry}
|
|
440
|
-
<span
|
|
441
|
-
class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
|
|
442
|
-
>{retry}</span
|
|
443
|
-
>
|
|
444
|
-
{/if}
|
|
445
|
-
</div>
|
|
446
|
-
{#if duration}
|
|
447
|
-
<div
|
|
448
|
-
class="flex items-center gap-1 text-on-surface-faint {isActive
|
|
449
|
-
? 'text-primary'
|
|
450
|
-
: ''}"
|
|
451
|
-
>
|
|
452
|
-
<Timer size={10} />
|
|
453
|
-
<span class="font-mono">{duration}</span>
|
|
454
|
-
</div>
|
|
455
|
-
{/if}
|
|
456
|
-
</div>
|
|
457
|
-
{:else}
|
|
458
|
-
<!-- Task variant: when it ran as primary, outcome secondary -->
|
|
459
|
-
<div class="mb-1.5 flex items-center justify-between">
|
|
460
|
-
<div class="flex min-w-0 items-center gap-2">
|
|
461
|
-
<div class="{config.color} shrink-0">
|
|
462
|
-
<Icon
|
|
463
|
-
size={16}
|
|
464
|
-
class={run.status === "running"
|
|
465
|
-
? "animate-spin"
|
|
466
|
-
: ""}
|
|
467
|
-
/>
|
|
468
|
-
</div>
|
|
469
|
-
<span
|
|
470
|
-
class="truncate text-sm font-semibold text-on-surface"
|
|
471
|
-
>
|
|
472
|
-
{formatDateTime(startedAt)}
|
|
473
|
-
</span>
|
|
474
|
-
</div>
|
|
475
|
-
<span
|
|
476
|
-
class="shrink-0 text-2xs {isActive
|
|
477
|
-
? 'font-medium text-primary'
|
|
478
|
-
: 'text-on-surface-faint'}"
|
|
479
|
-
title={formatFullDateTime(startedAt)}
|
|
480
|
-
>
|
|
481
|
-
{formatRelativeTime(startedAt)}
|
|
482
|
-
</span>
|
|
483
|
-
</div>
|
|
484
|
-
|
|
485
|
-
<div class="flex items-center justify-between pl-6 text-xs">
|
|
486
|
-
<div
|
|
487
|
-
class="flex min-w-0 items-center gap-2 text-on-surface-muted"
|
|
488
|
-
>
|
|
489
|
-
<span class="capitalize">{runDisplayStatus(run)}</span>
|
|
490
|
-
<span class="text-on-surface-faint"
|
|
491
|
-
>· {formatTriggeredByLabel(run.triggered_by)}</span
|
|
492
|
-
>
|
|
493
|
-
{#if retry}
|
|
494
|
-
<span
|
|
495
|
-
class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
|
|
496
|
-
>{retry}</span
|
|
497
|
-
>
|
|
498
|
-
{/if}
|
|
499
|
-
{#if run.instance_index > 0}
|
|
500
|
-
<span
|
|
501
|
-
class="font-mono text-2xs text-on-surface-faint"
|
|
502
|
-
>instance #{run.instance_index}</span
|
|
503
|
-
>
|
|
504
|
-
{/if}
|
|
505
|
-
</div>
|
|
506
|
-
{#if duration}
|
|
507
|
-
<div
|
|
508
|
-
class="flex items-center gap-1 text-on-surface-faint {isActive
|
|
509
|
-
? 'text-primary'
|
|
510
|
-
: ''}"
|
|
511
|
-
>
|
|
512
|
-
<Timer size={10} />
|
|
513
|
-
<span class="font-mono">{duration}</span>
|
|
514
|
-
</div>
|
|
515
|
-
{/if}
|
|
516
|
-
</div>
|
|
517
|
-
{/if}
|
|
518
|
-
|
|
519
|
-
<div
|
|
520
|
-
class="duration-normal absolute top-1/2 left-0 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-all {isActive
|
|
521
|
-
? 'opacity-100'
|
|
522
|
-
: 'opacity-0'}"
|
|
523
|
-
aria-hidden="true"
|
|
524
|
-
></div>
|
|
525
|
-
</button>
|
|
586
|
+
{@render runRowButton(run, selectedRunId === run.id, undefined)}
|
|
526
587
|
</div>
|
|
527
588
|
{/if}
|
|
528
589
|
{/each}
|
|
@@ -530,3 +591,148 @@
|
|
|
530
591
|
{/if}
|
|
531
592
|
</div>
|
|
532
593
|
</div>
|
|
594
|
+
|
|
595
|
+
<!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
|
|
596
|
+
selection exists — so the row checkbox can take its place over it. -->
|
|
597
|
+
{#snippet statusDot(colorClass: string, running: boolean)}
|
|
598
|
+
<span
|
|
599
|
+
class="{colorClass} size-[9px] shrink-0 rounded-full bg-current ring-[3px] ring-current/20 {running
|
|
600
|
+
? 'animate-pulse'
|
|
601
|
+
: ''} {bulkActions
|
|
602
|
+
? selectionActive
|
|
603
|
+
? 'opacity-0'
|
|
604
|
+
: 'transition-opacity group-hover/row:opacity-0'
|
|
605
|
+
: ''}"
|
|
606
|
+
aria-hidden="true"
|
|
607
|
+
></span>
|
|
608
|
+
{/snippet}
|
|
609
|
+
|
|
610
|
+
<!-- Row checkbox: sits over the status dot (which fades out beneath it) so the
|
|
611
|
+
row keeps the artifact's geometry. Lives in the row wrapper (not the button)
|
|
612
|
+
to keep the markup valid, positioned to land on the dot. The 14px box is
|
|
613
|
+
wrapped in a 28px label so the hover/click hitbox is comfortable without
|
|
614
|
+
enlarging the visible checkbox. Used by both the task rail and the
|
|
615
|
+
cross-task /runs grid so selection behaves identically in each. -->
|
|
616
|
+
{#snippet rowCheckboxOverlay(run: Run)}
|
|
617
|
+
<label
|
|
618
|
+
class="absolute top-1/2 left-[2px] z-10 flex size-7 -translate-y-1/2 cursor-pointer items-center justify-center"
|
|
619
|
+
>
|
|
620
|
+
<input
|
|
621
|
+
type="checkbox"
|
|
622
|
+
checked={isRowSelected(run.id)}
|
|
623
|
+
onchange={() => toggleRow(run.id)}
|
|
624
|
+
onclick={(e) => e.stopPropagation()}
|
|
625
|
+
aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
|
|
626
|
+
class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 transition-opacity {selectionActive
|
|
627
|
+
? 'opacity-100'
|
|
628
|
+
: 'group-hover/row:opacity-100'}"
|
|
629
|
+
/>
|
|
630
|
+
</label>
|
|
631
|
+
{/snippet}
|
|
632
|
+
|
|
633
|
+
{#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
|
|
634
|
+
{@const dstatus = runDisplayStatus(run)}
|
|
635
|
+
{@const config = getRunStatusConfig(dstatus)}
|
|
636
|
+
{@const running = run.status === "running"}
|
|
637
|
+
{@const spine = config.dot.replace(" animate-pulse", "")}
|
|
638
|
+
{@const startedAt = run.start_at ?? run.created_at}
|
|
639
|
+
{@const retry = runRetryLabel(run)}
|
|
640
|
+
{@const suffix = instanceSuffix(run.instance_index, getInstanceCount(run.task_name))}
|
|
641
|
+
<button
|
|
642
|
+
class="btn-scale group relative w-full rounded-[10px] border text-left transition-all select-none {showTaskName
|
|
643
|
+
? 'p-3'
|
|
644
|
+
: 'px-3 py-[11px]'} {isActive
|
|
645
|
+
? 'border-outline bg-surface-raised shadow-sm'
|
|
646
|
+
: 'border-transparent hover:bg-surface-sunken'}"
|
|
647
|
+
onclick={() => selectRun(run.id)}
|
|
648
|
+
onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
|
|
649
|
+
>
|
|
650
|
+
{#if showTaskName}
|
|
651
|
+
<!-- Cross-task /runs variant: the same readout language as the task
|
|
652
|
+
rail — status dot, status-colored outcome, mono right readout —
|
|
653
|
+
with the task name carried as the primary. -->
|
|
654
|
+
<div class="flex items-center gap-2.5">
|
|
655
|
+
{@render statusDot(config.color, running)}
|
|
656
|
+
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
657
|
+
<span class="flex items-center gap-1.5">
|
|
658
|
+
<span class="truncate text-[13px] font-semibold text-on-surface">
|
|
659
|
+
{run.task_name}{#if suffix}<span class="text-on-surface-muted"
|
|
660
|
+
>{suffix}</span
|
|
661
|
+
>{/if}
|
|
662
|
+
</span>
|
|
663
|
+
<span class="shrink-0 text-on-surface-faint">·</span>
|
|
664
|
+
<span class="shrink-0 text-[12px] font-semibold capitalize {config.color}"
|
|
665
|
+
>{dstatus}</span
|
|
666
|
+
>
|
|
667
|
+
</span>
|
|
668
|
+
<span
|
|
669
|
+
class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-faint"
|
|
670
|
+
title={formatFullDateTime(startedAt)}
|
|
671
|
+
>
|
|
672
|
+
<span class="truncate">{formatDateTime(startedAt)}</span>
|
|
673
|
+
<span class="shrink-0">· {formatTriggeredByLabel(run.triggered_by)}</span>
|
|
674
|
+
{#if retry}
|
|
675
|
+
<span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
|
|
676
|
+
>{retry}</span
|
|
677
|
+
>
|
|
678
|
+
{/if}
|
|
679
|
+
</span>
|
|
680
|
+
</span>
|
|
681
|
+
<span
|
|
682
|
+
class="shrink-0 self-start pt-0.5 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
|
|
683
|
+
title={retry ?? undefined}
|
|
684
|
+
>
|
|
685
|
+
{rowRightLabel(run, dstatus)}
|
|
686
|
+
</span>
|
|
687
|
+
</div>
|
|
688
|
+
{:else}
|
|
689
|
+
<!-- Task-rail variant (artifact ".run"): a single dense line —
|
|
690
|
+
time · date · outcome, with a mono exit/duration readout.
|
|
691
|
+
leading-tight matches the artifact's ~1.2 line-height so the
|
|
692
|
+
(descender-less) text optically centers instead of riding high
|
|
693
|
+
inside Tailwind's default 1.5 line box. -->
|
|
694
|
+
<div class="flex items-center gap-[11px] leading-tight">
|
|
695
|
+
{@render statusDot(config.color, running)}
|
|
696
|
+
<span class="flex min-w-0 flex-1 items-center gap-1.5 truncate">
|
|
697
|
+
<span
|
|
698
|
+
class="text-[12.5px] font-semibold tracking-tight text-on-surface"
|
|
699
|
+
title={formatFullDateTime(startedAt)}
|
|
700
|
+
>
|
|
701
|
+
{formatTimeHM(startedAt)} · {formatDayMonth(startedAt)}
|
|
702
|
+
</span>
|
|
703
|
+
<span class="text-on-surface-faint">·</span>
|
|
704
|
+
<span class="text-[12.5px] font-semibold capitalize {config.color}"
|
|
705
|
+
>{dstatus}</span
|
|
706
|
+
>
|
|
707
|
+
{#if suffix}
|
|
708
|
+
<span class="font-mono text-2xs text-on-surface-faint">{suffix}</span>
|
|
709
|
+
{/if}
|
|
710
|
+
</span>
|
|
711
|
+
<span
|
|
712
|
+
class="shrink-0 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
|
|
713
|
+
title={retry ?? undefined}
|
|
714
|
+
>
|
|
715
|
+
{rowRightLabel(run, dstatus)}
|
|
716
|
+
</span>
|
|
717
|
+
</div>
|
|
718
|
+
{#if match}
|
|
719
|
+
{@const hl = highlightParts(match.text, outputQuery)}
|
|
720
|
+
<div
|
|
721
|
+
class="mt-1.5 truncate rounded-md bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
|
|
722
|
+
>
|
|
723
|
+
{hl.before}<mark
|
|
724
|
+
class="rounded-sm bg-primary-soft px-0.5 text-primary-soft-text"
|
|
725
|
+
>{hl.match}</mark
|
|
726
|
+
>{hl.after}
|
|
727
|
+
</div>
|
|
728
|
+
{/if}
|
|
729
|
+
{/if}
|
|
730
|
+
|
|
731
|
+
<div
|
|
732
|
+
class="duration-normal absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] transition-all {spine} {isActive
|
|
733
|
+
? 'opacity-100'
|
|
734
|
+
: 'opacity-0'}"
|
|
735
|
+
aria-hidden="true"
|
|
736
|
+
></div>
|
|
737
|
+
</button>
|
|
738
|
+
{/snippet}
|