Files
LambdaMail/services/webmail/src/app/(admin)/admin/queue/page.tsx
T
Lucas RayanandClaude Sonnet 5.5 e667843b59 feat(webmail): the new interface - logo, dark and light themes, command menu
A mark (a lambda on an ultramarine tile with the flap of an envelope), the
favicon, home-screen icons and an install manifest. Every colour is a variable
defined once per theme, and the Tailwind scales the screens already use read
them, so dark (the default) and light are a change of values and not of
classes. Geist is served by the app itself. Ctrl+K opens a command menu for
folders, actions, accounts and appearance, and typing something that matches no
command searches the mail; c writes a message and / goes to the search field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:33:15 -03:00

253 lines
10 KiB
TypeScript

"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<string, number>;
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<QueueSummary | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.queueTitle")}</h1>
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.queueSubtitle")}</p>
</div>
<Button variant="secondary" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
<span>{t("common.refresh")}</span>
</Button>
</div>
{/* Counts per status, straight from the by_status aggregate. */}
{summary && Object.keys(summary.by_status).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(summary.by_status).map(([status, count]) => (
<span
key={status}
className="flex items-center gap-2.5 rounded-xl bg-dark-panel px-3.5 py-2.5 shadow-edge"
>
<span className="text-[19px] font-medium leading-none tabular-nums text-slate-100">{count}</span>
<span className="text-xs text-slate-400">{status}</span>
</span>
))}
</div>
)}
{message && (
<div className="rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">{message}</div>
)}
{error && (
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
)}
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative min-w-[220px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input
type="search"
value={search}
onChange={(e) => 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"
/>
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label={t("common.status")}
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.all")}</option>
{/* Taken from the aggregate, so the choices are the states this
queue actually contains rather than a list invented here. */}
{Object.keys(summary?.by_status ?? {}).sort().map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
{jobs.length === 0 && !loading ? (
<div className="p-8 text-center text-[13px] text-slate-400">{t("admin.queueEmpty")}</div>
) : (
<div className="overflow-x-auto">
<table className="lm-table">
<thead>
<tr>
<th className="pl-0">{t("ui.jobId")}</th>
<th>{t("ui.sender")}</th>
<th>{t("ui.recipient")}</th>
<th>{t("ui.destination")}</th>
<th>{t("ui.attempts")}</th>
<th>{t("ui.lastError")}</th>
<th>{t("common.status")}</th>
<th className="pr-0 text-right">{t("ui.actions")}</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr key={j.id}>
<td className="pl-0 font-mono text-[12.5px] text-slate-100">{j.id.slice(0, 8)}</td>
<td className="max-w-[220px] break-words text-[12.5px] text-slate-300">{j.envelope_from}</td>
<td className="max-w-[220px] break-words text-[12.5px] text-slate-300">{j.envelope_to}</td>
<td className="break-words text-[12.5px] text-slate-300">{j.destination_domain}</td>
<td className="whitespace-nowrap text-[12.5px] tabular-nums text-slate-300">{j.attempt}</td>
{/* The diagnostic wraps instead of being truncated: a queue
screen exists to show why a message did not leave. */}
<td className="max-w-[250px] text-xs leading-relaxed text-slate-400">
{j.last_error
? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}`
: t("common.none")}
</td>
<td>
<Badge variant={statusVariant(j.status)} className="whitespace-nowrap">
{j.status}
</Badge>
</td>
<td className="whitespace-nowrap pr-0 text-right">
<Button variant="secondary" size="sm" onClick={() => void act(j.id, "retry")}>
{t("admin.retryJob")}
</Button>{" "}
<Button variant="ghost" size="sm" onClick={() => void act(j.id, "cancel")}>
{t("admin.cancelJob")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination
page={summary?.page ?? 1}
pageSize={summary?.page_size ?? 25}
total={summary?.total ?? 0}
totalPages={summary?.total_pages ?? 1}
onPage={setPage}
busy={loading}
/>
</div>
</div>
);
}