feat: server-side pagination and filtering for admin queues page

- Move queue, status, and offset to URL search params (?queue=&status=&offset=)
- Load jobs server-side in +page.server.ts with auth token (matches other admin pages)
- Derive total from adminQueues counts (waiting+active+completed+failed+delayed)
  so pagination knows total without an extra query
- Add fetchFn/token params to getAdminQueueJobs for server-side use
- Retry/remove/pause/resume actions now use invalidateAll() instead of local state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 11:49:50 +01:00
parent 79932157bf
commit fbd2efa994
3 changed files with 145 additions and 109 deletions

View File

@@ -1981,12 +1981,17 @@ export async function getAdminQueueJobs(
status?: string, status?: string,
limit?: number, limit?: number,
offset?: number, offset?: number,
fetchFn?: typeof globalThis.fetch,
token?: string,
): Promise<Job[]> { ): Promise<Job[]> {
return loggedApiCall("getAdminQueueJobs", async () => { return loggedApiCall("getAdminQueueJobs", async () => {
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>( const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
ADMIN_QUEUE_JOBS_QUERY, const data = await client.request<{ adminQueueJobs: Job[] }>(ADMIN_QUEUE_JOBS_QUERY, {
{ queue, status, limit, offset }, queue,
); status,
limit,
offset,
});
return data.adminQueueJobs; return data.adminQueueJobs;
}); });
} }

View File

@@ -1,7 +1,35 @@
import { getAdminQueues } from "$lib/services"; import { getAdminQueues, getAdminQueueJobs } from "$lib/services";
export async function load({ fetch, cookies }) { const LIMIT = 25;
export async function load({ fetch, cookies, url }) {
const token = cookies.get("session_token") || ""; const token = cookies.get("session_token") || "";
const queues = await getAdminQueues(fetch, token).catch(() => []); const queues = await getAdminQueues(fetch, token).catch(() => []);
return { queues };
const queueParam = url.searchParams.get("queue") ?? queues[0]?.name ?? null;
const status = url.searchParams.get("status") ?? null;
const offset = parseInt(url.searchParams.get("offset") ?? "0") || 0;
let jobs: Awaited<ReturnType<typeof getAdminQueueJobs>> = [];
let total = 0;
if (queueParam) {
jobs = await getAdminQueueJobs(
queueParam,
status ?? undefined,
LIMIT,
offset,
fetch,
token,
).catch(() => []);
const queueInfo = queues.find((q) => q.name === queueParam);
if (queueInfo) {
const { waiting, active, completed, failed, delayed } = queueInfo.counts;
const counts: Record<string, number> = { waiting, active, completed, failed, delayed };
total = status ? (counts[status] ?? 0) : Object.values(counts).reduce((a, b) => a + b, 0);
}
}
return { queues, queue: queueParam, status, jobs, total, offset, limit: LIMIT };
} }

View File

@@ -1,14 +1,10 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from "$app/navigation"; import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { import { adminRetryJob, adminRemoveJob, adminPauseQueue, adminResumeQueue } from "$lib/services";
getAdminQueueJobs,
adminRetryJob,
adminRemoveJob,
adminPauseQueue,
adminResumeQueue,
} from "$lib/services";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import type { Job } from "$lib/services"; import type { Job } from "$lib/services";
@@ -16,14 +12,6 @@
const { data } = $props(); const { data } = $props();
const queues = $derived(data.queues);
// null means "user hasn't picked yet" — fall back to first queue
let selectedQueueOverride = $state<string | null>(null);
const selectedQueue = $derived(selectedQueueOverride ?? queues[0]?.name ?? null);
let selectedStatus = $state<string | null>(null);
let jobs = $state<Job[]>([]);
let loadingJobs = $state(false);
let togglingQueue = $state<string | null>(null); let togglingQueue = $state<string | null>(null);
const STATUS_FILTERS = [ const STATUS_FILTERS = [
@@ -35,33 +23,28 @@
{ value: "delayed", label: $_("admin.queues.status_delayed") }, { value: "delayed", label: $_("admin.queues.status_delayed") },
]; ];
async function loadJobs() { function navigate(overrides: Record<string, string | null>) {
if (!selectedQueue) return; const params = new SvelteURLSearchParams(page.url.searchParams.toString());
loadingJobs = true; for (const [k, v] of Object.entries(overrides)) {
try { if (v === null) params.delete(k);
jobs = await getAdminQueueJobs(selectedQueue, selectedStatus ?? undefined, 50, 0); else params.set(k, v);
} finally {
loadingJobs = false;
} }
goto(`?${params.toString()}`);
} }
async function selectQueue(name: string) { function selectQueue(name: string) {
selectedQueueOverride = name; navigate({ queue: name, status: null, offset: null });
selectedStatus = null;
await loadJobs();
} }
async function selectStatus(status: string | null) { function selectStatus(status: string | null) {
selectedStatus = status; navigate({ status, offset: null });
await loadJobs();
} }
async function retryJob(job: Job) { async function retryJob(job: Job) {
try { try {
await adminRetryJob(job.queue, job.id); await adminRetryJob(job.queue, job.id);
toast.success($_("admin.queues.retry_success")); toast.success($_("admin.queues.retry_success"));
await loadJobs(); await invalidateAll();
await refreshCounts();
} catch { } catch {
toast.error($_("admin.queues.retry_error")); toast.error($_("admin.queues.retry_error"));
} }
@@ -71,8 +54,7 @@
try { try {
await adminRemoveJob(job.queue, job.id); await adminRemoveJob(job.queue, job.id);
toast.success($_("admin.queues.remove_success")); toast.success($_("admin.queues.remove_success"));
jobs = jobs.filter((j) => j.id !== job.id); await invalidateAll();
await refreshCounts();
} catch { } catch {
toast.error($_("admin.queues.remove_error")); toast.error($_("admin.queues.remove_error"));
} }
@@ -88,7 +70,7 @@
await adminPauseQueue(queueName); await adminPauseQueue(queueName);
toast.success($_("admin.queues.pause_success")); toast.success($_("admin.queues.pause_success"));
} }
await refreshCounts(); await invalidateAll();
} catch { } catch {
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error")); toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
} finally { } finally {
@@ -96,14 +78,6 @@
} }
} }
async function refreshCounts() {
await invalidateAll();
}
$effect(() => {
if (selectedQueue) loadJobs();
});
function statusColor(status: string): string { function statusColor(status: string): string {
switch (status) { switch (status) {
case "active": case "active":
@@ -130,12 +104,17 @@
<div class="py-3 sm:py-6 lg:pl-6"> <div class="py-3 sm:py-6 lg:pl-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1> <h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
{#if data.queue && data.total > 0}
<span class="text-sm text-muted-foreground">
{$_("admin.users.total", { values: { total: data.total } })}
</span>
{/if}
</div> </div>
<!-- Queue cards --> <!-- Queue cards -->
<div class="flex flex-wrap gap-3 mb-6"> <div class="flex flex-wrap gap-3 mb-6">
{#each queues as queue (queue.name)} {#each data.queues as queue (queue.name)}
{@const isSelected = selectedQueue === queue.name} {@const isSelected = data.queue === queue.name}
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
@@ -194,12 +173,12 @@
{/each} {/each}
</div> </div>
{#if selectedQueue} {#if data.queue}
<!-- Status filter tabs --> <!-- Status filter tabs -->
<div class="flex gap-1 mb-4 flex-wrap"> <div class="flex gap-1 mb-4 flex-wrap">
{#each STATUS_FILTERS as f (f.value ?? "all")} {#each STATUS_FILTERS as f (f.value ?? "all")}
<Button <Button
variant={selectedStatus === f.value ? "default" : "outline"} variant={data.status === f.value ? "default" : "outline"}
onclick={() => selectStatus(f.value)} onclick={() => selectStatus(f.value)}
> >
{f.label} {f.label}
@@ -233,70 +212,94 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border/30"> <tbody class="divide-y divide-border/30">
{#if loadingJobs} {#each data.jobs as job (job.id)}
<tr> <tr class="hover:bg-muted/10 transition-colors">
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground" <td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
>{$_("common.loading")}</td <td class="px-4 py-3">
<div>
<p class="font-medium">{job.name}</p>
{#if job.failedReason}
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
</p>
{/if}
</div>
</td>
<td class="px-4 py-3">
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{job.attemptsMade}</td
> >
</tr> <td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
{:else} >{formatDate(job.createdAt)}</td
{#each jobs as job (job.id)} >
<tr class="hover:bg-muted/10 transition-colors"> <td class="px-4 py-3 text-right">
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td> <div class="flex items-center justify-end gap-1">
<td class="px-4 py-3"> {#if job.status === "failed"}
<div>
<p class="font-medium">{job.name}</p>
{#if job.failedReason}
<p class="text-xs text-destructive mt-0.5 max-w-xs truncate">
{$_("admin.queues.failed_reason", { values: { reason: job.failedReason } })}
</p>
{/if}
</div>
</td>
<td class="px-4 py-3">
<Badge variant="outline" class={statusColor(job.status)}>{job.status}</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell"
>{job.attemptsMade}</td
>
<td class="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs"
>{formatDate(job.createdAt)}</td
>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
{#if job.status === "failed"}
<Button
size="sm"
variant="ghost"
aria-label={$_("admin.queues.retry")}
onclick={() => retryJob(job)}
>
<span class="icon-[ri--restart-line] h-4 w-4"></span>
</Button>
{/if}
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
aria-label={$_("admin.queues.remove")} aria-label={$_("admin.queues.retry")}
class="text-destructive hover:text-destructive hover:bg-destructive/10" onclick={() => retryJob(job)}
onclick={() => removeJob(job)}
> >
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span> <span class="icon-[ri--restart-line] h-4 w-4"></span>
</Button> </Button>
</div> {/if}
</td> <Button
</tr> size="sm"
{/each} variant="ghost"
{#if jobs.length === 0} aria-label={$_("admin.queues.remove")}
<tr> class="text-destructive hover:text-destructive hover:bg-destructive/10"
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground" onclick={() => removeJob(job)}
>{$_("admin.queues.no_jobs")}</td >
> <span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</tr> </Button>
{/if} </div>
</td>
</tr>
{/each}
{#if data.jobs.length === 0}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
>{$_("admin.queues.no_jobs")}</td
>
</tr>
{/if} {/if}
</tbody> </tbody>
</table> </table>
</div> </div>
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
{$_("admin.users.showing", {
values: {
start: data.offset + 1,
end: Math.min(data.offset + data.limit, data.total),
total: data.total,
},
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => navigate({ offset: String(Math.max(0, data.offset - data.limit)) })}
>
{$_("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => navigate({ offset: String(data.offset + data.limit) })}
>
{$_("common.next")}
</Button>
</div>
</div>
{/if}
{/if} {/if}
</div> </div>