2026-03-08 18:25:09 +01:00
|
|
|
<script lang="ts">
|
|
|
|
|
import { invalidateAll } from "$app/navigation";
|
|
|
|
|
import { toast } from "svelte-sonner";
|
|
|
|
|
import { _ } from "svelte-i18n";
|
|
|
|
|
import {
|
|
|
|
|
getAdminQueueJobs,
|
|
|
|
|
adminRetryJob,
|
|
|
|
|
adminRemoveJob,
|
|
|
|
|
adminPauseQueue,
|
|
|
|
|
adminResumeQueue,
|
|
|
|
|
} from "$lib/services";
|
|
|
|
|
import { Button } from "$lib/components/ui/button";
|
|
|
|
|
import { Badge } from "$lib/components/ui/badge";
|
2026-03-08 18:32:39 +01:00
|
|
|
import type { Job } from "$lib/services";
|
2026-03-08 18:25:09 +01:00
|
|
|
|
|
|
|
|
const { data } = $props();
|
|
|
|
|
|
|
|
|
|
const queues = $derived(data.queues);
|
2026-03-08 18:32:39 +01:00
|
|
|
|
2026-03-08 18:36:50 +01:00
|
|
|
// 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);
|
2026-03-08 18:25:09 +01:00
|
|
|
let selectedStatus = $state<string | null>(null);
|
|
|
|
|
let jobs = $state<Job[]>([]);
|
|
|
|
|
let loadingJobs = $state(false);
|
|
|
|
|
let togglingQueue = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const STATUS_FILTERS = [
|
|
|
|
|
{ value: null, label: $_("admin.queues.status_all") },
|
|
|
|
|
{ value: "waiting", label: $_("admin.queues.status_waiting") },
|
|
|
|
|
{ value: "active", label: $_("admin.queues.status_active") },
|
|
|
|
|
{ value: "completed", label: $_("admin.queues.status_completed") },
|
|
|
|
|
{ value: "failed", label: $_("admin.queues.status_failed") },
|
|
|
|
|
{ value: "delayed", label: $_("admin.queues.status_delayed") },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
async function loadJobs() {
|
|
|
|
|
if (!selectedQueue) return;
|
|
|
|
|
loadingJobs = true;
|
|
|
|
|
try {
|
|
|
|
|
jobs = await getAdminQueueJobs(selectedQueue, selectedStatus ?? undefined, 50, 0);
|
|
|
|
|
} finally {
|
|
|
|
|
loadingJobs = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function selectQueue(name: string) {
|
2026-03-08 18:36:50 +01:00
|
|
|
selectedQueueOverride = name;
|
2026-03-08 18:25:09 +01:00
|
|
|
selectedStatus = null;
|
|
|
|
|
await loadJobs();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function selectStatus(status: string | null) {
|
|
|
|
|
selectedStatus = status;
|
|
|
|
|
await loadJobs();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function retryJob(job: Job) {
|
|
|
|
|
try {
|
|
|
|
|
await adminRetryJob(job.queue, job.id);
|
|
|
|
|
toast.success($_("admin.queues.retry_success"));
|
|
|
|
|
await loadJobs();
|
|
|
|
|
await refreshCounts();
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error($_("admin.queues.retry_error"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeJob(job: Job) {
|
|
|
|
|
try {
|
|
|
|
|
await adminRemoveJob(job.queue, job.id);
|
|
|
|
|
toast.success($_("admin.queues.remove_success"));
|
|
|
|
|
jobs = jobs.filter((j) => j.id !== job.id);
|
|
|
|
|
await refreshCounts();
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error($_("admin.queues.remove_error"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function toggleQueue(queueName: string, isPaused: boolean) {
|
|
|
|
|
togglingQueue = queueName;
|
|
|
|
|
try {
|
|
|
|
|
if (isPaused) {
|
|
|
|
|
await adminResumeQueue(queueName);
|
|
|
|
|
toast.success($_("admin.queues.resume_success"));
|
|
|
|
|
} else {
|
|
|
|
|
await adminPauseQueue(queueName);
|
|
|
|
|
toast.success($_("admin.queues.pause_success"));
|
|
|
|
|
}
|
|
|
|
|
await refreshCounts();
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error(isPaused ? $_("admin.queues.resume_error") : $_("admin.queues.pause_error"));
|
|
|
|
|
} finally {
|
|
|
|
|
togglingQueue = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshCounts() {
|
|
|
|
|
await invalidateAll();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (selectedQueue) loadJobs();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function statusColor(status: string): string {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case "active":
|
|
|
|
|
return "text-blue-500 border-blue-500/30 bg-blue-500/10";
|
|
|
|
|
case "completed":
|
|
|
|
|
return "text-green-500 border-green-500/30 bg-green-500/10";
|
|
|
|
|
case "failed":
|
|
|
|
|
return "text-destructive border-destructive/30 bg-destructive/10";
|
|
|
|
|
case "delayed":
|
|
|
|
|
return "text-yellow-500 border-yellow-500/30 bg-yellow-500/10";
|
|
|
|
|
default:
|
|
|
|
|
return "text-muted-foreground border-border/40 bg-muted/20";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(iso: string | null): string {
|
|
|
|
|
if (!iso) return "—";
|
|
|
|
|
return new Date(iso).toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="py-3 sm:py-6 sm:pl-6">
|
|
|
|
|
<div class="flex items-center justify-between mb-6 px-3 sm:px-0">
|
|
|
|
|
<h1 class="text-2xl font-bold">{$_("admin.queues.title")}</h1>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Queue cards -->
|
|
|
|
|
<div class="flex flex-wrap gap-3 mb-6 px-3 sm:px-0">
|
|
|
|
|
{#each queues as queue (queue.name)}
|
|
|
|
|
{@const isSelected = selectedQueue === queue.name}
|
2026-03-08 18:35:16 +01:00
|
|
|
<div
|
|
|
|
|
role="button"
|
|
|
|
|
tabindex="0"
|
2026-03-08 18:25:09 +01:00
|
|
|
class={`flex-1 min-w-48 rounded-lg border p-4 text-left transition-colors cursor-pointer ${
|
|
|
|
|
isSelected
|
|
|
|
|
? "border-primary/50 bg-primary/5"
|
|
|
|
|
: "border-border/40 bg-card hover:border-border/70"
|
|
|
|
|
}`}
|
|
|
|
|
onclick={() => selectQueue(queue.name)}
|
2026-03-08 18:35:16 +01:00
|
|
|
onkeydown={(e) => e.key === "Enter" && selectQueue(queue.name)}
|
2026-03-08 18:25:09 +01:00
|
|
|
aria-pressed={isSelected}
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center justify-between mb-3">
|
|
|
|
|
<span class="font-semibold capitalize">{queue.name}</span>
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
{#if queue.isPaused}
|
|
|
|
|
<Badge variant="outline" class="text-yellow-600 border-yellow-500/40 bg-yellow-500/10"
|
|
|
|
|
>{$_("admin.queues.paused_badge")}</Badge
|
|
|
|
|
>
|
|
|
|
|
{/if}
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
class="h-6 px-2 text-xs"
|
|
|
|
|
disabled={togglingQueue === queue.name}
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleQueue(queue.name, queue.isPaused);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{queue.isPaused ? $_("admin.queues.resume") : $_("admin.queues.pause")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-wrap gap-2 text-xs">
|
|
|
|
|
{#if queue.counts.waiting > 0}
|
|
|
|
|
<span class="text-muted-foreground">{queue.counts.waiting} waiting</span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if queue.counts.active > 0}
|
|
|
|
|
<span class="text-blue-500">{queue.counts.active} active</span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if queue.counts.completed > 0}
|
|
|
|
|
<span class="text-green-500">{queue.counts.completed} completed</span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if queue.counts.failed > 0}
|
|
|
|
|
<span class="text-destructive font-medium">{queue.counts.failed} failed</span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if queue.counts.delayed > 0}
|
|
|
|
|
<span class="text-yellow-500">{queue.counts.delayed} delayed</span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if Object.values(queue.counts).every((v) => v === 0)}
|
|
|
|
|
<span class="text-muted-foreground">empty</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
2026-03-08 18:35:16 +01:00
|
|
|
</div>
|
2026-03-08 18:25:09 +01:00
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if selectedQueue}
|
|
|
|
|
<!-- Status filter tabs -->
|
|
|
|
|
<div class="flex gap-1 mb-4 px-3 sm:px-0 flex-wrap">
|
|
|
|
|
{#each STATUS_FILTERS as f (f.value ?? "all")}
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant={selectedStatus === f.value ? "default" : "outline"}
|
|
|
|
|
onclick={() => selectStatus(f.value)}
|
|
|
|
|
>
|
|
|
|
|
{f.label}
|
|
|
|
|
</Button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Jobs table -->
|
|
|
|
|
<div class="sm:rounded-lg border-y sm:border border-border/40 overflow-x-auto">
|
|
|
|
|
<table class="w-full text-sm">
|
|
|
|
|
<thead class="bg-muted/30">
|
|
|
|
|
<tr>
|
|
|
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
|
|
|
|
>{$_("admin.queues.col_id")}</th
|
|
|
|
|
>
|
|
|
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
|
|
|
|
>{$_("admin.queues.col_name")}</th
|
|
|
|
|
>
|
|
|
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground"
|
|
|
|
|
>{$_("admin.queues.col_status")}</th
|
|
|
|
|
>
|
2026-03-08 18:32:39 +01:00
|
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">
|
2026-03-08 18:25:09 +01:00
|
|
|
{$_("admin.queues.col_attempts")}
|
|
|
|
|
</th>
|
2026-03-08 18:32:39 +01:00
|
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden lg:table-cell">
|
2026-03-08 18:25:09 +01:00
|
|
|
{$_("admin.queues.col_created")}
|
|
|
|
|
</th>
|
|
|
|
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground"
|
|
|
|
|
>{$_("admin.queues.col_actions")}</th
|
|
|
|
|
>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody class="divide-y divide-border/30">
|
|
|
|
|
{#if loadingJobs}
|
|
|
|
|
<tr>
|
|
|
|
|
<td colspan="6" class="px-4 py-8 text-center text-muted-foreground"
|
|
|
|
|
>{$_("common.loading")}</td
|
|
|
|
|
>
|
|
|
|
|
</tr>
|
|
|
|
|
{:else}
|
|
|
|
|
{#each jobs as job (job.id)}
|
|
|
|
|
<tr class="hover:bg-muted/10 transition-colors">
|
|
|
|
|
<td class="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</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
|
|
|
|
|
>
|
|
|
|
|
<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
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
aria-label={$_("admin.queues.remove")}
|
|
|
|
|
class="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
|
|
|
onclick={() => removeJob(job)}
|
|
|
|
|
>
|
|
|
|
|
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{/each}
|
|
|
|
|
{#if 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>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|