@joewinke/jatui 0.22.0 → 0.23.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/CLAUDE.md +41 -1
- package/dist/actions/reveal.d.ts +37 -0
- package/dist/actions/reveal.js +42 -0
- package/dist/components/AudioRecorder.svelte +749 -0
- package/dist/components/AudioRecorder.svelte.d.ts +36 -0
- package/dist/components/IconSelector.svelte +417 -0
- package/dist/components/IconSelector.svelte.d.ts +55 -0
- package/dist/components/InfiniteGrid.svelte +305 -0
- package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
- package/dist/components/InfiniteSearchGrid.svelte +131 -0
- package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
- package/dist/components/KeyboardShortcutsOverlay.svelte +40 -3
- package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +31 -16
- package/dist/components/MediaCaptureIcon.svelte +23 -0
- package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
- package/dist/components/ThumbnailGrid.svelte +137 -0
- package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
- package/dist/components/batch/BatchJobTable.svelte +549 -0
- package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
- package/dist/components/batch/QuotaMeter.svelte +78 -0
- package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
- package/dist/components/batch/batchQueue.d.ts +56 -0
- package/dist/components/batch/batchQueue.js +81 -0
- package/dist/components/batch/elapsedEstimate.d.ts +24 -0
- package/dist/components/batch/elapsedEstimate.js +25 -0
- package/dist/components/batch/pollOperation.d.ts +45 -0
- package/dist/components/batch/pollOperation.js +80 -0
- package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
- package/dist/components/batch/useElapsedEstimate.js +38 -0
- package/dist/components/infiniteGridLoader.d.ts +70 -0
- package/dist/components/infiniteGridLoader.js +141 -0
- package/dist/components/table/columnResize.d.ts +38 -0
- package/dist/components/table/columnResize.js +117 -0
- package/dist/components/table/index.d.ts +2 -0
- package/dist/components/table/index.js +1 -0
- package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
- package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
- package/dist/components/variant/variantCombinations.d.ts +75 -0
- package/dist/components/variant/variantCombinations.js +100 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +25 -0
- package/dist/styles/motion.css +100 -0
- package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
- package/dist/utils/keyboardShortcutRegistry.js +365 -0
- package/package.json +1 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { JobRow } from './batchQueue';
|
|
3
|
+
|
|
4
|
+
export interface JobContext {
|
|
5
|
+
id: string;
|
|
6
|
+
/** 1-based attempt number. */
|
|
7
|
+
attempt: number;
|
|
8
|
+
/** Aborted when the job or the batch is cancelled — pass it to fetch/pollOperation. */
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
/** Report 0..1 progress for the row's bar. */
|
|
11
|
+
reportProgress: (fraction: number) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Per-row controls handed to the cell snippets. */
|
|
15
|
+
export interface RowControls {
|
|
16
|
+
index: number;
|
|
17
|
+
retry: () => void;
|
|
18
|
+
skip: () => void;
|
|
19
|
+
unskip: () => void;
|
|
20
|
+
cancel: () => void;
|
|
21
|
+
moveUp: () => void;
|
|
22
|
+
moveDown: () => void;
|
|
23
|
+
canMoveUp: boolean;
|
|
24
|
+
canMoveDown: boolean;
|
|
25
|
+
/** Live elapsed ms for this row's current/last attempt. */
|
|
26
|
+
elapsedMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type BatchPhase = 'idle' | 'running' | 'paused';
|
|
30
|
+
|
|
31
|
+
/** Batch-level controls handed to the `toolbar` snippet. */
|
|
32
|
+
export interface BatchControls {
|
|
33
|
+
phase: BatchPhase;
|
|
34
|
+
start: () => void;
|
|
35
|
+
pause: () => void;
|
|
36
|
+
cancel: () => Promise<void>;
|
|
37
|
+
retryFailed: () => void;
|
|
38
|
+
etaMs: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type { JobRow };
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<script lang="ts" generics="T">
|
|
45
|
+
/**
|
|
46
|
+
* BatchJobTable (jatui-m4uz3.7) — generic queue runner.
|
|
47
|
+
*
|
|
48
|
+
* Harvested from vid-gen's BatchGenerator: a table of jobs with per-row
|
|
49
|
+
* status / progress / elapsed / retry / skip / reorder, a concurrency cap,
|
|
50
|
+
* an optional per-run process limit (quota budget), a rolling-average ETA,
|
|
51
|
+
* and toast + showConfirm feedback instead of alert()/confirm().
|
|
52
|
+
*
|
|
53
|
+
* The host supplies `jobs` (bind it) and an async `run(data, ctx)`; the
|
|
54
|
+
* component owns the scheduling. Every default cell is a snippet override.
|
|
55
|
+
* Mount <ToastHost /> and <ConfirmDialog /> once in the app root.
|
|
56
|
+
*/
|
|
57
|
+
import { onDestroy, onMount, type Snippet } from 'svelte';
|
|
58
|
+
import { showToast } from '../../stores/toast';
|
|
59
|
+
import { showConfirm } from '../../stores/confirmDialog';
|
|
60
|
+
import {
|
|
61
|
+
formatElapsed,
|
|
62
|
+
isProcessLimitReached,
|
|
63
|
+
moveJob,
|
|
64
|
+
nextJobsToStart,
|
|
65
|
+
summarizeJobs,
|
|
66
|
+
type BatchSummary,
|
|
67
|
+
type JobStatus
|
|
68
|
+
} from './batchQueue';
|
|
69
|
+
import { useElapsedEstimate } from './useElapsedEstimate';
|
|
70
|
+
|
|
71
|
+
interface Props {
|
|
72
|
+
jobs: JobRow<T>[];
|
|
73
|
+
run: (data: T, ctx: JobContext) => Promise<unknown>;
|
|
74
|
+
/** Max jobs running at once. Default 3. */
|
|
75
|
+
concurrencyLimit?: number;
|
|
76
|
+
/** Max jobs to start per run (Start → drained). `null` = unlimited. */
|
|
77
|
+
processLimit?: number | null;
|
|
78
|
+
autoStart?: boolean;
|
|
79
|
+
/** Toast on batch completion / process limit. Default true. */
|
|
80
|
+
notify?: boolean;
|
|
81
|
+
/** showConfirm before cancelling running jobs or clearing rows. Default true. */
|
|
82
|
+
confirmDestructive?: boolean;
|
|
83
|
+
/** Accessible name for the table. */
|
|
84
|
+
caption?: string;
|
|
85
|
+
emptyText?: string;
|
|
86
|
+
/** Extra <th> cells for host data columns (after the # column). */
|
|
87
|
+
headers?: Snippet;
|
|
88
|
+
/** Matching <td> cells for each row. */
|
|
89
|
+
cells?: Snippet<[JobRow<T>, RowControls]>;
|
|
90
|
+
statusCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
91
|
+
progressCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
92
|
+
elapsedCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
93
|
+
actionsCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
94
|
+
/** Extra toolbar content (e.g. a QuotaMeter), right-aligned. */
|
|
95
|
+
toolbar?: Snippet<[BatchControls]>;
|
|
96
|
+
onJobDone?: (job: JobRow<T>) => void;
|
|
97
|
+
onJobFailed?: (job: JobRow<T>, error: unknown) => void;
|
|
98
|
+
onBatchDone?: (summary: BatchSummary) => void;
|
|
99
|
+
class?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let {
|
|
103
|
+
jobs = $bindable(),
|
|
104
|
+
run,
|
|
105
|
+
concurrencyLimit = 3,
|
|
106
|
+
processLimit = null,
|
|
107
|
+
autoStart = false,
|
|
108
|
+
notify = true,
|
|
109
|
+
confirmDestructive = true,
|
|
110
|
+
caption = 'Batch jobs',
|
|
111
|
+
emptyText = 'No jobs in the queue.',
|
|
112
|
+
headers,
|
|
113
|
+
cells,
|
|
114
|
+
statusCell,
|
|
115
|
+
progressCell,
|
|
116
|
+
elapsedCell,
|
|
117
|
+
actionsCell,
|
|
118
|
+
toolbar,
|
|
119
|
+
onJobDone,
|
|
120
|
+
onJobFailed,
|
|
121
|
+
onBatchDone,
|
|
122
|
+
class: className = ''
|
|
123
|
+
}: Props = $props();
|
|
124
|
+
|
|
125
|
+
let phase = $state<BatchPhase>('idle');
|
|
126
|
+
let startedThisRun = 0;
|
|
127
|
+
let settledThisRun = 0;
|
|
128
|
+
const controllers = new Map<string, AbortController>();
|
|
129
|
+
const eta = useElapsedEstimate({ window: 10 });
|
|
130
|
+
|
|
131
|
+
const summary = $derived(summarizeJobs(jobs));
|
|
132
|
+
|
|
133
|
+
// Ticking clock for elapsed/ETA — only while something is running.
|
|
134
|
+
let now = $state(Date.now());
|
|
135
|
+
$effect(() => {
|
|
136
|
+
if (summary.running === 0) return;
|
|
137
|
+
now = Date.now();
|
|
138
|
+
const t = setInterval(() => (now = Date.now()), 500);
|
|
139
|
+
return () => clearInterval(t);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
function elapsedOf(job: JobRow<T>): number {
|
|
143
|
+
if (job.startedAt == null) return 0;
|
|
144
|
+
return (job.finishedAt ?? now) - job.startedAt;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const etaMs = $derived(
|
|
148
|
+
summary.queued + summary.running === 0
|
|
149
|
+
? null
|
|
150
|
+
: eta.remainingMs(
|
|
151
|
+
summary.queued,
|
|
152
|
+
jobs.filter((j) => j.status === 'running').map(elapsedOf),
|
|
153
|
+
concurrencyLimit
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const find = (id: string) => jobs.find((j) => j.id === id);
|
|
158
|
+
|
|
159
|
+
// ── Scheduling ──────────────────────────────────────────────────────────
|
|
160
|
+
function limits() {
|
|
161
|
+
return { concurrencyLimit, processLimit, startedThisRun };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function dispatch() {
|
|
165
|
+
if (phase !== 'running') return;
|
|
166
|
+
const ids = nextJobsToStart(jobs, limits());
|
|
167
|
+
for (const id of ids) void startJob(id);
|
|
168
|
+
if (ids.length === 0 && summarizeJobs(jobs).running === 0) finishRun();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function finishRun() {
|
|
172
|
+
phase = 'idle';
|
|
173
|
+
if (settledThisRun === 0) return;
|
|
174
|
+
const s = summarizeJobs(jobs);
|
|
175
|
+
const limited = isProcessLimitReached(jobs, limits());
|
|
176
|
+
if (notify) {
|
|
177
|
+
if (limited) {
|
|
178
|
+
showToast({
|
|
179
|
+
type: 'warning',
|
|
180
|
+
message: `Process limit of ${processLimit} reached — ${s.queued} job${s.queued === 1 ? '' : 's'} left queued.`
|
|
181
|
+
});
|
|
182
|
+
} else if (s.failed > 0) {
|
|
183
|
+
showToast({ type: 'warning', message: `Batch finished: ${s.done} done, ${s.failed} failed.` });
|
|
184
|
+
} else {
|
|
185
|
+
showToast({ type: 'success', message: `Batch finished: ${s.done} done.` });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
onBatchDone?.(s);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function startJob(id: string) {
|
|
192
|
+
const job = find(id);
|
|
193
|
+
if (!job || job.status !== 'queued') return;
|
|
194
|
+
const ctrl = new AbortController();
|
|
195
|
+
controllers.set(id, ctrl);
|
|
196
|
+
startedThisRun++;
|
|
197
|
+
job.status = 'running';
|
|
198
|
+
job.attempts += 1;
|
|
199
|
+
job.progress = 0;
|
|
200
|
+
job.startedAt = Date.now();
|
|
201
|
+
job.finishedAt = undefined;
|
|
202
|
+
job.error = undefined;
|
|
203
|
+
|
|
204
|
+
const ctx: JobContext = {
|
|
205
|
+
id,
|
|
206
|
+
attempt: job.attempts,
|
|
207
|
+
signal: ctrl.signal,
|
|
208
|
+
reportProgress: (fraction) => {
|
|
209
|
+
const j = find(id);
|
|
210
|
+
if (j && !ctrl.signal.aborted && j.status === 'running') {
|
|
211
|
+
j.progress = Math.min(1, Math.max(0, fraction));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
let outcome: JobStatus;
|
|
217
|
+
let result: unknown;
|
|
218
|
+
let error: unknown;
|
|
219
|
+
try {
|
|
220
|
+
result = await run($state.snapshot(job.data) as T, ctx);
|
|
221
|
+
outcome = ctrl.signal.aborted ? 'cancelled' : 'done';
|
|
222
|
+
} catch (err) {
|
|
223
|
+
error = err;
|
|
224
|
+
outcome = ctrl.signal.aborted ? 'cancelled' : 'failed';
|
|
225
|
+
}
|
|
226
|
+
controllers.delete(id);
|
|
227
|
+
|
|
228
|
+
const j = find(id);
|
|
229
|
+
if (j) {
|
|
230
|
+
j.status = outcome;
|
|
231
|
+
j.finishedAt = Date.now();
|
|
232
|
+
if (outcome === 'done') {
|
|
233
|
+
j.progress = 1;
|
|
234
|
+
j.result = result;
|
|
235
|
+
if (j.startedAt != null) eta.record(j.finishedAt - j.startedAt);
|
|
236
|
+
onJobDone?.(j);
|
|
237
|
+
} else if (outcome === 'failed') {
|
|
238
|
+
j.error = error instanceof Error ? error.message : String(error);
|
|
239
|
+
onJobFailed?.(j, error);
|
|
240
|
+
}
|
|
241
|
+
settledThisRun++;
|
|
242
|
+
}
|
|
243
|
+
dispatch();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Batch controls ──────────────────────────────────────────────────────
|
|
247
|
+
export function start() {
|
|
248
|
+
if (phase === 'running') return;
|
|
249
|
+
if (phase === 'idle') {
|
|
250
|
+
startedThisRun = 0;
|
|
251
|
+
settledThisRun = 0;
|
|
252
|
+
}
|
|
253
|
+
phase = 'running';
|
|
254
|
+
dispatch();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function pause() {
|
|
258
|
+
if (phase === 'running') phase = 'paused';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function cancel() {
|
|
262
|
+
const running = summarizeJobs(jobs).running;
|
|
263
|
+
if (running > 0 && confirmDestructive) {
|
|
264
|
+
const ok = await showConfirm({
|
|
265
|
+
title: 'Cancel batch?',
|
|
266
|
+
body: `${running} running job${running === 1 ? '' : 's'} will be aborted. Queued jobs stay queued.`,
|
|
267
|
+
confirmLabel: 'Cancel batch',
|
|
268
|
+
cancelLabel: 'Keep running',
|
|
269
|
+
danger: true
|
|
270
|
+
});
|
|
271
|
+
if (!ok) return;
|
|
272
|
+
}
|
|
273
|
+
phase = 'idle';
|
|
274
|
+
for (const c of controllers.values()) c.abort();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function retryFailed() {
|
|
278
|
+
let n = 0;
|
|
279
|
+
for (const j of jobs) {
|
|
280
|
+
if (j.status === 'failed' || j.status === 'cancelled') {
|
|
281
|
+
requeue(j);
|
|
282
|
+
n++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (n > 0) dispatch();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function clearFinished() {
|
|
289
|
+
const n = jobs.filter((j) => j.status === 'done' || j.status === 'skipped').length;
|
|
290
|
+
if (n === 0) return;
|
|
291
|
+
if (confirmDestructive) {
|
|
292
|
+
const ok = await showConfirm({
|
|
293
|
+
title: 'Clear finished jobs?',
|
|
294
|
+
body: `Remove ${n} done or skipped row${n === 1 ? '' : 's'} from the table.`,
|
|
295
|
+
confirmLabel: 'Clear',
|
|
296
|
+
danger: true
|
|
297
|
+
});
|
|
298
|
+
if (!ok) return;
|
|
299
|
+
}
|
|
300
|
+
jobs = jobs.filter((j) => j.status !== 'done' && j.status !== 'skipped');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function requeue(j: JobRow<T>) {
|
|
304
|
+
j.status = 'queued';
|
|
305
|
+
j.error = undefined;
|
|
306
|
+
j.progress = undefined;
|
|
307
|
+
j.startedAt = undefined;
|
|
308
|
+
j.finishedAt = undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Row controls ────────────────────────────────────────────────────────
|
|
312
|
+
function controlsFor(job: JobRow<T>, index: number): RowControls {
|
|
313
|
+
return {
|
|
314
|
+
index,
|
|
315
|
+
elapsedMs: elapsedOf(job),
|
|
316
|
+
canMoveUp: job.status === 'queued' && index > 0,
|
|
317
|
+
canMoveDown: job.status === 'queued' && index < jobs.length - 1,
|
|
318
|
+
retry: () => {
|
|
319
|
+
const j = find(job.id);
|
|
320
|
+
if (j && (j.status === 'failed' || j.status === 'cancelled')) {
|
|
321
|
+
requeue(j);
|
|
322
|
+
dispatch();
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
skip: () => {
|
|
326
|
+
const j = find(job.id);
|
|
327
|
+
if (j?.status === 'queued') j.status = 'skipped';
|
|
328
|
+
},
|
|
329
|
+
unskip: () => {
|
|
330
|
+
const j = find(job.id);
|
|
331
|
+
if (j?.status === 'skipped') {
|
|
332
|
+
j.status = 'queued';
|
|
333
|
+
dispatch();
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
cancel: () => controllers.get(job.id)?.abort(),
|
|
337
|
+
moveUp: () => (jobs = moveJob(jobs, job.id, -1)),
|
|
338
|
+
moveDown: () => (jobs = moveJob(jobs, job.id, 1))
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
onMount(() => {
|
|
343
|
+
if (autoStart) start();
|
|
344
|
+
});
|
|
345
|
+
onDestroy(() => {
|
|
346
|
+
for (const c of controllers.values()) c.abort();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const batchControls: BatchControls = {
|
|
350
|
+
get phase() {
|
|
351
|
+
return phase;
|
|
352
|
+
},
|
|
353
|
+
get etaMs() {
|
|
354
|
+
return etaMs;
|
|
355
|
+
},
|
|
356
|
+
start,
|
|
357
|
+
pause,
|
|
358
|
+
cancel,
|
|
359
|
+
retryFailed
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const STATUS_META: Record<JobStatus, { label: string; cls: string }> = {
|
|
363
|
+
queued: { label: 'Queued', cls: 'badge-ghost' },
|
|
364
|
+
running: { label: 'Running', cls: 'badge-info' },
|
|
365
|
+
done: { label: 'Done', cls: 'badge-success' },
|
|
366
|
+
failed: { label: 'Failed', cls: 'badge-error' },
|
|
367
|
+
skipped: { label: 'Skipped', cls: 'badge-neutral' },
|
|
368
|
+
cancelled: { label: 'Cancelled', cls: 'badge-warning' }
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const retryable = $derived(summary.failed + summary.cancelled);
|
|
372
|
+
const clearable = $derived(summary.done + summary.skipped);
|
|
373
|
+
const hasWork = $derived(summary.queued > 0);
|
|
374
|
+
</script>
|
|
375
|
+
|
|
376
|
+
<div class="flex flex-col gap-3 {className}">
|
|
377
|
+
<!-- Toolbar -->
|
|
378
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
379
|
+
{#if phase === 'running'}
|
|
380
|
+
<button type="button" class="btn btn-sm" onclick={pause}>Pause</button>
|
|
381
|
+
{:else}
|
|
382
|
+
<button type="button" class="btn btn-sm btn-primary" disabled={!hasWork} onclick={start}>
|
|
383
|
+
{phase === 'paused' ? 'Resume' : 'Start'}
|
|
384
|
+
</button>
|
|
385
|
+
{/if}
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
class="btn btn-sm btn-ghost"
|
|
389
|
+
disabled={phase === 'idle' && summary.running === 0}
|
|
390
|
+
onclick={cancel}
|
|
391
|
+
>
|
|
392
|
+
Cancel
|
|
393
|
+
</button>
|
|
394
|
+
<button type="button" class="btn btn-sm btn-ghost" disabled={retryable === 0} onclick={retryFailed}>
|
|
395
|
+
Retry failed{retryable ? ` (${retryable})` : ''}
|
|
396
|
+
</button>
|
|
397
|
+
<button type="button" class="btn btn-sm btn-ghost" disabled={clearable === 0} onclick={clearFinished}>
|
|
398
|
+
Clear finished
|
|
399
|
+
</button>
|
|
400
|
+
{#if toolbar}
|
|
401
|
+
<div class="ml-auto">{@render toolbar(batchControls)}</div>
|
|
402
|
+
{/if}
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
<!-- Overall progress -->
|
|
406
|
+
<div class="flex flex-col gap-1">
|
|
407
|
+
<div class="flex flex-wrap items-baseline justify-between gap-2 text-xs" aria-live="polite">
|
|
408
|
+
<span class="tabular-nums">
|
|
409
|
+
<span class="font-medium">{summary.settled} / {summary.total}</span>
|
|
410
|
+
<span class="text-base-content/60">
|
|
411
|
+
· {summary.done} done{#if summary.failed}, <span class="text-error">{summary.failed} failed</span
|
|
412
|
+
>{/if}{#if summary.running}, {summary.running} running{/if}{#if summary.queued}, {summary.queued}
|
|
413
|
+
queued{/if}
|
|
414
|
+
</span>
|
|
415
|
+
</span>
|
|
416
|
+
<span class="text-base-content/60 tabular-nums">
|
|
417
|
+
{#if phase === 'paused'}
|
|
418
|
+
Paused
|
|
419
|
+
{:else if etaMs != null && phase === 'running'}
|
|
420
|
+
ETA {formatElapsed(etaMs)}
|
|
421
|
+
{:else if phase === 'running'}
|
|
422
|
+
Estimating…
|
|
423
|
+
{/if}
|
|
424
|
+
</span>
|
|
425
|
+
</div>
|
|
426
|
+
<progress
|
|
427
|
+
class="progress progress-primary h-1.5 w-full"
|
|
428
|
+
value={summary.fraction}
|
|
429
|
+
max="1"
|
|
430
|
+
aria-label="Batch progress: {summary.settled} of {summary.total} settled"
|
|
431
|
+
></progress>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div class="overflow-x-auto">
|
|
435
|
+
<table class="table table-sm">
|
|
436
|
+
<caption class="sr-only">{caption}</caption>
|
|
437
|
+
<thead>
|
|
438
|
+
<tr>
|
|
439
|
+
<th class="w-8">#</th>
|
|
440
|
+
{@render headers?.()}
|
|
441
|
+
<th>Status</th>
|
|
442
|
+
<th class="w-40">Progress</th>
|
|
443
|
+
<th class="w-20">Elapsed</th>
|
|
444
|
+
<th class="w-px text-right"><span class="sr-only">Actions</span></th>
|
|
445
|
+
</tr>
|
|
446
|
+
</thead>
|
|
447
|
+
<tbody>
|
|
448
|
+
{#each jobs as job, index (job.id)}
|
|
449
|
+
{@const c = controlsFor(job, index)}
|
|
450
|
+
<tr class={job.status === 'skipped' || job.status === 'cancelled' ? 'opacity-60' : ''}>
|
|
451
|
+
<td class="text-base-content/50 tabular-nums">{index + 1}</td>
|
|
452
|
+
{@render cells?.(job, c)}
|
|
453
|
+
<td>
|
|
454
|
+
{#if statusCell}
|
|
455
|
+
{@render statusCell(job, c)}
|
|
456
|
+
{:else}
|
|
457
|
+
<span class="badge badge-sm {STATUS_META[job.status].cls}">
|
|
458
|
+
{STATUS_META[job.status].label}{job.attempts > 1 ? ` · try ${job.attempts}` : ''}
|
|
459
|
+
</span>
|
|
460
|
+
{#if job.error}
|
|
461
|
+
<p class="text-error mt-1 max-w-xs truncate text-xs" title={job.error}>{job.error}</p>
|
|
462
|
+
{/if}
|
|
463
|
+
{/if}
|
|
464
|
+
</td>
|
|
465
|
+
<td>
|
|
466
|
+
{#if progressCell}
|
|
467
|
+
{@render progressCell(job, c)}
|
|
468
|
+
{:else if job.status === 'running'}
|
|
469
|
+
<progress
|
|
470
|
+
class="progress progress-info h-1.5 w-full"
|
|
471
|
+
value={job.progress ?? undefined}
|
|
472
|
+
max="1"
|
|
473
|
+
aria-label="Job {index + 1} progress"
|
|
474
|
+
></progress>
|
|
475
|
+
{:else if job.status === 'done'}
|
|
476
|
+
<progress class="progress progress-success h-1.5 w-full" value="1" max="1" aria-hidden="true"
|
|
477
|
+
></progress>
|
|
478
|
+
{/if}
|
|
479
|
+
</td>
|
|
480
|
+
<td class="tabular-nums">
|
|
481
|
+
{#if elapsedCell}
|
|
482
|
+
{@render elapsedCell(job, c)}
|
|
483
|
+
{:else if job.startedAt != null}
|
|
484
|
+
{formatElapsed(c.elapsedMs)}
|
|
485
|
+
{:else}
|
|
486
|
+
<span class="text-base-content/40">—</span>
|
|
487
|
+
{/if}
|
|
488
|
+
</td>
|
|
489
|
+
<td class="text-right whitespace-nowrap">
|
|
490
|
+
{#if actionsCell}
|
|
491
|
+
{@render actionsCell(job, c)}
|
|
492
|
+
{:else}
|
|
493
|
+
<div class="join">
|
|
494
|
+
{#if job.status === 'queued'}
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
class="btn btn-ghost btn-xs join-item"
|
|
498
|
+
disabled={!c.canMoveUp}
|
|
499
|
+
aria-label="Move job {index + 1} up"
|
|
500
|
+
onclick={c.moveUp}>↑</button
|
|
501
|
+
>
|
|
502
|
+
<button
|
|
503
|
+
type="button"
|
|
504
|
+
class="btn btn-ghost btn-xs join-item"
|
|
505
|
+
disabled={!c.canMoveDown}
|
|
506
|
+
aria-label="Move job {index + 1} down"
|
|
507
|
+
onclick={c.moveDown}>↓</button
|
|
508
|
+
>
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
class="btn btn-ghost btn-xs join-item"
|
|
512
|
+
aria-label="Skip job {index + 1}"
|
|
513
|
+
onclick={c.skip}>Skip</button
|
|
514
|
+
>
|
|
515
|
+
{:else if job.status === 'running'}
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
class="btn btn-ghost btn-xs join-item"
|
|
519
|
+
aria-label="Cancel job {index + 1}"
|
|
520
|
+
onclick={c.cancel}>Cancel</button
|
|
521
|
+
>
|
|
522
|
+
{:else if job.status === 'failed' || job.status === 'cancelled'}
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
class="btn btn-ghost btn-xs join-item"
|
|
526
|
+
aria-label="Retry job {index + 1}"
|
|
527
|
+
onclick={c.retry}>Retry</button
|
|
528
|
+
>
|
|
529
|
+
{:else if job.status === 'skipped'}
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
class="btn btn-ghost btn-xs join-item"
|
|
533
|
+
aria-label="Restore job {index + 1} to the queue"
|
|
534
|
+
onclick={c.unskip}>Unskip</button
|
|
535
|
+
>
|
|
536
|
+
{/if}
|
|
537
|
+
</div>
|
|
538
|
+
{/if}
|
|
539
|
+
</td>
|
|
540
|
+
</tr>
|
|
541
|
+
{:else}
|
|
542
|
+
<tr>
|
|
543
|
+
<td colspan="99" class="text-base-content/50 py-8 text-center">{emptyText}</td>
|
|
544
|
+
</tr>
|
|
545
|
+
{/each}
|
|
546
|
+
</tbody>
|
|
547
|
+
</table>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { JobRow } from './batchQueue';
|
|
2
|
+
export interface JobContext {
|
|
3
|
+
id: string;
|
|
4
|
+
/** 1-based attempt number. */
|
|
5
|
+
attempt: number;
|
|
6
|
+
/** Aborted when the job or the batch is cancelled — pass it to fetch/pollOperation. */
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
/** Report 0..1 progress for the row's bar. */
|
|
9
|
+
reportProgress: (fraction: number) => void;
|
|
10
|
+
}
|
|
11
|
+
/** Per-row controls handed to the cell snippets. */
|
|
12
|
+
export interface RowControls {
|
|
13
|
+
index: number;
|
|
14
|
+
retry: () => void;
|
|
15
|
+
skip: () => void;
|
|
16
|
+
unskip: () => void;
|
|
17
|
+
cancel: () => void;
|
|
18
|
+
moveUp: () => void;
|
|
19
|
+
moveDown: () => void;
|
|
20
|
+
canMoveUp: boolean;
|
|
21
|
+
canMoveDown: boolean;
|
|
22
|
+
/** Live elapsed ms for this row's current/last attempt. */
|
|
23
|
+
elapsedMs: number;
|
|
24
|
+
}
|
|
25
|
+
export type BatchPhase = 'idle' | 'running' | 'paused';
|
|
26
|
+
/** Batch-level controls handed to the `toolbar` snippet. */
|
|
27
|
+
export interface BatchControls {
|
|
28
|
+
phase: BatchPhase;
|
|
29
|
+
start: () => void;
|
|
30
|
+
pause: () => void;
|
|
31
|
+
cancel: () => Promise<void>;
|
|
32
|
+
retryFailed: () => void;
|
|
33
|
+
etaMs: number | null;
|
|
34
|
+
}
|
|
35
|
+
export type { JobRow };
|
|
36
|
+
/**
|
|
37
|
+
* BatchJobTable (jatui-m4uz3.7) — generic queue runner.
|
|
38
|
+
*
|
|
39
|
+
* Harvested from vid-gen's BatchGenerator: a table of jobs with per-row
|
|
40
|
+
* status / progress / elapsed / retry / skip / reorder, a concurrency cap,
|
|
41
|
+
* an optional per-run process limit (quota budget), a rolling-average ETA,
|
|
42
|
+
* and toast + showConfirm feedback instead of alert()/confirm().
|
|
43
|
+
*
|
|
44
|
+
* The host supplies `jobs` (bind it) and an async `run(data, ctx)`; the
|
|
45
|
+
* component owns the scheduling. Every default cell is a snippet override.
|
|
46
|
+
* Mount <ToastHost /> and <ConfirmDialog /> once in the app root.
|
|
47
|
+
*/
|
|
48
|
+
import { type Snippet } from 'svelte';
|
|
49
|
+
import { type BatchSummary } from './batchQueue';
|
|
50
|
+
declare function $$render<T>(): {
|
|
51
|
+
props: {
|
|
52
|
+
jobs: JobRow<T>[];
|
|
53
|
+
run: (data: T, ctx: JobContext) => Promise<unknown>;
|
|
54
|
+
/** Max jobs running at once. Default 3. */
|
|
55
|
+
concurrencyLimit?: number;
|
|
56
|
+
/** Max jobs to start per run (Start → drained). `null` = unlimited. */
|
|
57
|
+
processLimit?: number | null;
|
|
58
|
+
autoStart?: boolean;
|
|
59
|
+
/** Toast on batch completion / process limit. Default true. */
|
|
60
|
+
notify?: boolean;
|
|
61
|
+
/** showConfirm before cancelling running jobs or clearing rows. Default true. */
|
|
62
|
+
confirmDestructive?: boolean;
|
|
63
|
+
/** Accessible name for the table. */
|
|
64
|
+
caption?: string;
|
|
65
|
+
emptyText?: string;
|
|
66
|
+
/** Extra <th> cells for host data columns (after the # column). */
|
|
67
|
+
headers?: Snippet;
|
|
68
|
+
/** Matching <td> cells for each row. */
|
|
69
|
+
cells?: Snippet<[JobRow<T>, RowControls]>;
|
|
70
|
+
statusCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
71
|
+
progressCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
72
|
+
elapsedCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
73
|
+
actionsCell?: Snippet<[JobRow<T>, RowControls]>;
|
|
74
|
+
/** Extra toolbar content (e.g. a QuotaMeter), right-aligned. */
|
|
75
|
+
toolbar?: Snippet<[BatchControls]>;
|
|
76
|
+
onJobDone?: (job: JobRow<T>) => void;
|
|
77
|
+
onJobFailed?: (job: JobRow<T>, error: unknown) => void;
|
|
78
|
+
onBatchDone?: (summary: BatchSummary) => void;
|
|
79
|
+
class?: string;
|
|
80
|
+
};
|
|
81
|
+
exports: {
|
|
82
|
+
start: () => void;
|
|
83
|
+
pause: () => void;
|
|
84
|
+
cancel: () => Promise<void>;
|
|
85
|
+
retryFailed: () => void;
|
|
86
|
+
clearFinished: () => Promise<void>;
|
|
87
|
+
};
|
|
88
|
+
bindings: "jobs";
|
|
89
|
+
slots: {};
|
|
90
|
+
events: {};
|
|
91
|
+
};
|
|
92
|
+
declare class __sveltets_Render<T> {
|
|
93
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
94
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
95
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
96
|
+
bindings(): "jobs";
|
|
97
|
+
exports(): {
|
|
98
|
+
start: () => void;
|
|
99
|
+
pause: () => void;
|
|
100
|
+
cancel: () => Promise<void>;
|
|
101
|
+
retryFailed: () => void;
|
|
102
|
+
clearFinished: () => Promise<void>;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
interface $$IsomorphicComponent {
|
|
106
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
107
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
108
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
109
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
110
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
111
|
+
}
|
|
112
|
+
declare const BatchJobTable: $$IsomorphicComponent;
|
|
113
|
+
type BatchJobTable<T> = InstanceType<typeof BatchJobTable<T>>;
|
|
114
|
+
export default BatchJobTable;
|