@svadmin/ui 0.33.1 → 0.34.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/dist/component-registry.svelte.d.ts +2 -0
- package/dist/components/AdminApp.svelte +8 -2
- package/dist/components/AdminApp.svelte.d.ts +2 -1
- package/dist/components/CancelTaskButton.svelte +60 -0
- package/dist/components/CancelTaskButton.svelte.d.ts +13 -0
- package/dist/components/Layout.svelte +13 -2
- package/dist/components/RetryTaskButton.svelte +60 -0
- package/dist/components/RetryTaskButton.svelte.d.ts +13 -0
- package/dist/components/TaskDetails.svelte +138 -0
- package/dist/components/TaskDetails.svelte.d.ts +19 -0
- package/dist/components/TaskList.svelte +150 -0
- package/dist/components/TaskList.svelte.d.ts +24 -0
- package/dist/components/TaskProgressBar.svelte +25 -0
- package/dist/components/TaskProgressBar.svelte.d.ts +8 -0
- package/dist/components/TaskQueueDrawer.svelte +318 -113
- package/dist/components/TaskQueueDrawer.svelte.d.ts +6 -6
- package/dist/components/TaskQueueDrawer.test.svelte.d.ts +1 -0
- package/dist/components/TaskQueueDrawer.test.svelte.js +143 -0
- package/dist/components/TaskStatusBadge.svelte +78 -0
- package/dist/components/TaskStatusBadge.svelte.d.ts +8 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -0
- package/dist/types.d.ts +0 -11
- package/package.json +20 -12
- package/src/app.css +0 -674
|
@@ -1,56 +1,211 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { ListTodo, Loader2, Plus, Search, RefreshCw } from '@lucide/svelte';
|
|
3
|
+
import { getTaskProvider, useSubmitTask, useTaskList } from '@svadmin/core';
|
|
4
|
+
import { t } from '@svadmin/core/i18n';
|
|
5
|
+
import type { TaskProvider, TaskRecord } from '@svadmin/core';
|
|
4
6
|
import { Button } from './ui/button/index.js';
|
|
5
7
|
import { Badge } from './ui/badge/index.js';
|
|
6
|
-
import
|
|
8
|
+
import { Input } from './ui/input/index.js';
|
|
9
|
+
import { Select } from './ui/select/index.js';
|
|
10
|
+
import { Textarea } from './ui/textarea/index.js';
|
|
11
|
+
import { Label } from './ui/label/index.js';
|
|
12
|
+
import * as Sheet from './ui/sheet/index.js';
|
|
13
|
+
import * as Tabs from './ui/tabs/index.js';
|
|
14
|
+
import * as Card from './ui/card/index.js';
|
|
15
|
+
import TaskList from './TaskList.svelte';
|
|
16
|
+
import TaskDetails from './TaskDetails.svelte';
|
|
17
|
+
|
|
18
|
+
type AutoRefreshValue = '0' | '5000' | '15000' | '30000';
|
|
19
|
+
type TaskTab = 'tasks' | 'dlq';
|
|
7
20
|
|
|
8
21
|
let {
|
|
9
22
|
open = $bindable(false),
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
onDownload,
|
|
23
|
+
taskProvider = getTaskProvider({ optional: true }) as TaskProvider<TaskRecord> | undefined,
|
|
24
|
+
title,
|
|
25
|
+
initialTab = 'tasks',
|
|
14
26
|
}: {
|
|
15
27
|
open?: boolean;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
onDownload?: (taskId: string, url: string) => void;
|
|
28
|
+
taskProvider?: TaskProvider<TaskRecord>;
|
|
29
|
+
title?: string;
|
|
30
|
+
initialTab?: TaskTab;
|
|
20
31
|
} = $props();
|
|
21
32
|
|
|
22
|
-
let
|
|
33
|
+
let activeTab = $state<TaskTab>('tasks');
|
|
34
|
+
let selectedTaskId = $state<string | null>(null);
|
|
35
|
+
let searchQuery = $state('');
|
|
36
|
+
let statusFilter = $state('all');
|
|
37
|
+
let autoRefresh = $state<AutoRefreshValue>('15000');
|
|
38
|
+
let submitTaskName = $state('');
|
|
39
|
+
let submitIdempotencyKey = $state('');
|
|
40
|
+
let submitBodyText = $state(t('task.bodyPlaceholder'));
|
|
41
|
+
let submitError = $state<string | null>(null);
|
|
42
|
+
let submitOpen = $state(false);
|
|
43
|
+
|
|
44
|
+
const refreshInterval = $derived(autoRefresh === '0' ? false : Number(autoRefresh));
|
|
45
|
+
const resolvedTitle = $derived(title ?? t('task.drawerTitle'));
|
|
46
|
+
|
|
47
|
+
const taskQuery = useTaskList({
|
|
48
|
+
get taskProvider() {
|
|
49
|
+
return taskProvider;
|
|
50
|
+
},
|
|
51
|
+
get queryOptions() {
|
|
52
|
+
return {
|
|
53
|
+
enabled: !!taskProvider,
|
|
54
|
+
refetchInterval: refreshInterval,
|
|
55
|
+
refetchIntervalInBackground: true,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const dlqQuery = useTaskList({
|
|
61
|
+
get dlq() {
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
get taskProvider() {
|
|
65
|
+
return taskProvider;
|
|
66
|
+
},
|
|
67
|
+
get queryOptions() {
|
|
68
|
+
return {
|
|
69
|
+
enabled: !!taskProvider && !!taskProvider?.listDlq,
|
|
70
|
+
refetchInterval: refreshInterval,
|
|
71
|
+
refetchIntervalInBackground: true,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const submitTask = useSubmitTask();
|
|
77
|
+
|
|
78
|
+
const allTasks = $derived(taskQuery.data?.data ?? []);
|
|
79
|
+
const dlqTasks = $derived(dlqQuery.data?.data ?? []);
|
|
80
|
+
|
|
81
|
+
const availableStatuses = $derived.by(() => {
|
|
82
|
+
const source = activeTab === 'dlq' ? dlqTasks : allTasks;
|
|
83
|
+
const unique = Array.from(new Set(source.map((task) => String(task.status ?? 'pending').toLowerCase()).filter(Boolean)));
|
|
84
|
+
return unique.sort();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function matchesSearch(task: TaskRecord, query: string) {
|
|
88
|
+
if (!query) return true;
|
|
89
|
+
const haystack = [
|
|
90
|
+
task.id,
|
|
91
|
+
task.title,
|
|
92
|
+
task.name,
|
|
93
|
+
task.status,
|
|
94
|
+
task.message,
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(' ')
|
|
98
|
+
.toLowerCase();
|
|
99
|
+
return haystack.includes(query.toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function matchesStatus(task: TaskRecord, status: string) {
|
|
103
|
+
if (status === 'all') return true;
|
|
104
|
+
return String(task.status ?? 'pending').toLowerCase() === status;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const filteredTasks = $derived.by(() => {
|
|
108
|
+
const source = activeTab === 'dlq' ? dlqTasks : allTasks;
|
|
109
|
+
return source.filter((task) => matchesSearch(task, searchQuery) && matchesStatus(task, statusFilter));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const runningCount = $derived(
|
|
113
|
+
allTasks.filter((task) => ['pending', 'queued', 'running', 'processing'].includes(String(task.status ?? '').toLowerCase())).length,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
$effect(() => {
|
|
117
|
+
activeTab = initialTab;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
$effect(() => {
|
|
121
|
+
if (statusFilter !== 'all' && !availableStatuses.includes(statusFilter)) {
|
|
122
|
+
statusFilter = 'all';
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
$effect(() => {
|
|
127
|
+
const current = filteredTasks;
|
|
128
|
+
if (current.length === 0) {
|
|
129
|
+
selectedTaskId = null;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!selectedTaskId) {
|
|
134
|
+
selectedTaskId = current[0].id;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
23
137
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
failed: XCircle,
|
|
29
|
-
};
|
|
138
|
+
if (!current.some((task) => task.id === selectedTaskId)) {
|
|
139
|
+
selectedTaskId = current[0].id;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
30
142
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
143
|
+
function formatTime(value: unknown) {
|
|
144
|
+
if (!value) return '—';
|
|
145
|
+
return new Date(value as string | Date).toLocaleString();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resetSubmitForm() {
|
|
149
|
+
submitTaskName = '';
|
|
150
|
+
submitIdempotencyKey = '';
|
|
151
|
+
submitBodyText = t('task.bodyPlaceholder');
|
|
152
|
+
submitError = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function handleSubmitTask() {
|
|
156
|
+
submitError = null;
|
|
157
|
+
if (!submitTaskName.trim()) {
|
|
158
|
+
submitError = t('validation.required');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let body: Record<string, unknown> | undefined;
|
|
163
|
+
if (submitBodyText.trim()) {
|
|
164
|
+
try {
|
|
165
|
+
body = JSON.parse(submitBodyText) as Record<string, unknown>;
|
|
166
|
+
} catch {
|
|
167
|
+
submitError = t('task.invalidJson');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
37
171
|
|
|
38
|
-
|
|
39
|
-
|
|
172
|
+
try {
|
|
173
|
+
const handle = await submitTask.mutation.mutateAsync({
|
|
174
|
+
taskName: submitTaskName.trim(),
|
|
175
|
+
taskProvider,
|
|
176
|
+
options: {
|
|
177
|
+
body,
|
|
178
|
+
idempotencyKey: submitIdempotencyKey.trim() || undefined,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
activeTab = 'tasks';
|
|
182
|
+
selectedTaskId = handle.id ?? null;
|
|
183
|
+
submitOpen = false;
|
|
184
|
+
resetSubmitForm();
|
|
185
|
+
await Promise.all([
|
|
186
|
+
taskQuery.refetch?.(),
|
|
187
|
+
dlqQuery.refetch?.(),
|
|
188
|
+
]);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
submitError = error instanceof Error ? error.message : t('task.submitFailed');
|
|
191
|
+
}
|
|
40
192
|
}
|
|
41
193
|
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
194
|
+
async function refreshCurrentTab() {
|
|
195
|
+
if (activeTab === 'dlq') {
|
|
196
|
+
await dlqQuery.refetch?.();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await taskQuery.refetch?.();
|
|
45
200
|
}
|
|
46
201
|
</script>
|
|
47
202
|
|
|
48
|
-
<!-- Trigger Button (to be placed in Header) -->
|
|
49
203
|
<Button
|
|
50
204
|
variant="ghost"
|
|
51
205
|
size="icon"
|
|
52
206
|
class="relative"
|
|
53
207
|
onclick={() => open = !open}
|
|
208
|
+
aria-label={resolvedTitle}
|
|
54
209
|
>
|
|
55
210
|
<ListTodo class="h-4 w-4" />
|
|
56
211
|
{#if runningCount > 0}
|
|
@@ -60,99 +215,149 @@
|
|
|
60
215
|
{/if}
|
|
61
216
|
</Button>
|
|
62
217
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
69
|
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
70
|
-
<div
|
|
71
|
-
class="bg-card shadow-2xl w-full max-w-sm h-full flex flex-col border-l border-border animate-in slide-in-from-right duration-200"
|
|
72
|
-
onclick={(e) => e.stopPropagation()}
|
|
73
|
-
>
|
|
74
|
-
<!-- Header -->
|
|
75
|
-
<div class="p-4 border-b border-border flex items-center justify-between bg-muted/30">
|
|
76
|
-
<div class="flex items-center gap-2">
|
|
218
|
+
<Sheet.Root bind:open side="right" class="w-full max-w-6xl border-l border-border bg-card p-0" onClose={() => open = false}>
|
|
219
|
+
<Sheet.Content class="h-full gap-0 p-0">
|
|
220
|
+
<div class="flex h-full flex-col">
|
|
221
|
+
<div class="border-b border-border bg-muted/30 px-4 py-3">
|
|
222
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
77
223
|
<ListTodo class="h-5 w-5 text-muted-foreground" />
|
|
78
|
-
<h2 class="text-sm font-semibold">
|
|
79
|
-
{
|
|
80
|
-
|
|
224
|
+
<h2 class="text-sm font-semibold">{resolvedTitle}</h2>
|
|
225
|
+
<Badge variant="secondary" class="text-xs">{t('task.activeCount', { count: runningCount })}</Badge>
|
|
226
|
+
{#if allTasks[0]?.createdAt}
|
|
227
|
+
<span class="text-xs text-muted-foreground">{t('task.lastQueuedAt', { time: formatTime(allTasks[0].createdAt) })}</span>
|
|
81
228
|
{/if}
|
|
82
229
|
</div>
|
|
83
|
-
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => open = false}>
|
|
84
|
-
<X class="h-4 w-4" />
|
|
85
|
-
</Button>
|
|
86
230
|
</div>
|
|
87
231
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
232
|
+
{#if !taskProvider}
|
|
233
|
+
<div class="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
|
234
|
+
{t('common.configRequired')}
|
|
235
|
+
</div>
|
|
236
|
+
{:else}
|
|
237
|
+
<div class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
|
|
238
|
+
<div class="min-h-0 overflow-auto border-b border-border lg:border-b-0 lg:border-r">
|
|
239
|
+
<div class="space-y-4 p-4">
|
|
240
|
+
<div class="flex flex-col gap-3">
|
|
241
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
242
|
+
<Tabs.Root value={activeTab} class="w-full">
|
|
243
|
+
<Tabs.List class="inline-flex rounded-lg border border-border bg-muted/40 p-1">
|
|
244
|
+
<Tabs.Trigger value="tasks" active={activeTab === 'tasks'} onclick={() => activeTab = 'tasks'}>
|
|
245
|
+
{t('task.tabTasks')}
|
|
246
|
+
<Badge variant="outline" class="ml-1 text-[10px]">{taskQuery.data?.total ?? allTasks.length}</Badge>
|
|
247
|
+
</Tabs.Trigger>
|
|
248
|
+
<Tabs.Trigger value="dlq" active={activeTab === 'dlq'} onclick={() => activeTab = 'dlq'}>
|
|
249
|
+
{t('task.tabDlq')}
|
|
250
|
+
<Badge variant="outline" class="ml-1 text-[10px]">{dlqQuery.data?.total ?? dlqTasks.length}</Badge>
|
|
251
|
+
</Tabs.Trigger>
|
|
252
|
+
</Tabs.List>
|
|
253
|
+
</Tabs.Root>
|
|
254
|
+
<div class="ml-auto flex items-center gap-2">
|
|
255
|
+
<Button variant="outline" size="sm" onclick={() => void refreshCurrentTab()}>
|
|
256
|
+
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
|
257
|
+
{t('common.refresh')}
|
|
258
|
+
</Button>
|
|
259
|
+
<Button size="sm" onclick={() => submitOpen = !submitOpen}>
|
|
260
|
+
<Plus class="mr-1.5 h-3.5 w-3.5" />
|
|
261
|
+
{t('task.submitAction')}
|
|
262
|
+
</Button>
|
|
263
|
+
</div>
|
|
97
264
|
</div>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
265
|
+
|
|
266
|
+
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_160px]">
|
|
267
|
+
<div class="relative">
|
|
268
|
+
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
269
|
+
<Input
|
|
270
|
+
class="pl-9"
|
|
271
|
+
placeholder={t('task.searchPlaceholder')}
|
|
272
|
+
bind:value={searchQuery}
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
<Select bind:value={statusFilter} aria-label={t('task.statusFilterLabel')}>
|
|
276
|
+
<option value="all">{t('task.statusAll')}</option>
|
|
277
|
+
{#each availableStatuses as status}
|
|
278
|
+
<option value={status}>{status}</option>
|
|
279
|
+
{/each}
|
|
280
|
+
</Select>
|
|
281
|
+
<Select bind:value={autoRefresh} aria-label={t('task.autoRefreshLabel')}>
|
|
282
|
+
<option value="0">{t('task.refreshOff')}</option>
|
|
283
|
+
<option value="5000">{t('task.refresh5s')}</option>
|
|
284
|
+
<option value="15000">{t('task.refresh15s')}</option>
|
|
285
|
+
<option value="30000">{t('task.refresh30s')}</option>
|
|
286
|
+
</Select>
|
|
104
287
|
</div>
|
|
105
288
|
</div>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
289
|
+
|
|
290
|
+
{#if submitOpen}
|
|
291
|
+
<Card.Root class="border-border/60 shadow-none">
|
|
292
|
+
<Card.Header class="pb-3">
|
|
293
|
+
<Card.Title class="text-base">{t('task.submitTitle')}</Card.Title>
|
|
294
|
+
<Card.Description>{t('task.submitDescription')}</Card.Description>
|
|
295
|
+
</Card.Header>
|
|
296
|
+
<Card.Content class="space-y-4 pt-0">
|
|
297
|
+
<div class="space-y-2">
|
|
298
|
+
<Label for="task-name">{t('task.taskNameLabel')}</Label>
|
|
299
|
+
<Input id="task-name" bind:value={submitTaskName} placeholder="image.generate" />
|
|
300
|
+
</div>
|
|
301
|
+
<div class="space-y-2">
|
|
302
|
+
<Label for="task-idempotency-key">{t('task.idempotencyKeyLabel')}</Label>
|
|
303
|
+
<Input id="task-idempotency-key" bind:value={submitIdempotencyKey} placeholder="poster-2026-04-19" />
|
|
304
|
+
</div>
|
|
305
|
+
<div class="space-y-2">
|
|
306
|
+
<Label for="task-body">{t('task.bodyLabel')}</Label>
|
|
307
|
+
<Textarea id="task-body" bind:value={submitBodyText} class="min-h-[140px] font-mono text-xs" />
|
|
308
|
+
</div>
|
|
309
|
+
{#if submitError}
|
|
310
|
+
<p class="text-sm text-destructive">{submitError}</p>
|
|
311
|
+
{/if}
|
|
312
|
+
<div class="flex flex-wrap justify-end gap-2">
|
|
313
|
+
<Button variant="ghost" onclick={() => { submitOpen = false; submitError = null; }}>
|
|
314
|
+
{t('common.cancel')}
|
|
315
|
+
</Button>
|
|
316
|
+
<Button onclick={() => void handleSubmitTask()} disabled={submitTask.mutation.isPending}>
|
|
317
|
+
{#if submitTask.mutation.isPending}
|
|
318
|
+
<Loader2 class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
319
|
+
{/if}
|
|
320
|
+
{t('task.submitAction')}
|
|
321
|
+
</Button>
|
|
322
|
+
</div>
|
|
323
|
+
</Card.Content>
|
|
324
|
+
</Card.Root>
|
|
325
|
+
{/if}
|
|
326
|
+
|
|
327
|
+
<TaskList
|
|
328
|
+
tasks={filteredTasks}
|
|
329
|
+
{taskProvider}
|
|
330
|
+
dlq={activeTab === 'dlq'}
|
|
331
|
+
title={activeTab === 'dlq' ? t('task.dlqTitle') : t('task.listTitle')}
|
|
332
|
+
emptyText={activeTab === 'dlq' ? t('task.noDlq') : t('task.noTasks')}
|
|
333
|
+
onSelect={(task) => {
|
|
334
|
+
selectedTaskId = task.id;
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
112
337
|
</div>
|
|
338
|
+
</div>
|
|
113
339
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
340
|
+
<div class="min-h-0 overflow-auto bg-muted/10">
|
|
341
|
+
<div class="p-4">
|
|
342
|
+
{#if selectedTaskId}
|
|
343
|
+
<TaskDetails
|
|
344
|
+
taskId={selectedTaskId}
|
|
345
|
+
{taskProvider}
|
|
346
|
+
useProviderData
|
|
347
|
+
queryOptions={{
|
|
348
|
+
refetchInterval: refreshInterval,
|
|
349
|
+
refetchIntervalInBackground: true,
|
|
350
|
+
}}
|
|
351
|
+
/>
|
|
352
|
+
{:else}
|
|
353
|
+
<div class="flex h-full min-h-[280px] items-center justify-center rounded-lg border border-dashed border-border/60 bg-background/70 text-center text-sm text-muted-foreground">
|
|
354
|
+
{t('task.selectHint')}
|
|
122
355
|
</div>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
{/if}
|
|
126
|
-
|
|
127
|
-
<!-- Actions -->
|
|
128
|
-
{#if task.status === 'completed' && task.downloadUrl}
|
|
129
|
-
<div class="mt-2 ml-7">
|
|
130
|
-
<Button
|
|
131
|
-
variant="outline" size="sm" class="h-7 text-xs"
|
|
132
|
-
onclick={() => onDownload?.(task.id, task.downloadUrl!)}
|
|
133
|
-
>
|
|
134
|
-
<Download class="h-3 w-3 mr-1" />
|
|
135
|
-
Download
|
|
136
|
-
</Button>
|
|
137
|
-
</div>
|
|
138
|
-
{:else if task.status === 'failed' && onRetry}
|
|
139
|
-
<div class="mt-2 ml-7">
|
|
140
|
-
<Button
|
|
141
|
-
variant="outline" size="sm" class="h-7 text-xs text-destructive"
|
|
142
|
-
onclick={() => onRetry?.(task.id)}
|
|
143
|
-
>
|
|
144
|
-
Retry
|
|
145
|
-
</Button>
|
|
146
|
-
</div>
|
|
147
|
-
{/if}
|
|
148
|
-
</div>
|
|
149
|
-
{:else}
|
|
150
|
-
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
|
151
|
-
<ListTodo class="h-8 w-8 mb-2 opacity-30" />
|
|
152
|
-
<p class="text-sm">No background tasks</p>
|
|
356
|
+
{/if}
|
|
357
|
+
</div>
|
|
153
358
|
</div>
|
|
154
|
-
|
|
155
|
-
|
|
359
|
+
</div>
|
|
360
|
+
{/if}
|
|
156
361
|
</div>
|
|
157
|
-
</
|
|
158
|
-
|
|
362
|
+
</Sheet.Content>
|
|
363
|
+
</Sheet.Root>
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TaskProvider, TaskRecord } from '@svadmin/core';
|
|
2
|
+
type TaskTab = 'tasks' | 'dlq';
|
|
2
3
|
type $$ComponentProps = {
|
|
3
4
|
open?: boolean;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
onDownload?: (taskId: string, url: string) => void;
|
|
5
|
+
taskProvider?: TaskProvider<TaskRecord>;
|
|
6
|
+
title?: string;
|
|
7
|
+
initialTab?: TaskTab;
|
|
8
8
|
};
|
|
9
|
-
declare const TaskQueueDrawer: import("svelte").Component<$$ComponentProps, {}, "open"
|
|
9
|
+
declare const TaskQueueDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
10
10
|
type TaskQueueDrawer = ReturnType<typeof TaskQueueDrawer>;
|
|
11
11
|
export default TaskQueueDrawer;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import TaskQueueDrawer from './TaskQueueDrawer.svelte';
|
|
4
|
+
const taskFixtures = [
|
|
5
|
+
{
|
|
6
|
+
id: 'task-1',
|
|
7
|
+
title: 'Generate poster',
|
|
8
|
+
status: 'running',
|
|
9
|
+
message: 'Rendering hero artwork',
|
|
10
|
+
createdAt: '2026-04-19T10:00:00.000Z',
|
|
11
|
+
updatedAt: '2026-04-19T10:05:00.000Z',
|
|
12
|
+
progress: 42,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'task-2',
|
|
16
|
+
title: 'Send email batch',
|
|
17
|
+
status: 'queued',
|
|
18
|
+
message: 'Waiting for worker',
|
|
19
|
+
createdAt: '2026-04-19T10:10:00.000Z',
|
|
20
|
+
updatedAt: '2026-04-19T10:12:00.000Z',
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
const dlqFixtures = [
|
|
24
|
+
{
|
|
25
|
+
id: 'task-dlq-1',
|
|
26
|
+
title: 'Nightly sync',
|
|
27
|
+
status: 'failed',
|
|
28
|
+
message: 'Remote API timeout',
|
|
29
|
+
createdAt: '2026-04-18T23:00:00.000Z',
|
|
30
|
+
updatedAt: '2026-04-18T23:05:00.000Z',
|
|
31
|
+
error: { message: 'Remote API timeout' },
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
const mockTaskProvider = {
|
|
35
|
+
submit: vi.fn(async () => ({
|
|
36
|
+
id: 'submitted-image.generate',
|
|
37
|
+
wait: async () => ({
|
|
38
|
+
id: 'submitted-image.generate',
|
|
39
|
+
title: 'image.generate',
|
|
40
|
+
status: 'queued',
|
|
41
|
+
createdAt: '2026-04-19T11:00:00.000Z',
|
|
42
|
+
updatedAt: '2026-04-19T11:00:00.000Z',
|
|
43
|
+
}),
|
|
44
|
+
})),
|
|
45
|
+
get: vi.fn(async (taskId) => {
|
|
46
|
+
return [...taskFixtures, ...dlqFixtures].find((task) => task.id === taskId) ?? taskFixtures[0];
|
|
47
|
+
}),
|
|
48
|
+
list: vi.fn(async () => ({ data: taskFixtures, total: taskFixtures.length })),
|
|
49
|
+
listDlq: vi.fn(async () => ({ data: dlqFixtures, total: dlqFixtures.length })),
|
|
50
|
+
retry: vi.fn(async () => undefined),
|
|
51
|
+
cancel: vi.fn(async () => undefined),
|
|
52
|
+
};
|
|
53
|
+
const refetchTasks = vi.fn(async () => ({ data: { data: taskFixtures, total: taskFixtures.length } }));
|
|
54
|
+
const refetchDlq = vi.fn(async () => ({ data: { data: dlqFixtures, total: dlqFixtures.length } }));
|
|
55
|
+
const mutateAsync = vi.fn(async (params) => ({
|
|
56
|
+
id: `submitted-${params.taskName}`,
|
|
57
|
+
wait: async () => ({
|
|
58
|
+
id: `submitted-${params.taskName}`,
|
|
59
|
+
title: params.taskName,
|
|
60
|
+
status: 'queued',
|
|
61
|
+
}),
|
|
62
|
+
}));
|
|
63
|
+
vi.mock('@svadmin/core', () => ({
|
|
64
|
+
getTaskProvider: () => mockTaskProvider,
|
|
65
|
+
useTaskList: ({ dlq }) => ({
|
|
66
|
+
data: dlq
|
|
67
|
+
? { data: dlqFixtures, total: dlqFixtures.length }
|
|
68
|
+
: { data: taskFixtures, total: taskFixtures.length },
|
|
69
|
+
isLoading: false,
|
|
70
|
+
refetch: dlq ? refetchDlq : refetchTasks,
|
|
71
|
+
}),
|
|
72
|
+
useTask: ({ taskId }) => ({
|
|
73
|
+
data: [...taskFixtures, ...dlqFixtures].find((task) => task.id === taskId) ?? taskFixtures[0],
|
|
74
|
+
isLoading: false,
|
|
75
|
+
}),
|
|
76
|
+
useSubmitTask: () => ({
|
|
77
|
+
mutation: {
|
|
78
|
+
mutateAsync,
|
|
79
|
+
isPending: false,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
}));
|
|
83
|
+
vi.mock('@tanstack/svelte-query', () => ({
|
|
84
|
+
useQueryClient: () => ({
|
|
85
|
+
invalidateQueries: vi.fn(async () => undefined),
|
|
86
|
+
}),
|
|
87
|
+
}));
|
|
88
|
+
describe('TaskQueueDrawer', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
refetchTasks.mockClear();
|
|
91
|
+
refetchDlq.mockClear();
|
|
92
|
+
mutateAsync.mockClear();
|
|
93
|
+
mockTaskProvider.submit.mockClear();
|
|
94
|
+
mockTaskProvider.get.mockClear();
|
|
95
|
+
mockTaskProvider.list.mockClear();
|
|
96
|
+
mockTaskProvider.listDlq.mockClear();
|
|
97
|
+
mockTaskProvider.retry.mockClear();
|
|
98
|
+
mockTaskProvider.cancel.mockClear();
|
|
99
|
+
});
|
|
100
|
+
it('supports tab switching, filtering, and detail selection', async () => {
|
|
101
|
+
render(TaskQueueDrawer, { open: true, taskProvider: mockTaskProvider });
|
|
102
|
+
expect(screen.getByText('Task Center')).toBeTruthy();
|
|
103
|
+
expect(screen.getAllByText('Generate poster').length).toBeGreaterThan(0);
|
|
104
|
+
expect(screen.getAllByText('Rendering hero artwork').length).toBeGreaterThan(0);
|
|
105
|
+
const searchInput = screen.getByPlaceholderText('Filter tasks by name, ID, status, or message...');
|
|
106
|
+
await fireEvent.input(searchInput, { target: { value: 'email' } });
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(screen.queryAllByText('Generate poster').length).toBeLessThan(2);
|
|
109
|
+
});
|
|
110
|
+
expect(screen.getByText('Send email batch')).toBeTruthy();
|
|
111
|
+
await fireEvent.input(searchInput, { target: { value: '' } });
|
|
112
|
+
await fireEvent.click(screen.getByRole('tab', { name: /DLQ/i }));
|
|
113
|
+
expect(screen.getByText('Nightly sync')).toBeTruthy();
|
|
114
|
+
const dlqRow = screen.getByText('Nightly sync').closest('tr');
|
|
115
|
+
expect(dlqRow).toBeTruthy();
|
|
116
|
+
await fireEvent.click(dlqRow);
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(screen.getAllByText('Remote API timeout').length).toBeGreaterThan(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
it('submits a task from the inline form', async () => {
|
|
122
|
+
render(TaskQueueDrawer, { open: true, taskProvider: mockTaskProvider });
|
|
123
|
+
await fireEvent.click(screen.getAllByRole('button', { name: 'Submit Task' })[0]);
|
|
124
|
+
await fireEvent.input(screen.getByLabelText('Task name'), {
|
|
125
|
+
target: { value: 'image.generate' },
|
|
126
|
+
});
|
|
127
|
+
await fireEvent.input(screen.getByLabelText('Task payload (JSON)'), {
|
|
128
|
+
target: { value: '{"prompt":"poster"}' },
|
|
129
|
+
});
|
|
130
|
+
const submitButtons = screen.getAllByRole('button', { name: 'Submit Task' });
|
|
131
|
+
await fireEvent.click(submitButtons[submitButtons.length - 1]);
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(mutateAsync).toHaveBeenCalledWith({
|
|
134
|
+
taskName: 'image.generate',
|
|
135
|
+
taskProvider: mockTaskProvider,
|
|
136
|
+
options: {
|
|
137
|
+
body: { prompt: 'poster' },
|
|
138
|
+
idempotencyKey: undefined,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|