feat(admin): paginacao na interface, edicao de utilizadores e pagina de testes
- fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais uma consulta, e as respostas podem chegar fora de ordem - pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador vazio ou negativo, nem dizer "76 a 100 de 82" - editar utilizador: papel, quota, idioma, com os apelidos que entregam naquela caixa a mostra no mesmo painel - "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem altera definicoes -- uma pagina de diagnostico que mexe naquilo que diagnostica e pior do que nao existir. As regras de cada verificacao sao funcoes puras com testes, separadas da recolha dos factos. Alguns casos que estes testes fixam: uma quota zero significa sem limite, e um racio marcaria todas as caixas ilimitadas como cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar ninguem investigar; e uma resposta de ausencia ligada com o periodo ja passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
52bf43b74f
commit
2b92abd185
16 files changed
+1431
-153
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
Lock,
|
||||
Mail,
|
||||
FlaskConical,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../i18n/provider";
|
||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
@@ -35,6 +36,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ href: "/admin/queue", label: t("admin.queueTitle"), icon: ListOrdered },
|
||||
{ href: "/admin/dmarc", label: t("admin.dmarcTitle"), icon: Activity },
|
||||
{ href: "/admin/security", label: t("admin.securityTitle"), icon: ShieldCheck },
|
||||
{ href: "/admin/tests", label: t("admin.testsTitle"), icon: FlaskConical },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
@@ -14,9 +14,14 @@ import {
|
||||
Upload,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Pencil,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Pagination } from "../../../../components/ui/Pagination";
|
||||
import { useDebounced } from "../../../../lib/useDebounced";
|
||||
import { initialsFor } from "../../../../lib/initials";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
|
||||
@@ -28,6 +33,7 @@ interface Mailbox {
|
||||
quotaMb: number;
|
||||
mfaEnabled: boolean;
|
||||
locked: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface Alias {
|
||||
@@ -45,6 +51,16 @@ interface ApiMailbox {
|
||||
quota_bytes: number;
|
||||
mfa_enrolled: boolean;
|
||||
is_active: boolean;
|
||||
locale: string | null;
|
||||
}
|
||||
|
||||
/** The envelope every server-paged list answers with. */
|
||||
interface PagedUsers {
|
||||
items: ApiMailbox[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface ApiAlias {
|
||||
@@ -79,14 +95,45 @@ export default function AdminMailboxesPage() {
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Paging and filtering are the server's job; nothing here slices a list that
|
||||
// was never fully fetched in the first place.
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageInfo, setPageInfo] = useState({ total: 0, page: 1, pageSize: 25, totalPages: 1 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState("");
|
||||
const debouncedSearch = useDebounced(search, 300);
|
||||
|
||||
// The user being edited, and the aliases that deliver to them.
|
||||
const [editing, setEditing] = useState<Mailbox | null>(null);
|
||||
const [editRole, setEditRole] = useState("USER");
|
||||
const [editQuotaMb, setEditQuotaMb] = useState(0);
|
||||
const [editLocale, setEditLocale] = useState("");
|
||||
const [editAliases, setEditAliases] = useState<Alias[] | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: "25" });
|
||||
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||
if (roleFilter) params.set("role", roleFilter);
|
||||
if (activeFilter) params.set("active", activeFilter);
|
||||
|
||||
const [mb, al] = await Promise.all([
|
||||
fetch("/api/v1/admin/mailboxes").then((r) => (r.ok ? r.json() : [])),
|
||||
fetch(`/api/v1/admin/mailboxes?${params.toString()}`).then((r) =>
|
||||
r.ok ? r.json() : { items: [], total: 0, page: 1, page_size: 25, total_pages: 1 },
|
||||
),
|
||||
fetch("/api/v1/admin/aliases").then((r) => (r.ok ? r.json() : [])),
|
||||
]);
|
||||
const users = mb as PagedUsers;
|
||||
setPageInfo({
|
||||
total: users.total ?? 0,
|
||||
page: users.page ?? 1,
|
||||
pageSize: users.page_size ?? 25,
|
||||
totalPages: users.total_pages ?? 1,
|
||||
});
|
||||
setMailboxes(
|
||||
(mb as ApiMailbox[]).map((m) => ({
|
||||
(users.items ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
email: m.email_address,
|
||||
role: m.role,
|
||||
@@ -94,6 +141,7 @@ export default function AdminMailboxesPage() {
|
||||
quotaMb: Math.round(Number(m.quota_bytes ?? 0) / 1048576),
|
||||
mfaEnabled: Boolean(m.mfa_enrolled),
|
||||
locked: !m.is_active,
|
||||
locale: m.locale ?? "",
|
||||
}))
|
||||
);
|
||||
setAliases(
|
||||
@@ -107,7 +155,13 @@ export default function AdminMailboxesPage() {
|
||||
} catch {
|
||||
setError(t("errors.loadFailed"));
|
||||
}
|
||||
}, []);
|
||||
}, [t, page, debouncedSearch, roleFilter, activeFilter]);
|
||||
|
||||
// A changed filter belongs on page one; keeping the page number lands the
|
||||
// reader on an empty page of a smaller result set.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, roleFilter, activeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -155,6 +209,62 @@ export default function AdminMailboxesPage() {
|
||||
await load();
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens one user for editing, with the aliases that deliver to them.
|
||||
*
|
||||
* An alias belongs to a domain rather than to a person - it carries a list
|
||||
* of destinations, which may point at other servers - so what is shown here
|
||||
* is the set that lands in this mailbox, not a collection the user owns.
|
||||
*/
|
||||
const openUser = async (mb: Mailbox) => {
|
||||
setEditing(mb);
|
||||
setEditRole(mb.role);
|
||||
setEditQuotaMb(mb.quotaMb);
|
||||
setEditLocale(mb.locale);
|
||||
setEditAliases(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(mb.id)}/aliases`);
|
||||
const data = res.ok ? await res.json() : [];
|
||||
setEditAliases(
|
||||
(data as ApiAlias[]).map((a) => ({
|
||||
id: a.id,
|
||||
aliasAddress: a.source_address,
|
||||
targetAddress: (a.destination_addresses ?? []).join(", "),
|
||||
domain: a.domain_name,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setEditAliases([]);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUser = async () => {
|
||||
if (!editing) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(editing.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role: editRole,
|
||||
// Stored in bytes; the field is in megabytes because that is what an
|
||||
// operator thinks in.
|
||||
quota_bytes: Math.max(0, Math.round(editQuotaMb * 1048576)),
|
||||
locale: editLocale || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message);
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAlias = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -385,6 +495,42 @@ export default function AdminMailboxesPage() {
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
{/* Filtering and paging are done by the server. */}
|
||||
<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-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
aria-label={t("ui.role")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="USER">USER</option>
|
||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
||||
</select>
|
||||
<select
|
||||
value={activeFilter}
|
||||
onChange={(e) => setActiveFilter(e.target.value)}
|
||||
aria-label={t("ui.accountStatus")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="true">{t("ui.active")}</option>
|
||||
<option value="false">{t("ui.accountLocked")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
@@ -439,6 +585,15 @@ export default function AdminMailboxesPage() {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap pr-0 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUser(mb)}
|
||||
title={t("common.edit")}
|
||||
aria-label={t("common.edit")}
|
||||
className={`${iconButton} inline-flex`}
|
||||
>
|
||||
<Pencil className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleLock(mb.id, mb.locked)}
|
||||
@@ -463,6 +618,105 @@ export default function AdminMailboxesPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={pageInfo.page}
|
||||
pageSize={pageInfo.pageSize}
|
||||
total={pageInfo.total}
|
||||
totalPages={pageInfo.totalPages}
|
||||
onPage={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The user being edited, with the aliases that land in their mailbox. */}
|
||||
{editing && (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-16">
|
||||
<div className="w-full max-w-[560px] rounded-2xl bg-dark-panel p-5 shadow-edge">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="break-words text-[17px] font-medium leading-tight text-slate-100">
|
||||
{editing.email}
|
||||
</h2>
|
||||
<p className="mt-1 text-[12px] text-slate-400">{t("ui.editUser")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
aria-label={t("common.cancel")}
|
||||
className={`${iconButton} inline-flex`}
|
||||
>
|
||||
<X className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<label htmlFor="edit-role" className={fieldLabel}>{t("ui.role")}</label>
|
||||
<select
|
||||
id="edit-role"
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
className={input}
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-quota" className={fieldLabel}>{t("ui.quotaMb")}</label>
|
||||
<input
|
||||
id="edit-quota"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editQuotaMb}
|
||||
onChange={(e) => setEditQuotaMb(Number(e.target.value))}
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-locale" className={fieldLabel}>{t("settings.language")}</label>
|
||||
<select
|
||||
id="edit-locale"
|
||||
value={editLocale}
|
||||
onChange={(e) => setEditLocale(e.target.value)}
|
||||
className={input}
|
||||
>
|
||||
<option value="">{t("common.none")}</option>
|
||||
<option value="pt-BR">Portugues (BR)</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Espanol</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="text-xs text-slate-400">{t("admin.aliasesForUser")}</div>
|
||||
{editAliases === null ? (
|
||||
<div className="mt-2 text-[12.5px] text-slate-500">{t("common.loading")}</div>
|
||||
) : editAliases.length === 0 ? (
|
||||
<div className="mt-2 text-[12.5px] text-slate-500">{t("admin.noAliasesForUser")}</div>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{editAliases.map((a) => (
|
||||
<li key={a.id} className="break-all font-mono text-[12px] text-slate-200">
|
||||
{a.aliasAddress}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => void saveUser()} disabled={saving}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCw } from "lucide-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.
|
||||
@@ -33,6 +35,10 @@ interface QueueJob {
|
||||
interface QueueSummary {
|
||||
by_status: Record<string, number>;
|
||||
recent: QueueJob[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,10 +61,28 @@ export default function AdminQueuePage() {
|
||||
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 res = await fetch("/api/v1/admin/queue");
|
||||
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);
|
||||
@@ -70,7 +94,7 @@ export default function AdminQueuePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, [t, page, debouncedSearch, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -134,6 +158,33 @@ export default function AdminQueuePage() {
|
||||
<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-white/[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-white/[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>
|
||||
@@ -186,6 +237,15 @@ export default function AdminQueuePage() {
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { PlayCircle, Server, User as UserIcon, Search } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { useDebounced } from "../../../../lib/useDebounced";
|
||||
|
||||
/**
|
||||
* Checks an operator runs by hand, against the server or one user.
|
||||
*
|
||||
* Everything here reads state; nothing sends mail or changes a setting. A
|
||||
* diagnostic screen that alters what it is diagnosing is worse than no screen.
|
||||
*/
|
||||
|
||||
interface Check {
|
||||
key: string;
|
||||
status: "PASS" | "WARN" | "FAIL" | "INFO";
|
||||
subject?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
scope: string;
|
||||
subject?: string;
|
||||
status: Check["status"];
|
||||
checks: Check[];
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email_address: string;
|
||||
}
|
||||
|
||||
function variantFor(status: Check["status"]): "success" | "warning" | "danger" | "neutral" {
|
||||
if (status === "PASS") return "success";
|
||||
if (status === "WARN") return "warning";
|
||||
if (status === "FAIL") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export default function AdminTestsPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
const [serverReport, setServerReport] = useState<Report | null>(null);
|
||||
const [userReport, setUserReport] = useState<Report | null>(null);
|
||||
const [runningServer, setRunningServer] = useState(false);
|
||||
const [runningUser, setRunningUser] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [selected, setSelected] = useState("");
|
||||
const debouncedSearch = useDebounced(search, 300);
|
||||
|
||||
// The picker asks the server for a short page rather than pulling every
|
||||
// user into the browser to filter them here.
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ page: "1", page_size: "20" });
|
||||
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||
const res = await fetch(`/api/v1/admin/mailboxes?${params.toString()}`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setUsers((data.items ?? []) as UserRow[]);
|
||||
} catch {
|
||||
setUsers([]);
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
const runServer = async () => {
|
||||
setRunningServer(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/diagnostics/server");
|
||||
if (!res.ok) throw new Error();
|
||||
setServerReport(await res.json());
|
||||
} catch {
|
||||
setServerReport(null);
|
||||
setError(t("errors.loadFailed"));
|
||||
} finally {
|
||||
setRunningServer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runUser = async () => {
|
||||
if (!selected) return;
|
||||
setRunningUser(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/diagnostics/user/${encodeURIComponent(selected)}`);
|
||||
if (!res.ok) throw new Error();
|
||||
setUserReport(await res.json());
|
||||
} catch {
|
||||
setUserReport(null);
|
||||
setError(t("errors.loadFailed"));
|
||||
} finally {
|
||||
setRunningUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const panel = "flex flex-col gap-3.5 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
|
||||
|
||||
const renderReport = (report: Report | null) =>
|
||||
report === null ? null : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Badge variant={variantFor(report.status)}>{report.status}</Badge>
|
||||
<span className="text-[12px] text-slate-400">
|
||||
{t("admin.checksRun", { count: report.checks.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{report.checks.map((c, i) => (
|
||||
<li
|
||||
key={`${c.key}-${c.subject ?? i}`}
|
||||
className="flex flex-wrap items-center gap-2.5 rounded-xl bg-dark-card px-3.5 py-2.5 shadow-edge"
|
||||
>
|
||||
<Badge variant={variantFor(c.status)} className="whitespace-nowrap">
|
||||
{c.status}
|
||||
</Badge>
|
||||
{/* The key is translated here rather than sent as a finished
|
||||
sentence, so the page reads in the interface's language. */}
|
||||
<span className="min-w-0 flex-1 break-words text-[13px] text-slate-200">
|
||||
{t(`checks.${c.key}`)}
|
||||
{c.subject ? <span className="text-slate-400"> — {c.subject}</span> : null}
|
||||
</span>
|
||||
{c.detail ? (
|
||||
<span className="whitespace-nowrap font-mono text-[11.5px] text-slate-400">{c.detail}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
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="min-w-0">
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.testsTitle")}</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.testsSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
|
||||
)}
|
||||
|
||||
<section className={panel}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<Server className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.serverChecks")}
|
||||
</h2>
|
||||
<Button variant="primary" size="sm" onClick={() => void runServer()} disabled={runningServer}>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
<span>{runningServer ? t("common.loading") : t("admin.runChecks")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{renderReport(serverReport)}
|
||||
</section>
|
||||
|
||||
<section className={panel}>
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<UserIcon className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.userChecks")}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="relative min-w-[200px] 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-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
aria-label={t("admin.userChecks")}
|
||||
className="min-w-[220px] rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.selectUser")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.email_address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void runUser()}
|
||||
disabled={runningUser || !selected}
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
<span>{runningUser ? t("common.loading") : t("admin.runChecks")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{userReport?.subject ? (
|
||||
<div className="break-all text-[12.5px] text-slate-400">{userReport.subject}</div>
|
||||
) : null}
|
||||
{renderReport(userReport)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user