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:
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user