@runwisp/ui 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/lib/assets/runwisp-logo.svg +2 -2
- package/src/lib/components/LogConsole.svelte +561 -66
- package/src/lib/components/Modal.svelte +3 -1
- package/src/lib/components/Popover.svelte +64 -31
- package/src/lib/components/dashboard/RunDetailPanel.svelte +774 -169
- package/src/lib/components/dashboard/RunFilterPopover.svelte +299 -0
- package/src/lib/components/dashboard/RunsList.svelte +418 -219
- 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 +50 -1
- package/src/lib/components/dashboard/run-helpers.ts +38 -0
- package/src/lib/index.ts +27 -5
- package/src/lib/log-console/LogCache.svelte.ts +56 -1
- package/src/lib/log-console/ansi.test.ts +18 -1
- package/src/lib/log-console/ansi.ts +17 -0
- package/src/lib/log-console/types.ts +17 -0
- package/src/lib/theme-tokens.css +74 -62
- package/src/lib/theme.css +1 -0
- package/src/lib/utils/format.test.ts +38 -0
- package/src/lib/utils/format.ts +36 -0
- package/src/stories/LogConsole.stories.svelte +13 -0
- package/src/stories/assets/runwisp_c.svg +2 -2
|
@@ -2,39 +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";
|
|
31
|
-
import
|
|
19
|
+
import RunFilterPopover from "./RunFilterPopover.svelte";
|
|
32
20
|
import type { Run } from "./types.js";
|
|
33
|
-
import type { RunSelector } from "@runwisp/common";
|
|
21
|
+
import type { RunSelector, RunStatus } from "@runwisp/common";
|
|
34
22
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
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";
|
|
38
47
|
|
|
39
48
|
type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
|
|
40
49
|
|
|
@@ -47,6 +56,8 @@
|
|
|
47
56
|
selectedRunId = $bindable(null),
|
|
48
57
|
onselect,
|
|
49
58
|
showFilters = false,
|
|
59
|
+
showTask = false,
|
|
60
|
+
tasks = [],
|
|
50
61
|
showTaskName = false,
|
|
51
62
|
taskNameFilter,
|
|
52
63
|
headerLabel = "Run History",
|
|
@@ -56,6 +67,12 @@
|
|
|
56
67
|
onBulkCancel,
|
|
57
68
|
onBulkDelete,
|
|
58
69
|
onBulkRerun,
|
|
70
|
+
getInstanceCount = () => 1,
|
|
71
|
+
flush = false,
|
|
72
|
+
outputSearch = false,
|
|
73
|
+
outputQuery = "",
|
|
74
|
+
outputMatches = null,
|
|
75
|
+
outputSearchPending = false,
|
|
59
76
|
}: {
|
|
60
77
|
items: Run[];
|
|
61
78
|
total: number;
|
|
@@ -65,6 +82,11 @@
|
|
|
65
82
|
selectedRunId?: string | null;
|
|
66
83
|
onselect?: (runId: string) => void;
|
|
67
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 }[];
|
|
68
90
|
showTaskName?: boolean;
|
|
69
91
|
taskNameFilter?: string;
|
|
70
92
|
headerLabel?: string;
|
|
@@ -74,12 +96,34 @@
|
|
|
74
96
|
onBulkCancel?: BulkHandler;
|
|
75
97
|
onBulkDelete?: BulkHandler;
|
|
76
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;
|
|
77
120
|
} = $props();
|
|
78
121
|
|
|
79
|
-
|
|
122
|
+
// Task-rail rows are a single dense line (artifact ".run", 46px); the
|
|
123
|
+
// cross-task /runs view adds the task name on a second line (64px).
|
|
124
|
+
const rowHeight = $derived(showTaskName ? 64 : 44);
|
|
80
125
|
const OVERSCAN = 8;
|
|
81
126
|
const LOAD_AHEAD = 10;
|
|
82
|
-
const SEARCH_DEBOUNCE_MS = 250;
|
|
83
127
|
|
|
84
128
|
// Selection model: two modes —
|
|
85
129
|
// 1. explicit: user picked specific rows. explicitIds holds them.
|
|
@@ -89,22 +133,57 @@
|
|
|
89
133
|
const exceptIds = new SvelteSet<string>();
|
|
90
134
|
|
|
91
135
|
let scrollElement: HTMLDivElement | undefined = $state();
|
|
92
|
-
let searchInput = $state(filters.search);
|
|
93
136
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
$
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
|
103
182
|
|
|
104
183
|
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
|
105
184
|
count: 0,
|
|
106
185
|
getScrollElement: () => scrollElement ?? null,
|
|
107
|
-
estimateSize: () =>
|
|
186
|
+
estimateSize: () => rowHeight,
|
|
108
187
|
overscan: OVERSCAN,
|
|
109
188
|
});
|
|
110
189
|
|
|
@@ -158,6 +237,10 @@
|
|
|
158
237
|
let selectedRuns = $derived(items.filter((r: Run) => isRowSelected(r.id)));
|
|
159
238
|
let selectionCount = $derived(selectedRuns.length);
|
|
160
239
|
let hasSelection = $derived(selectionCount > 0);
|
|
240
|
+
// Task-rail rows overlay the checkbox on the status dot (it appears on hover
|
|
241
|
+
// or once a selection exists). selectionActive forces all checkboxes visible
|
|
242
|
+
// so the operator can extend the selection without hunting per-row hovers.
|
|
243
|
+
let selectionActive = $derived(bulkActions && hasSelection);
|
|
161
244
|
let allSelected = $derived(selectAllMode && exceptIds.size === 0);
|
|
162
245
|
|
|
163
246
|
let anyRunning = $derived(selectedRuns.some((r: Run) => r.status === "running"));
|
|
@@ -165,24 +248,78 @@
|
|
|
165
248
|
selectedRuns.some((r: Run) => r.status !== "running" && r.status !== "pending"),
|
|
166
249
|
);
|
|
167
250
|
|
|
251
|
+
// The selector's filter is the same RunFilter the list query uses, so a
|
|
252
|
+
// "select all matching" bulk op targets exactly the rows on screen. The
|
|
253
|
+
// popover dimensions only apply where the popover is shown; the task scope
|
|
254
|
+
// (page-injected or popover-set) always applies.
|
|
255
|
+
function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
|
|
256
|
+
const filter: NonNullable<RunSelector["filter"]> = {};
|
|
257
|
+
if (taskNameFilter) filter.task_name = taskNameFilter;
|
|
258
|
+
else if (filters.task_name) filter.task_name = filters.task_name;
|
|
259
|
+
if (!showFilters) return filter;
|
|
260
|
+
if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
|
|
261
|
+
const query = filters.search.trim();
|
|
262
|
+
if (query) filter.search = query;
|
|
263
|
+
if (filters.created_after) filter.created_after = filters.created_after;
|
|
264
|
+
if (filters.created_before) filter.created_before = filters.created_before;
|
|
265
|
+
if (filters.triggered_by) filter.triggered_by = filters.triggered_by;
|
|
266
|
+
const exit = exitCodeRange(filters.exit_code);
|
|
267
|
+
if (exit.min !== undefined) filter.exit_code_min = exit.min;
|
|
268
|
+
if (exit.max !== undefined) filter.exit_code_max = exit.max;
|
|
269
|
+
if (filters.retries_only) filter.retries_only = true;
|
|
270
|
+
return filter;
|
|
271
|
+
}
|
|
272
|
+
|
|
168
273
|
function buildSelector(): RunSelector {
|
|
169
274
|
if (!selectAllMode) {
|
|
170
275
|
return { match_all: false, ids: [...explicitIds] };
|
|
171
276
|
}
|
|
172
|
-
const filter: { status?: string; task_name?: string; search?: string } = {};
|
|
173
|
-
if (taskNameFilter) filter.task_name = taskNameFilter;
|
|
174
|
-
if (showFilters) {
|
|
175
|
-
if (filters.status && filters.status !== "all") filter.status = filters.status;
|
|
176
|
-
const query = filters.search.trim();
|
|
177
|
-
if (query) filter.search = query;
|
|
178
|
-
}
|
|
179
277
|
return {
|
|
180
278
|
match_all: true,
|
|
181
|
-
filter,
|
|
279
|
+
filter: buildSelectorFilter(),
|
|
182
280
|
except_ids: [...exceptIds],
|
|
183
281
|
};
|
|
184
282
|
}
|
|
185
283
|
|
|
284
|
+
// Active-filter chips for the header row. `task` only chips on the
|
|
285
|
+
// cross-task view; everywhere else the task name is the page scope.
|
|
286
|
+
const filterChips = $derived(
|
|
287
|
+
activeDimensions(filters)
|
|
288
|
+
.filter((dim) => dim !== "task" || showTask)
|
|
289
|
+
.map((dim) => ({ dimension: dim, label: chipLabel(dim) })),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
function chipLabel(dim: FilterDimension): string {
|
|
293
|
+
switch (dim) {
|
|
294
|
+
case "status":
|
|
295
|
+
return statusChipLabel(filters.statuses);
|
|
296
|
+
case "time":
|
|
297
|
+
return timeChipLabel();
|
|
298
|
+
case "task":
|
|
299
|
+
return filters.task_name ?? "";
|
|
300
|
+
case "triggered_by":
|
|
301
|
+
return "Trigger: " + triggerDescription(filters.triggered_by ?? "");
|
|
302
|
+
case "exit_code":
|
|
303
|
+
return exitCodeChipLabel(filters.exit_code);
|
|
304
|
+
case "retries":
|
|
305
|
+
return "Retries only";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function timeChipLabel(): string {
|
|
310
|
+
const after = filters.created_after;
|
|
311
|
+
const before = filters.created_before;
|
|
312
|
+
if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
|
|
313
|
+
if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
|
|
314
|
+
if (after) return `Since ${formatDateTime(after)}`;
|
|
315
|
+
if (before) return `Before ${formatDateTime(before)}`;
|
|
316
|
+
return "";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function removeChip(dim: FilterDimension) {
|
|
320
|
+
filters = clearDimension(filters, dim);
|
|
321
|
+
}
|
|
322
|
+
|
|
186
323
|
function emitBulk(handler: BulkHandler | undefined, predicate: (r: Run) => boolean) {
|
|
187
324
|
if (!handler) return;
|
|
188
325
|
const affected = selectedRuns.filter(predicate);
|
|
@@ -203,7 +340,13 @@
|
|
|
203
340
|
}
|
|
204
341
|
|
|
205
342
|
function toggleSortDirection() {
|
|
206
|
-
|
|
343
|
+
// Reassign the whole object (not just the property): the parent reads
|
|
344
|
+
// `filters` through a multi-level `bind:`, and a fresh reference is what
|
|
345
|
+
// reliably re-triggers its fetch effect.
|
|
346
|
+
filters = {
|
|
347
|
+
...filters,
|
|
348
|
+
sort_direction: filters.sort_direction === "asc" ? "desc" : "asc",
|
|
349
|
+
};
|
|
207
350
|
}
|
|
208
351
|
|
|
209
352
|
let masterCheckboxRef: HTMLInputElement | undefined = $state();
|
|
@@ -214,13 +357,17 @@
|
|
|
214
357
|
</script>
|
|
215
358
|
|
|
216
359
|
<div
|
|
217
|
-
class=
|
|
360
|
+
class={flush
|
|
361
|
+
? "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"
|
|
362
|
+
: "flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
|
|
218
363
|
>
|
|
219
364
|
<!-- Inline heading: master checkbox, label, selection count or controls -->
|
|
220
365
|
<div
|
|
221
|
-
class="flex shrink-0 items-center gap-2 border-b
|
|
222
|
-
? 'bg-primary-soft/40'
|
|
223
|
-
:
|
|
366
|
+
class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
|
|
367
|
+
? 'border-outline-faint bg-primary-soft/40'
|
|
368
|
+
: flush
|
|
369
|
+
? 'border-transparent bg-surface'
|
|
370
|
+
: 'border-outline-faint bg-surface-sunken'}"
|
|
224
371
|
>
|
|
225
372
|
{#if bulkActions}
|
|
226
373
|
<label
|
|
@@ -285,8 +432,18 @@
|
|
|
285
432
|
<span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase">
|
|
286
433
|
{headerLabel}
|
|
287
434
|
</span>
|
|
288
|
-
<
|
|
435
|
+
<span class="text-xs text-on-surface-faint">
|
|
436
|
+
{#if outputSearchActive}
|
|
437
|
+
{matchedRuns.length} of {items.length}
|
|
438
|
+
{:else}
|
|
439
|
+
{total}
|
|
440
|
+
{total === 1 ? "run" : "runs"}
|
|
441
|
+
{/if}
|
|
442
|
+
</span>
|
|
289
443
|
<div class="ml-auto flex items-center gap-1">
|
|
444
|
+
{#if showFilters}
|
|
445
|
+
<RunFilterPopover bind:filters {showTask} {tasks} />
|
|
446
|
+
{/if}
|
|
290
447
|
<Button
|
|
291
448
|
variant="ghost"
|
|
292
449
|
size="xs"
|
|
@@ -307,43 +464,75 @@
|
|
|
307
464
|
{/if}
|
|
308
465
|
</div>
|
|
309
466
|
|
|
310
|
-
{#if showFilters}
|
|
311
|
-
<!--
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
bind:value={searchInput}
|
|
321
|
-
placeholder="Search task or ID..."
|
|
322
|
-
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"
|
|
323
|
-
/>
|
|
324
|
-
</div>
|
|
325
|
-
<div class="relative">
|
|
326
|
-
<select
|
|
327
|
-
bind:value={filters.status}
|
|
328
|
-
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"
|
|
467
|
+
{#if showFilters && filterChips.length > 0}
|
|
468
|
+
<!-- Active-filter chips. Rendered only when filters are set, so the
|
|
469
|
+
header stays clean when empty; each chip's X clears its dimension.
|
|
470
|
+
The filter controls themselves live in the popover above. -->
|
|
471
|
+
<div
|
|
472
|
+
class="flex shrink-0 flex-wrap gap-1 border-b border-outline-faint bg-surface-sunken px-3 py-2"
|
|
473
|
+
>
|
|
474
|
+
{#each filterChips as chip (chip.dimension)}
|
|
475
|
+
<span
|
|
476
|
+
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"
|
|
329
477
|
>
|
|
330
|
-
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
</div>
|
|
478
|
+
{chip.label}
|
|
479
|
+
<button
|
|
480
|
+
type="button"
|
|
481
|
+
onclick={() => removeChip(chip.dimension)}
|
|
482
|
+
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"
|
|
483
|
+
aria-label="Remove {chip.label} filter"
|
|
484
|
+
>
|
|
485
|
+
<X size={11} />
|
|
486
|
+
</button>
|
|
487
|
+
</span>
|
|
488
|
+
{/each}
|
|
342
489
|
</div>
|
|
343
490
|
{/if}
|
|
344
491
|
|
|
345
492
|
<div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
|
|
346
|
-
{#if
|
|
493
|
+
{#if outputSearchActive}
|
|
494
|
+
<!-- Output-search results: the rail filters to runs that printed the
|
|
495
|
+
query, each annotated with the matching line. -->
|
|
496
|
+
{#if outputSearchPending}
|
|
497
|
+
<!-- Searching: shimmer placeholders shaped like result rows. -->
|
|
498
|
+
<div class="flex flex-col gap-0.5" aria-busy="true" aria-label="Searching output">
|
|
499
|
+
{#each [0, 1, 2, 3, 4] as i (i)}
|
|
500
|
+
<div class="flex items-center gap-2.5 rounded-[10px] px-3 py-2.5">
|
|
501
|
+
<span
|
|
502
|
+
class="size-[9px] shrink-0 animate-pulse rounded-full bg-surface-sunken"
|
|
503
|
+
></span>
|
|
504
|
+
<span
|
|
505
|
+
class="h-3 animate-pulse rounded bg-surface-sunken"
|
|
506
|
+
style:width="{70 - i * 8}%"
|
|
507
|
+
></span>
|
|
508
|
+
<span class="ml-auto h-3 w-9 animate-pulse rounded bg-surface-sunken"
|
|
509
|
+
></span>
|
|
510
|
+
</div>
|
|
511
|
+
{/each}
|
|
512
|
+
</div>
|
|
513
|
+
{:else if matchedRuns.length === 0}
|
|
514
|
+
<div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
|
|
515
|
+
No output matches
|
|
516
|
+
<b class="text-on-surface">“{outputQuery.trim()}”</b>.<br />
|
|
517
|
+
Try a different term.
|
|
518
|
+
</div>
|
|
519
|
+
{:else}
|
|
520
|
+
<div class="flex flex-col gap-0.5">
|
|
521
|
+
{#each matchedRuns as run (run.id)}
|
|
522
|
+
<div class="group/row relative flex items-stretch gap-1">
|
|
523
|
+
{#if bulkActions}
|
|
524
|
+
{@render rowCheckboxOverlay(run)}
|
|
525
|
+
{/if}
|
|
526
|
+
{@render runRowButton(
|
|
527
|
+
run,
|
|
528
|
+
selectedRunId === run.id,
|
|
529
|
+
outputMatches?.get(run.id),
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
{/each}
|
|
533
|
+
</div>
|
|
534
|
+
{/if}
|
|
535
|
+
{:else if items.length === 0 && !loading}
|
|
347
536
|
<EmptyState
|
|
348
537
|
title={emptyText}
|
|
349
538
|
description={emptyDescription}
|
|
@@ -360,13 +549,8 @@
|
|
|
360
549
|
{#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
|
|
361
550
|
{@const run = items[row.index]}
|
|
362
551
|
{#if run}
|
|
363
|
-
{@const isActive = selectedRunId === run.id}
|
|
364
|
-
{@const isChecked = isRowSelected(run.id)}
|
|
365
|
-
{@const config = getRunStatusConfig(runDisplayStatus(run))}
|
|
366
|
-
{@const Icon = config.icon}
|
|
367
|
-
{@const duration = runDuration(run)}
|
|
368
552
|
<div
|
|
369
|
-
class="flex items-
|
|
553
|
+
class="group/row flex items-center gap-1"
|
|
370
554
|
style:position="absolute"
|
|
371
555
|
style:top="0"
|
|
372
556
|
style:left="0"
|
|
@@ -375,139 +559,9 @@
|
|
|
375
559
|
style:transform="translateY({row.start}px)"
|
|
376
560
|
>
|
|
377
561
|
{#if bulkActions}
|
|
378
|
-
|
|
379
|
-
class="flex shrink-0 cursor-pointer items-center px-1.5"
|
|
380
|
-
aria-label={`Select run ${formatShortId(run.id)}`}
|
|
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>
|
|
562
|
+
{@render rowCheckboxOverlay(run)}
|
|
390
563
|
{/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
|
-
>
|
|
425
|
-
{formatRelativeTime(run.start_at || run.created_at)}
|
|
426
|
-
</span>
|
|
427
|
-
</div>
|
|
428
|
-
|
|
429
|
-
<div class="flex items-center justify-between pl-6 text-xs">
|
|
430
|
-
<div
|
|
431
|
-
class="flex items-center gap-1 font-mono text-2xs text-on-surface-muted"
|
|
432
|
-
>
|
|
433
|
-
#{formatShortId(run.id)}
|
|
434
|
-
</div>
|
|
435
|
-
{#if duration}
|
|
436
|
-
<div
|
|
437
|
-
class="flex items-center gap-1 text-on-surface-faint {isActive
|
|
438
|
-
? 'text-primary'
|
|
439
|
-
: ''}"
|
|
440
|
-
>
|
|
441
|
-
<Timer size={10} />
|
|
442
|
-
<span class="font-mono">{duration}</span>
|
|
443
|
-
</div>
|
|
444
|
-
{/if}
|
|
445
|
-
</div>
|
|
446
|
-
{:else}
|
|
447
|
-
<!-- Task variant: shortId as primary, status secondary -->
|
|
448
|
-
<div class="mb-1.5 flex items-center justify-between">
|
|
449
|
-
<div class="flex items-center gap-2">
|
|
450
|
-
<div class={config.color}>
|
|
451
|
-
<Icon
|
|
452
|
-
size={16}
|
|
453
|
-
class={run.status === "running"
|
|
454
|
-
? "animate-spin"
|
|
455
|
-
: ""}
|
|
456
|
-
/>
|
|
457
|
-
</div>
|
|
458
|
-
<span
|
|
459
|
-
class="font-mono text-xs font-medium text-on-surface"
|
|
460
|
-
>
|
|
461
|
-
#{formatShortId(run.id)}
|
|
462
|
-
</span>
|
|
463
|
-
</div>
|
|
464
|
-
<span
|
|
465
|
-
class="text-2xs {isActive
|
|
466
|
-
? 'font-medium text-primary'
|
|
467
|
-
: 'text-on-surface-faint'}"
|
|
468
|
-
>
|
|
469
|
-
{formatRelativeTime(run.start_at || run.created_at)}
|
|
470
|
-
</span>
|
|
471
|
-
</div>
|
|
472
|
-
|
|
473
|
-
<div class="flex items-center justify-between text-xs">
|
|
474
|
-
<div class="flex items-center gap-2 text-on-surface-muted">
|
|
475
|
-
<Tooltip
|
|
476
|
-
content={config.description}
|
|
477
|
-
position="right"
|
|
478
|
-
wide
|
|
479
|
-
>
|
|
480
|
-
<span class="capitalize"
|
|
481
|
-
>{runDisplayStatus(run)}</span
|
|
482
|
-
>
|
|
483
|
-
</Tooltip>
|
|
484
|
-
{#if run.instance_index > 0}
|
|
485
|
-
<span
|
|
486
|
-
class="font-mono text-2xs text-on-surface-faint"
|
|
487
|
-
>instance #{run.instance_index}</span
|
|
488
|
-
>
|
|
489
|
-
{/if}
|
|
490
|
-
</div>
|
|
491
|
-
{#if duration}
|
|
492
|
-
<div
|
|
493
|
-
class="flex items-center gap-1 text-on-surface-faint {isActive
|
|
494
|
-
? 'text-primary'
|
|
495
|
-
: ''}"
|
|
496
|
-
>
|
|
497
|
-
<Timer size={10} />
|
|
498
|
-
<span class="font-mono">{duration}</span>
|
|
499
|
-
</div>
|
|
500
|
-
{/if}
|
|
501
|
-
</div>
|
|
502
|
-
{/if}
|
|
503
|
-
|
|
504
|
-
<div
|
|
505
|
-
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
|
|
506
|
-
? 'opacity-100'
|
|
507
|
-
: 'opacity-0'}"
|
|
508
|
-
aria-hidden="true"
|
|
509
|
-
></div>
|
|
510
|
-
</button>
|
|
564
|
+
{@render runRowButton(run, selectedRunId === run.id, undefined)}
|
|
511
565
|
</div>
|
|
512
566
|
{/if}
|
|
513
567
|
{/each}
|
|
@@ -515,3 +569,148 @@
|
|
|
515
569
|
{/if}
|
|
516
570
|
</div>
|
|
517
571
|
</div>
|
|
572
|
+
|
|
573
|
+
<!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
|
|
574
|
+
selection exists — so the row checkbox can take its place over it. -->
|
|
575
|
+
{#snippet statusDot(colorClass: string, running: boolean)}
|
|
576
|
+
<span
|
|
577
|
+
class="{colorClass} size-[9px] shrink-0 rounded-full bg-current ring-[3px] ring-current/20 {running
|
|
578
|
+
? 'animate-pulse'
|
|
579
|
+
: ''} {bulkActions
|
|
580
|
+
? selectionActive
|
|
581
|
+
? 'opacity-0'
|
|
582
|
+
: 'transition-opacity group-hover/row:opacity-0'
|
|
583
|
+
: ''}"
|
|
584
|
+
aria-hidden="true"
|
|
585
|
+
></span>
|
|
586
|
+
{/snippet}
|
|
587
|
+
|
|
588
|
+
<!-- Row checkbox: sits over the status dot (which fades out beneath it) so the
|
|
589
|
+
row keeps the artifact's geometry. Lives in the row wrapper (not the button)
|
|
590
|
+
to keep the markup valid, positioned to land on the dot. The 14px box is
|
|
591
|
+
wrapped in a 28px label so the hover/click hitbox is comfortable without
|
|
592
|
+
enlarging the visible checkbox. Used by both the task rail and the
|
|
593
|
+
cross-task /runs grid so selection behaves identically in each. -->
|
|
594
|
+
{#snippet rowCheckboxOverlay(run: Run)}
|
|
595
|
+
<label
|
|
596
|
+
class="absolute top-1/2 left-[2px] z-10 flex size-7 -translate-y-1/2 cursor-pointer items-center justify-center"
|
|
597
|
+
>
|
|
598
|
+
<input
|
|
599
|
+
type="checkbox"
|
|
600
|
+
checked={isRowSelected(run.id)}
|
|
601
|
+
onchange={() => toggleRow(run.id)}
|
|
602
|
+
onclick={(e) => e.stopPropagation()}
|
|
603
|
+
aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
|
|
604
|
+
class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 transition-opacity {selectionActive
|
|
605
|
+
? 'opacity-100'
|
|
606
|
+
: 'group-hover/row:opacity-100'}"
|
|
607
|
+
/>
|
|
608
|
+
</label>
|
|
609
|
+
{/snippet}
|
|
610
|
+
|
|
611
|
+
{#snippet runRowButton(run: Run, isActive: boolean, match: RunOutputMatch | undefined)}
|
|
612
|
+
{@const dstatus = runDisplayStatus(run)}
|
|
613
|
+
{@const config = getRunStatusConfig(dstatus)}
|
|
614
|
+
{@const running = run.status === "running"}
|
|
615
|
+
{@const spine = config.dot.replace(" animate-pulse", "")}
|
|
616
|
+
{@const startedAt = run.start_at ?? run.created_at}
|
|
617
|
+
{@const retry = runRetryLabel(run)}
|
|
618
|
+
{@const suffix = instanceSuffix(run.instance_index, getInstanceCount(run.task_name))}
|
|
619
|
+
<button
|
|
620
|
+
class="btn-scale group relative w-full rounded-[10px] border text-left transition-all select-none {showTaskName
|
|
621
|
+
? 'p-3'
|
|
622
|
+
: 'px-3 py-[11px]'} {isActive
|
|
623
|
+
? 'border-outline bg-surface-raised shadow-sm'
|
|
624
|
+
: 'border-transparent hover:bg-surface-sunken'}"
|
|
625
|
+
onclick={() => selectRun(run.id)}
|
|
626
|
+
onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
|
|
627
|
+
>
|
|
628
|
+
{#if showTaskName}
|
|
629
|
+
<!-- Cross-task /runs variant: the same readout language as the task
|
|
630
|
+
rail — status dot, status-colored outcome, mono right readout —
|
|
631
|
+
with the task name carried as the primary. -->
|
|
632
|
+
<div class="flex items-center gap-2.5">
|
|
633
|
+
{@render statusDot(config.color, running)}
|
|
634
|
+
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
635
|
+
<span class="flex items-center gap-1.5">
|
|
636
|
+
<span class="truncate text-[13px] font-semibold text-on-surface">
|
|
637
|
+
{run.task_name}{#if suffix}<span class="text-on-surface-muted"
|
|
638
|
+
>{suffix}</span
|
|
639
|
+
>{/if}
|
|
640
|
+
</span>
|
|
641
|
+
<span class="shrink-0 text-on-surface-faint">·</span>
|
|
642
|
+
<span class="shrink-0 text-[12px] font-semibold capitalize {config.color}"
|
|
643
|
+
>{dstatus}</span
|
|
644
|
+
>
|
|
645
|
+
</span>
|
|
646
|
+
<span
|
|
647
|
+
class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-faint"
|
|
648
|
+
title={formatFullDateTime(startedAt)}
|
|
649
|
+
>
|
|
650
|
+
<span class="truncate">{formatDateTime(startedAt)}</span>
|
|
651
|
+
<span class="shrink-0">· {formatTriggeredByLabel(run.triggered_by)}</span>
|
|
652
|
+
{#if retry}
|
|
653
|
+
<span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
|
|
654
|
+
>{retry}</span
|
|
655
|
+
>
|
|
656
|
+
{/if}
|
|
657
|
+
</span>
|
|
658
|
+
</span>
|
|
659
|
+
<span
|
|
660
|
+
class="shrink-0 self-start pt-0.5 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
|
|
661
|
+
title={retry ?? undefined}
|
|
662
|
+
>
|
|
663
|
+
{rowRightLabel(run, dstatus)}
|
|
664
|
+
</span>
|
|
665
|
+
</div>
|
|
666
|
+
{:else}
|
|
667
|
+
<!-- Task-rail variant (artifact ".run"): a single dense line —
|
|
668
|
+
time · date · outcome, with a mono exit/duration readout.
|
|
669
|
+
leading-tight matches the artifact's ~1.2 line-height so the
|
|
670
|
+
(descender-less) text optically centers instead of riding high
|
|
671
|
+
inside Tailwind's default 1.5 line box. -->
|
|
672
|
+
<div class="flex items-center gap-[11px] leading-tight">
|
|
673
|
+
{@render statusDot(config.color, running)}
|
|
674
|
+
<span class="flex min-w-0 flex-1 items-center gap-1.5 truncate">
|
|
675
|
+
<span
|
|
676
|
+
class="text-[12.5px] font-semibold tracking-tight text-on-surface"
|
|
677
|
+
title={formatFullDateTime(startedAt)}
|
|
678
|
+
>
|
|
679
|
+
{formatTimeHM(startedAt)} · {formatDayMonth(startedAt)}
|
|
680
|
+
</span>
|
|
681
|
+
<span class="text-on-surface-faint">·</span>
|
|
682
|
+
<span class="text-[12.5px] font-semibold capitalize {config.color}"
|
|
683
|
+
>{dstatus}</span
|
|
684
|
+
>
|
|
685
|
+
{#if suffix}
|
|
686
|
+
<span class="font-mono text-2xs text-on-surface-faint">{suffix}</span>
|
|
687
|
+
{/if}
|
|
688
|
+
</span>
|
|
689
|
+
<span
|
|
690
|
+
class="shrink-0 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
|
|
691
|
+
title={retry ?? undefined}
|
|
692
|
+
>
|
|
693
|
+
{rowRightLabel(run, dstatus)}
|
|
694
|
+
</span>
|
|
695
|
+
</div>
|
|
696
|
+
{#if match}
|
|
697
|
+
{@const hl = highlightParts(match.text, outputQuery)}
|
|
698
|
+
<div
|
|
699
|
+
class="mt-1.5 truncate rounded-md bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
|
|
700
|
+
>
|
|
701
|
+
{hl.before}<mark
|
|
702
|
+
class="rounded-sm bg-primary-soft px-0.5 text-primary-soft-text"
|
|
703
|
+
>{hl.match}</mark
|
|
704
|
+
>{hl.after}
|
|
705
|
+
</div>
|
|
706
|
+
{/if}
|
|
707
|
+
{/if}
|
|
708
|
+
|
|
709
|
+
<div
|
|
710
|
+
class="duration-normal absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] transition-all {spine} {isActive
|
|
711
|
+
? 'opacity-100'
|
|
712
|
+
: 'opacity-0'}"
|
|
713
|
+
aria-hidden="true"
|
|
714
|
+
></div>
|
|
715
|
+
</button>
|
|
716
|
+
{/snippet}
|