"use client"; import React, { useCallback, useEffect, useState } from "react"; import { RefreshCw, Search } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Badge } from "../../../../components/ui/Badge"; import { Button } from "../../../../components/ui/Button"; import { Pagination } from "../../../../components/ui/Pagination"; import { useDebounced } from "../../../../lib/useDebounced"; /** * The outbound queue, read from the database. * * This screen used to be a fixture: two invented jobs ("job-101", * user@example.com -> partner@remotehost.com) held in component state, whose * Retry and Freeze buttons only edited that local array. It showed the same two * rows on every deployment, never displayed a real stuck message, and reported * success for actions that had not happened. /api/v1/admin/queue was already * serving the real thing. */ /** Mirrors the outbound_jobs row shape returned by the queue endpoint. */ interface QueueJob { id: string; envelope_from: string; envelope_to: string; destination_domain: string; status: string; attempt: number; next_attempt_at: string | null; last_smtp_code: number | null; last_error: string | null; } interface QueueSummary { by_status: Record; recent: QueueJob[]; total: number; page: number; page_size: number; total_pages: number; } /** * Only failure states earn a warning colour; the rest are ordinary progress. * * These used to return "badge-danger", "badge-warning" and "badge-verified" - * three class names no stylesheet in this project ever defined, so every status * rendered as unstyled text. They are Badge variants now, which exist. */ function statusVariant(status: string): "danger" | "warning" | "neutral" { if (status === "BOUNCED" || status === "FROZEN") return "danger"; if (status === "DEFERRED") return "warning"; return "neutral"; } export default function AdminQueuePage() { const t = useTranslations(); const [summary, setSummary] = useState(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); // Filtering and paging happen in the database. Holding them here and // slicing in the browser would still have fetched only the first page, // which is the whole reason a backed-up queue could not be inspected. const [page, setPage] = useState(1); const [search, setSearch] = useState(""); const [status, setStatus] = useState(""); const debouncedSearch = useDebounced(search, 300); // A new search belongs on page one; keeping the old page number lands the // reader on an empty page of a smaller result set. useEffect(() => { setPage(1); }, [debouncedSearch, status]); const load = useCallback(async () => { setLoading(true); try { const params = new URLSearchParams({ page: String(page), page_size: "25" }); if (debouncedSearch) params.set("search", debouncedSearch); if (status) params.set("status", status); const res = await fetch(`/api/v1/admin/queue?${params.toString()}`); if (!res.ok) throw new Error(); setSummary(await res.json()); setError(null); } catch { // Left null so the table renders empty rather than showing stale or // invented rows while the service is unreachable. setSummary(null); setError(t("errors.loadFailed")); } finally { setLoading(false); } }, [t, page, debouncedSearch, status]); useEffect(() => { void load(); }, [load]); /** * Retry and cancel are the two actions the service actually implements. * The old "Freeze" button named a third that has never existed anywhere in * the backend, so pressing it changed nothing but the colour of a cell. */ const act = async (id: string, action: "retry" | "cancel") => { setMessage(null); setError(null); const res = await fetch(`/api/v1/admin/queue/${encodeURIComponent(id)}/${action}`, { method: "POST", }); if (!res.ok) { setError(t("errors.serverError")); return; } setMessage(t(action === "retry" ? "admin.jobRetried" : "admin.jobCancelled", { id })); // Re-read rather than patching local state, so what is on screen is what // the queue now holds. await load(); }; const jobs = summary?.recent ?? []; return (

{t("admin.queueTitle")}

{t("admin.queueSubtitle")}

{/* Counts per status, straight from the by_status aggregate. */} {summary && Object.keys(summary.by_status).length > 0 && (
{Object.entries(summary.by_status).map(([status, count]) => ( {count} {status} ))}
)} {message && (
{message}
)} {error && (
{error}
)}
setSearch(e.target.value)} placeholder={t("ui.searchPlaceholder")} aria-label={t("ui.searchPlaceholder")} className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500" />
{jobs.length === 0 && !loading ? (
{t("admin.queueEmpty")}
) : (
{jobs.map((j) => ( {/* The diagnostic wraps instead of being truncated: a queue screen exists to show why a message did not leave. */} ))}
{t("ui.jobId")} {t("ui.sender")} {t("ui.recipient")} {t("ui.destination")} {t("ui.attempts")} {t("ui.lastError")} {t("common.status")} {t("ui.actions")}
{j.id.slice(0, 8)} {j.envelope_from} {j.envelope_to} {j.destination_domain} {j.attempt} {j.last_error ? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}` : t("common.none")} {j.status} {" "}
)}
); }