@runwisp/ui 0.4.0 → 0.6.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 +8 -16
- package/src/app.html +1 -0
- package/src/lib/actions/portal.ts +1 -3
- package/src/lib/components/AccordionItem.svelte +2 -4
- package/src/lib/components/Badge.svelte +4 -8
- package/src/lib/components/Card.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +2 -3
- package/src/lib/components/Dropdown.svelte +3 -3
- package/src/lib/components/DurationInput.svelte +2 -2
- package/src/lib/components/Heading.svelte +1 -2
- package/src/lib/components/LogConsole.svelte +6 -5
- package/src/lib/components/PageHeader.svelte +29 -0
- package/src/lib/components/Pagination.svelte +2 -2
- package/src/lib/components/Popover.svelte +3 -3
- package/src/lib/components/Select.svelte +6 -10
- package/src/lib/components/TaskCard.svelte +33 -0
- package/src/lib/components/ToastContainer.svelte +12 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +36 -9
- package/src/lib/components/dashboard/RunsList.svelte +412 -243
- package/src/lib/components/dashboard/run-helpers.test.ts +37 -0
- package/src/lib/components/dashboard/run-helpers.ts +11 -0
- package/src/lib/components/dashboard/status-config.ts +1 -2
- package/src/lib/index.ts +9 -21
- package/src/lib/log-console/LogFetcher.svelte.ts +4 -4
- package/src/lib/log-console/types.ts +1 -4
- package/src/lib/theme-tokens.css +1 -3
- package/src/lib/utils/format.test.ts +88 -0
- package/src/lib/utils/toast.svelte.ts +25 -11
|
@@ -1,331 +1,500 @@
|
|
|
1
1
|
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
2
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
3
|
|
|
4
|
+
<script lang="ts" module>
|
|
5
|
+
export type RunsListSortDirection = "asc" | "desc" | "";
|
|
6
|
+
|
|
7
|
+
export interface RunsListFilters {
|
|
8
|
+
search: string;
|
|
9
|
+
status: string;
|
|
10
|
+
sort_direction: RunsListSortDirection;
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
4
14
|
<script lang="ts">
|
|
5
15
|
import {
|
|
6
16
|
Clock,
|
|
7
17
|
Timer,
|
|
8
|
-
ChevronLeft,
|
|
9
|
-
ChevronRight,
|
|
10
18
|
ArrowUpDown,
|
|
11
19
|
Search,
|
|
12
20
|
Funnel,
|
|
21
|
+
Square,
|
|
22
|
+
Trash2,
|
|
23
|
+
RotateCw,
|
|
13
24
|
} from "@lucide/svelte";
|
|
25
|
+
import { untrack } from "svelte";
|
|
26
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
27
|
+
import { createVirtualizer } from "@tanstack/svelte-virtual";
|
|
14
28
|
import Button from "../Button.svelte";
|
|
15
29
|
import Badge from "../Badge.svelte";
|
|
30
|
+
import EmptyState from "../EmptyState.svelte";
|
|
16
31
|
import type { Run } from "./types.js";
|
|
17
|
-
import type {
|
|
32
|
+
import type { RunSelector } from "@runwisp/common";
|
|
18
33
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
19
34
|
import { runDuration } from "./run-helpers.js";
|
|
20
35
|
import { formatRelativeTime } from "../../utils/format.js";
|
|
21
36
|
import { formatShortId } from "../../utils/id.js";
|
|
22
37
|
|
|
38
|
+
type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
|
|
39
|
+
|
|
23
40
|
let {
|
|
24
|
-
|
|
41
|
+
items,
|
|
42
|
+
total,
|
|
43
|
+
loading = false,
|
|
44
|
+
filters = $bindable(),
|
|
45
|
+
onLoadMore,
|
|
25
46
|
selectedRunId = $bindable(null),
|
|
26
47
|
onselect,
|
|
27
48
|
showFilters = false,
|
|
28
49
|
showTaskName = false,
|
|
50
|
+
taskNameFilter,
|
|
29
51
|
headerLabel = "Run History",
|
|
30
52
|
emptyText = "No runs yet",
|
|
53
|
+
bulkActions = false,
|
|
54
|
+
onBulkCancel,
|
|
55
|
+
onBulkDelete,
|
|
56
|
+
onBulkRerun,
|
|
31
57
|
}: {
|
|
32
|
-
|
|
58
|
+
items: Run[];
|
|
59
|
+
total: number;
|
|
60
|
+
loading?: boolean;
|
|
61
|
+
filters: RunsListFilters;
|
|
62
|
+
onLoadMore?: () => void;
|
|
33
63
|
selectedRunId?: string | null;
|
|
34
64
|
onselect?: (runId: string) => void;
|
|
35
65
|
showFilters?: boolean;
|
|
36
66
|
showTaskName?: boolean;
|
|
67
|
+
taskNameFilter?: string;
|
|
37
68
|
headerLabel?: string;
|
|
38
69
|
emptyText?: string;
|
|
70
|
+
bulkActions?: boolean;
|
|
71
|
+
onBulkCancel?: BulkHandler;
|
|
72
|
+
onBulkDelete?: BulkHandler;
|
|
73
|
+
onBulkRerun?: BulkHandler;
|
|
39
74
|
} = $props();
|
|
40
75
|
|
|
76
|
+
const ROW_HEIGHT = 76;
|
|
77
|
+
const OVERSCAN = 8;
|
|
78
|
+
const LOAD_AHEAD = 10;
|
|
79
|
+
const SEARCH_DEBOUNCE_MS = 250;
|
|
80
|
+
|
|
81
|
+
// Selection model: two modes —
|
|
82
|
+
// 1. explicit: user picked specific rows. explicitIds holds them.
|
|
83
|
+
// 2. selectAll: "all matching the current filter". exceptIds holds opt-outs.
|
|
84
|
+
let selectAllMode = $state(false);
|
|
85
|
+
const explicitIds = new SvelteSet<string>();
|
|
86
|
+
const exceptIds = new SvelteSet<string>();
|
|
87
|
+
|
|
88
|
+
let scrollElement: HTMLDivElement | undefined = $state();
|
|
89
|
+
let searchInput = $state(filters.search);
|
|
90
|
+
|
|
91
|
+
// Debounce typing in the search box before pushing into shared filters,
|
|
92
|
+
// so we don't fire a network request on every keystroke.
|
|
93
|
+
$effect(() => {
|
|
94
|
+
const next = searchInput;
|
|
95
|
+
const id = setTimeout(() => {
|
|
96
|
+
if (filters.search !== next) filters.search = next;
|
|
97
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
98
|
+
return () => clearTimeout(id);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
|
102
|
+
count: 0,
|
|
103
|
+
getScrollElement: () => scrollElement ?? null,
|
|
104
|
+
estimateSize: () => ROW_HEIGHT,
|
|
105
|
+
overscan: OVERSCAN,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// `setOptions` always notifies the store (see @tanstack/svelte-virtual src),
|
|
109
|
+
// so reading $virtualizer here would loop. Untrack the store read.
|
|
110
|
+
$effect(() => {
|
|
111
|
+
const count = items.length;
|
|
112
|
+
untrack(() => $virtualizer.setOptions({ count }));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
$effect(() => {
|
|
116
|
+
const visible = $virtualizer.getVirtualItems();
|
|
117
|
+
const last = visible.at(-1);
|
|
118
|
+
if (!last) return;
|
|
119
|
+
if (loading) return;
|
|
120
|
+
if (items.length >= total) return;
|
|
121
|
+
if (last.index >= items.length - LOAD_AHEAD) onLoadMore?.();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function isRowSelected(id: string): boolean {
|
|
125
|
+
return selectAllMode ? !exceptIds.has(id) : explicitIds.has(id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toggleRow(id: string) {
|
|
129
|
+
if (selectAllMode) {
|
|
130
|
+
if (exceptIds.has(id)) exceptIds.delete(id);
|
|
131
|
+
else exceptIds.add(id);
|
|
132
|
+
} else {
|
|
133
|
+
if (explicitIds.has(id)) explicitIds.delete(id);
|
|
134
|
+
else explicitIds.add(id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function clearSelection() {
|
|
139
|
+
selectAllMode = false;
|
|
140
|
+
explicitIds.clear();
|
|
141
|
+
exceptIds.clear();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function selectAllVisible() {
|
|
145
|
+
selectAllMode = true;
|
|
146
|
+
explicitIds.clear();
|
|
147
|
+
exceptIds.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
41
150
|
function selectRun(runId: string) {
|
|
42
151
|
selectedRunId = runId;
|
|
43
152
|
onselect?.(runId);
|
|
44
153
|
}
|
|
45
154
|
|
|
46
|
-
let
|
|
47
|
-
let
|
|
48
|
-
let
|
|
49
|
-
let
|
|
50
|
-
const pageSize = 10;
|
|
155
|
+
let selectedRuns = $derived(items.filter((r: Run) => isRowSelected(r.id)));
|
|
156
|
+
let selectionCount = $derived(selectedRuns.length);
|
|
157
|
+
let hasSelection = $derived(selectionCount > 0);
|
|
158
|
+
let allSelected = $derived(selectAllMode && exceptIds.size === 0);
|
|
51
159
|
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (statusFilter !== "all" && runDisplayStatus(r) !== statusFilter) return false;
|
|
56
|
-
if (searchQuery.trim()) {
|
|
57
|
-
const q = searchQuery.toLowerCase();
|
|
58
|
-
return (
|
|
59
|
-
r.id.toLowerCase().includes(q) || r.task_name.toLowerCase().includes(q)
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
return true;
|
|
63
|
-
})
|
|
64
|
-
: runs,
|
|
160
|
+
let anyRunning = $derived(selectedRuns.some((r: Run) => r.status === "running"));
|
|
161
|
+
let anyTerminal = $derived(
|
|
162
|
+
selectedRuns.some((r: Run) => r.status !== "running" && r.status !== "pending"),
|
|
65
163
|
);
|
|
66
164
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
165
|
+
function buildSelector(): RunSelector {
|
|
166
|
+
if (!selectAllMode) {
|
|
167
|
+
return { match_all: false, ids: [...explicitIds] };
|
|
168
|
+
}
|
|
169
|
+
const filter: { status?: string; task_name?: string; search?: string } = {};
|
|
170
|
+
if (taskNameFilter) filter.task_name = taskNameFilter;
|
|
171
|
+
if (showFilters) {
|
|
172
|
+
if (filters.status && filters.status !== "all") filter.status = filters.status;
|
|
173
|
+
const query = filters.search.trim();
|
|
174
|
+
if (query) filter.search = query;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
match_all: true,
|
|
178
|
+
filter,
|
|
179
|
+
except_ids: [...exceptIds],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
74
182
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
183
|
+
function emitBulk(handler: BulkHandler | undefined, predicate: (r: Run) => boolean) {
|
|
184
|
+
if (!handler) return;
|
|
185
|
+
const affected = selectedRuns.filter(predicate);
|
|
186
|
+
if (affected.length === 0) return;
|
|
187
|
+
const sel = buildSelector();
|
|
188
|
+
// Narrow the selector to the affected predicate when in explicit mode
|
|
189
|
+
// so we don't ask the server to operate on rows the UI excluded.
|
|
190
|
+
const narrowed: RunSelector = sel.match_all
|
|
191
|
+
? sel
|
|
192
|
+
: { match_all: false, ids: affected.map((r) => r.id) };
|
|
193
|
+
handler(narrowed, affected);
|
|
194
|
+
clearSelection();
|
|
195
|
+
}
|
|
79
196
|
|
|
197
|
+
function handleMasterToggle() {
|
|
198
|
+
if (hasSelection) clearSelection();
|
|
199
|
+
else selectAllVisible();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function toggleSortDirection() {
|
|
203
|
+
filters.sort_direction = filters.sort_direction === "asc" ? "desc" : "asc";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let masterCheckboxRef: HTMLInputElement | undefined = $state();
|
|
80
207
|
$effect(() => {
|
|
81
|
-
if (!
|
|
82
|
-
|
|
83
|
-
if (idx < 0) return;
|
|
84
|
-
const targetPage = Math.floor(idx / pageSize) + 1;
|
|
85
|
-
if (targetPage !== currentPage) currentPage = targetPage;
|
|
208
|
+
if (!masterCheckboxRef) return;
|
|
209
|
+
masterCheckboxRef.indeterminate = hasSelection && !allSelected;
|
|
86
210
|
});
|
|
87
211
|
</script>
|
|
88
212
|
|
|
89
213
|
<div
|
|
90
214
|
class="flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"
|
|
91
215
|
>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
>{filteredRuns.length}</Badge
|
|
104
|
-
>
|
|
105
|
-
</div>
|
|
106
|
-
|
|
107
|
-
<div class="relative">
|
|
108
|
-
<Search
|
|
109
|
-
class="absolute top-1/2 left-2.5 -translate-y-1/2 text-on-surface-faint"
|
|
110
|
-
size={14}
|
|
111
|
-
/>
|
|
216
|
+
<!-- Inline heading: master checkbox, label, selection count or controls -->
|
|
217
|
+
<div
|
|
218
|
+
class="flex shrink-0 items-center gap-2 border-b border-outline-faint px-3 py-2 {hasSelection
|
|
219
|
+
? 'bg-primary-soft/40'
|
|
220
|
+
: 'bg-surface-sunken'}"
|
|
221
|
+
>
|
|
222
|
+
{#if bulkActions}
|
|
223
|
+
<label
|
|
224
|
+
class="flex shrink-0 cursor-pointer items-center"
|
|
225
|
+
title={hasSelection ? "Clear selection" : "Select all"}
|
|
226
|
+
>
|
|
112
227
|
<input
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
228
|
+
bind:this={masterCheckboxRef}
|
|
229
|
+
type="checkbox"
|
|
230
|
+
checked={allSelected}
|
|
231
|
+
onchange={handleMasterToggle}
|
|
232
|
+
class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
|
|
233
|
+
aria-label={hasSelection ? "Clear selection" : "Select all"}
|
|
117
234
|
/>
|
|
118
|
-
</
|
|
235
|
+
</label>
|
|
236
|
+
{/if}
|
|
119
237
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
238
|
+
{#if hasSelection}
|
|
239
|
+
<span class="text-xs font-medium text-on-surface">
|
|
240
|
+
{selectionCount} selected
|
|
241
|
+
</span>
|
|
242
|
+
<div class="ml-auto flex items-center gap-1">
|
|
243
|
+
{#if onBulkRerun}
|
|
244
|
+
<Button
|
|
245
|
+
variant="ghost"
|
|
246
|
+
size="xs"
|
|
247
|
+
class="h-7 w-7 px-0"
|
|
248
|
+
onclick={() => emitBulk(onBulkRerun, () => true)}
|
|
249
|
+
title="Re-run task{selectionCount === 1 ? '' : 's'}"
|
|
125
250
|
>
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
251
|
+
{#snippet icon()}<RotateCw size={14} />{/snippet}
|
|
252
|
+
</Button>
|
|
253
|
+
{/if}
|
|
254
|
+
{#if onBulkCancel && anyRunning}
|
|
255
|
+
<Button
|
|
256
|
+
variant="ghost"
|
|
257
|
+
size="xs"
|
|
258
|
+
class="h-7 w-7 px-0"
|
|
259
|
+
onclick={() => emitBulk(onBulkCancel, (r) => r.status === "running")}
|
|
260
|
+
title="Cancel running run{selectionCount === 1 ? '' : 's'}"
|
|
261
|
+
>
|
|
262
|
+
{#snippet icon()}<Square size={14} />{/snippet}
|
|
263
|
+
</Button>
|
|
264
|
+
{/if}
|
|
265
|
+
{#if onBulkDelete && anyTerminal}
|
|
266
|
+
<Button
|
|
267
|
+
variant="danger"
|
|
268
|
+
size="xs"
|
|
269
|
+
class="h-7 w-7 px-0"
|
|
270
|
+
onclick={() =>
|
|
271
|
+
emitBulk(
|
|
272
|
+
onBulkDelete,
|
|
273
|
+
(r) => r.status !== "running" && r.status !== "pending",
|
|
274
|
+
)}
|
|
275
|
+
title="Delete run{selectionCount === 1 ? '' : 's'}"
|
|
276
|
+
>
|
|
277
|
+
{#snippet icon()}<Trash2 size={14} />{/snippet}
|
|
278
|
+
</Button>
|
|
279
|
+
{/if}
|
|
280
|
+
</div>
|
|
281
|
+
{:else}
|
|
282
|
+
<span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase">
|
|
283
|
+
{headerLabel}
|
|
284
|
+
</span>
|
|
285
|
+
<Badge variant="default" size="sm">{items.length} of {total}</Badge>
|
|
286
|
+
<div class="ml-auto flex items-center gap-1">
|
|
139
287
|
<Button
|
|
140
288
|
variant="ghost"
|
|
141
289
|
size="xs"
|
|
142
290
|
class="h-7 w-7 px-0"
|
|
143
|
-
onclick={
|
|
144
|
-
title="Toggle
|
|
291
|
+
onclick={toggleSortDirection}
|
|
292
|
+
title="Toggle sort order"
|
|
145
293
|
>
|
|
146
294
|
{#snippet icon()}
|
|
147
295
|
<ArrowUpDown
|
|
148
296
|
size={14}
|
|
149
|
-
class="text-on-surface-muted {
|
|
297
|
+
class="text-on-surface-muted {filters.sort_direction === 'asc'
|
|
150
298
|
? 'rotate-180'
|
|
151
299
|
: ''}"
|
|
152
300
|
/>
|
|
153
301
|
{/snippet}
|
|
154
302
|
</Button>
|
|
155
303
|
</div>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
>
|
|
162
|
-
<div class="
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
304
|
+
{/if}
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
{#if showFilters}
|
|
308
|
+
<!-- Filter controls (search + status) -->
|
|
309
|
+
<div class="shrink-0 space-y-2 border-b border-outline-faint bg-surface-sunken px-3 py-2">
|
|
310
|
+
<div class="relative">
|
|
311
|
+
<Search
|
|
312
|
+
class="absolute top-1/2 left-2.5 -translate-y-1/2 text-on-surface-faint"
|
|
313
|
+
size={14}
|
|
314
|
+
/>
|
|
315
|
+
<input
|
|
316
|
+
type="text"
|
|
317
|
+
bind:value={searchInput}
|
|
318
|
+
placeholder="Search task or ID..."
|
|
319
|
+
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"
|
|
320
|
+
/>
|
|
167
321
|
</div>
|
|
168
|
-
<div class="
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
onclick={() => (sortDirection = sortDirection === "desc" ? "asc" : "desc")}
|
|
173
|
-
title="Sort by Date"
|
|
322
|
+
<div class="relative">
|
|
323
|
+
<select
|
|
324
|
+
bind:value={filters.status}
|
|
325
|
+
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"
|
|
174
326
|
>
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
327
|
+
<option value="all">All Statuses</option>
|
|
328
|
+
<option value="running">Running</option>
|
|
329
|
+
<option value="success">Success</option>
|
|
330
|
+
<option value="failed">Failed</option>
|
|
331
|
+
<option value="crashed">Crashed</option>
|
|
332
|
+
<option value="skipped">Skipped</option>
|
|
333
|
+
</select>
|
|
334
|
+
<Funnel
|
|
335
|
+
class="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-on-surface-faint"
|
|
336
|
+
size={12}
|
|
337
|
+
/>
|
|
184
338
|
</div>
|
|
185
339
|
</div>
|
|
186
340
|
{/if}
|
|
187
341
|
|
|
188
|
-
<div class="min-h-0 flex-1
|
|
189
|
-
{#
|
|
190
|
-
{
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
{isActive
|
|
197
|
-
? 'border-primary-soft bg-primary-soft/50 shadow-sm'
|
|
198
|
-
: 'border-transparent bg-surface-raised hover:border-outline hover:bg-surface-sunken'}"
|
|
199
|
-
onclick={() => selectRun(run.id)}
|
|
200
|
-
onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
|
|
342
|
+
<div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
|
|
343
|
+
{#if items.length === 0 && !loading}
|
|
344
|
+
<EmptyState title={emptyText} icon={Clock} iconSize={32} class="py-8" />
|
|
345
|
+
{:else}
|
|
346
|
+
<div
|
|
347
|
+
style:height="{$virtualizer.getTotalSize()}px"
|
|
348
|
+
style:width="100%"
|
|
349
|
+
style:position="relative"
|
|
201
350
|
>
|
|
202
|
-
{#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
/>
|
|
211
|
-
</div>
|
|
212
|
-
<span class="truncate text-sm font-semibold text-on-surface">
|
|
213
|
-
{run.task_name}{#if run.instance_index > 0}<span
|
|
214
|
-
class="text-on-surface-muted">#{run.instance_index}</span
|
|
215
|
-
>{/if}
|
|
216
|
-
</span>
|
|
217
|
-
</div>
|
|
218
|
-
<span
|
|
219
|
-
class="shrink-0 text-[10px] {isActive
|
|
220
|
-
? 'font-medium text-primary'
|
|
221
|
-
: 'text-on-surface-faint'}"
|
|
222
|
-
>
|
|
223
|
-
{formatRelativeTime(run.start_at || run.created_at)}
|
|
224
|
-
</span>
|
|
225
|
-
</div>
|
|
226
|
-
|
|
227
|
-
<div class="flex items-center justify-between pl-6 text-xs">
|
|
351
|
+
{#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
|
|
352
|
+
{@const run = items[row.index]}
|
|
353
|
+
{#if run}
|
|
354
|
+
{@const isActive = selectedRunId === run.id}
|
|
355
|
+
{@const isChecked = isRowSelected(run.id)}
|
|
356
|
+
{@const config = getRunStatusConfig(runDisplayStatus(run))}
|
|
357
|
+
{@const Icon = config.icon}
|
|
358
|
+
{@const duration = runDuration(run)}
|
|
228
359
|
<div
|
|
229
|
-
class="flex items-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
? 'text-primary'
|
|
237
|
-
: ''}"
|
|
238
|
-
>
|
|
239
|
-
<Timer size={10} />
|
|
240
|
-
<span class="font-mono">{duration}</span>
|
|
241
|
-
</div>
|
|
242
|
-
{/if}
|
|
243
|
-
</div>
|
|
244
|
-
{:else}
|
|
245
|
-
<!-- Task variant: shortId as primary, status secondary -->
|
|
246
|
-
<div class="mb-1.5 flex items-center justify-between">
|
|
247
|
-
<div class="flex items-center gap-2">
|
|
248
|
-
<div class={config.color}>
|
|
249
|
-
<Icon
|
|
250
|
-
size={16}
|
|
251
|
-
class={run.status === "running" ? "animate-spin" : ""}
|
|
252
|
-
/>
|
|
253
|
-
</div>
|
|
254
|
-
<span class="font-mono text-xs font-medium text-on-surface">
|
|
255
|
-
#{formatShortId(run.id)}
|
|
256
|
-
</span>
|
|
257
|
-
</div>
|
|
258
|
-
<span
|
|
259
|
-
class="text-[10px] {isActive
|
|
260
|
-
? 'font-medium text-primary'
|
|
261
|
-
: 'text-on-surface-faint'}"
|
|
360
|
+
class="flex items-stretch gap-1"
|
|
361
|
+
style:position="absolute"
|
|
362
|
+
style:top="0"
|
|
363
|
+
style:left="0"
|
|
364
|
+
style:width="100%"
|
|
365
|
+
style:height="{row.size}px"
|
|
366
|
+
style:transform="translateY({row.start}px)"
|
|
262
367
|
>
|
|
263
|
-
{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
<div class="flex items-center justify-between text-xs">
|
|
268
|
-
<div class="flex items-center gap-2 text-on-surface-muted">
|
|
269
|
-
<span class="capitalize">{runDisplayStatus(run)}</span>
|
|
270
|
-
{#if run.instance_index > 0}
|
|
271
|
-
<span class="font-mono text-[11px] text-on-surface-faint"
|
|
272
|
-
>instance #{run.instance_index}</span
|
|
368
|
+
{#if bulkActions}
|
|
369
|
+
<label
|
|
370
|
+
class="flex shrink-0 cursor-pointer items-center px-1.5"
|
|
371
|
+
aria-label={`Select run ${formatShortId(run.id)}`}
|
|
273
372
|
>
|
|
373
|
+
<input
|
|
374
|
+
type="checkbox"
|
|
375
|
+
checked={isChecked}
|
|
376
|
+
onchange={() => toggleRow(run.id)}
|
|
377
|
+
onclick={(e) => e.stopPropagation()}
|
|
378
|
+
class="h-3.5 w-3.5 cursor-pointer rounded border-outline accent-primary"
|
|
379
|
+
/>
|
|
380
|
+
</label>
|
|
274
381
|
{/if}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
382
|
+
<button
|
|
383
|
+
class="btn-scale group duration-normal relative w-full rounded-lg border p-3 text-left transition-all select-none
|
|
384
|
+
{isActive
|
|
385
|
+
? 'border-primary-soft bg-primary-soft/50 shadow-sm'
|
|
386
|
+
: 'border-transparent bg-surface-raised hover:border-outline hover:bg-surface-sunken'}"
|
|
387
|
+
onclick={() => selectRun(run.id)}
|
|
388
|
+
onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
|
|
281
389
|
>
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
390
|
+
{#if showTaskName}
|
|
391
|
+
<!-- Runs variant: task name as primary, shortId secondary -->
|
|
392
|
+
<div class="mb-1 flex items-center justify-between">
|
|
393
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
394
|
+
<div class="{config.color} shrink-0">
|
|
395
|
+
<Icon
|
|
396
|
+
size={16}
|
|
397
|
+
class={run.status === "running"
|
|
398
|
+
? "animate-spin"
|
|
399
|
+
: ""}
|
|
400
|
+
/>
|
|
401
|
+
</div>
|
|
402
|
+
<span
|
|
403
|
+
class="truncate text-sm font-semibold text-on-surface"
|
|
404
|
+
>
|
|
405
|
+
{run.task_name}{#if run.instance_index > 0}<span
|
|
406
|
+
class="text-on-surface-muted"
|
|
407
|
+
>#{run.instance_index}</span
|
|
408
|
+
>{/if}
|
|
409
|
+
</span>
|
|
410
|
+
</div>
|
|
411
|
+
<span
|
|
412
|
+
class="shrink-0 text-2xs {isActive
|
|
413
|
+
? 'font-medium text-primary'
|
|
414
|
+
: 'text-on-surface-faint'}"
|
|
415
|
+
>
|
|
416
|
+
{formatRelativeTime(run.start_at || run.created_at)}
|
|
417
|
+
</span>
|
|
418
|
+
</div>
|
|
288
419
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
420
|
+
<div class="flex items-center justify-between pl-6 text-xs">
|
|
421
|
+
<div
|
|
422
|
+
class="flex items-center gap-1 font-mono text-2xs text-on-surface-muted"
|
|
423
|
+
>
|
|
424
|
+
#{formatShortId(run.id)}
|
|
425
|
+
</div>
|
|
426
|
+
{#if duration}
|
|
427
|
+
<div
|
|
428
|
+
class="flex items-center gap-1 text-on-surface-faint {isActive
|
|
429
|
+
? 'text-primary'
|
|
430
|
+
: ''}"
|
|
431
|
+
>
|
|
432
|
+
<Timer size={10} />
|
|
433
|
+
<span class="font-mono">{duration}</span>
|
|
434
|
+
</div>
|
|
435
|
+
{/if}
|
|
436
|
+
</div>
|
|
437
|
+
{:else}
|
|
438
|
+
<!-- Task variant: shortId as primary, status secondary -->
|
|
439
|
+
<div class="mb-1.5 flex items-center justify-between">
|
|
440
|
+
<div class="flex items-center gap-2">
|
|
441
|
+
<div class={config.color}>
|
|
442
|
+
<Icon
|
|
443
|
+
size={16}
|
|
444
|
+
class={run.status === "running"
|
|
445
|
+
? "animate-spin"
|
|
446
|
+
: ""}
|
|
447
|
+
/>
|
|
448
|
+
</div>
|
|
449
|
+
<span
|
|
450
|
+
class="font-mono text-xs font-medium text-on-surface"
|
|
451
|
+
>
|
|
452
|
+
#{formatShortId(run.id)}
|
|
453
|
+
</span>
|
|
454
|
+
</div>
|
|
455
|
+
<span
|
|
456
|
+
class="text-2xs {isActive
|
|
457
|
+
? 'font-medium text-primary'
|
|
458
|
+
: 'text-on-surface-faint'}"
|
|
459
|
+
>
|
|
460
|
+
{formatRelativeTime(run.start_at || run.created_at)}
|
|
461
|
+
</span>
|
|
462
|
+
</div>
|
|
296
463
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
464
|
+
<div class="flex items-center justify-between text-xs">
|
|
465
|
+
<div class="flex items-center gap-2 text-on-surface-muted">
|
|
466
|
+
<span class="capitalize">{runDisplayStatus(run)}</span>
|
|
467
|
+
{#if run.instance_index > 0}
|
|
468
|
+
<span
|
|
469
|
+
class="font-mono text-2xs text-on-surface-faint"
|
|
470
|
+
>instance #{run.instance_index}</span
|
|
471
|
+
>
|
|
472
|
+
{/if}
|
|
473
|
+
</div>
|
|
474
|
+
{#if duration}
|
|
475
|
+
<div
|
|
476
|
+
class="flex items-center gap-1 text-on-surface-faint {isActive
|
|
477
|
+
? 'text-primary'
|
|
478
|
+
: ''}"
|
|
479
|
+
>
|
|
480
|
+
<Timer size={10} />
|
|
481
|
+
<span class="font-mono">{duration}</span>
|
|
482
|
+
</div>
|
|
483
|
+
{/if}
|
|
484
|
+
</div>
|
|
485
|
+
{/if}
|
|
486
|
+
|
|
487
|
+
<div
|
|
488
|
+
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
|
|
489
|
+
? 'opacity-100'
|
|
490
|
+
: 'opacity-0'}"
|
|
491
|
+
aria-hidden="true"
|
|
492
|
+
></div>
|
|
493
|
+
</button>
|
|
494
|
+
</div>
|
|
495
|
+
{/if}
|
|
496
|
+
{/each}
|
|
303
497
|
</div>
|
|
304
498
|
{/if}
|
|
305
499
|
</div>
|
|
306
|
-
|
|
307
|
-
<!-- Pagination Footer -->
|
|
308
|
-
<div
|
|
309
|
-
class="flex shrink-0 items-center justify-between border-t border-outline-faint bg-surface-sunken p-2"
|
|
310
|
-
>
|
|
311
|
-
<Button
|
|
312
|
-
variant="ghost"
|
|
313
|
-
size="xs"
|
|
314
|
-
disabled={currentPage === 1}
|
|
315
|
-
onclick={() => currentPage--}
|
|
316
|
-
>
|
|
317
|
-
{#snippet icon()}<ChevronLeft size={14} />{/snippet}
|
|
318
|
-
</Button>
|
|
319
|
-
<span class="text-[10px] font-medium tracking-wide text-on-surface-muted uppercase">
|
|
320
|
-
Page {currentPage} of {totalPages}
|
|
321
|
-
</span>
|
|
322
|
-
<Button
|
|
323
|
-
variant="ghost"
|
|
324
|
-
size="xs"
|
|
325
|
-
disabled={currentPage === totalPages}
|
|
326
|
-
onclick={() => currentPage++}
|
|
327
|
-
>
|
|
328
|
-
{#snippet icon()}<ChevronRight size={14} />{/snippet}
|
|
329
|
-
</Button>
|
|
330
|
-
</div>
|
|
331
500
|
</div>
|