import { useEffect, useState, type ReactNode } from "react"; import { Link } from "wouter"; import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, ShieldCheck, Users } from "lucide-react"; import { legacyProtocolsOff } from "@/jmap/client"; import { useAppName } from "@/lib/brand"; import { adminSections, dashboardCards, type DashboardCard } from "@/lib/admin/adminAccess"; import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/admin/adminDashboard"; import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime"; import { formatSize } from "@/lib/format"; import { t } from "@/lib/i18n"; import { Empty } from "@/ui/misc"; import { useSession } from "@/store/session"; import { usePermissions } from "./usePermissions"; /** Loading, a number, refused by the server (the card goes), or failed (the card says so). */ type Loaded = { state: "loading" } | { state: "ok"; value: T } | { state: "refused" } | { state: "error" }; const LOADING = { state: "loading" } as const; async function settle(work: Promise): Promise> { try { return { state: "ok", value: await work }; } catch (err) { return isRefused(err) ? { state: "refused" } : { state: "error" }; } } /** * Administration's landing page: a card for each number the role may read. * * Which cards appear is `dashboardCards`, from the permissions Stalwart * reported; what they count is whatever Stalwart answers for this account, * which for a tenant administrator is their tenancy. A card whose feed the * server refuses anyway -- the metric history on a Community server -- is left * off rather than shown broken, and one that could not be loaded says so. */ export function AdminDashboard() { const perms = usePermissions(); const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null); const legacyOff = useSession((s) => legacyProtocolsOff(s.session, s.accountId)); const app = useAppName(); const cards = dashboardCards(perms); const sections = adminSections(perms); const [reload, setReload] = useState(0); const [users, setUsers] = useState>(LOADING); const [domains, setDomains] = useState>(LOADING); const [pending, setPending] = useState>(LOADING); const [messages, setMessages] = useState>(LOADING); const key = cards.join(","); useEffect(() => { let canceled = false; const into = (set: (v: Loaded) => void, work: () => Promise) => { set(LOADING); void settle(work()).then((v) => !canceled && set(v)); }; if (cards.includes("users")) into(setUsers, () => countObjects("Account")); if (cards.includes("domains")) into(setDomains, () => countObjects("Domain")); if (cards.includes("pending")) into(setPending, () => countObjects("QueuedMessage")); if (cards.includes("received")) into(setMessages, async () => summarizeMetrics(await loadMetrics(new Date(Date.now() - DASHBOARD_WINDOW_MS)))); return () => { canceled = true; }; // `key` is the card list's contents; the array itself is new every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, reload]); const number = new Intl.NumberFormat(resolvedLocale()); const count = (v: Loaded) => (v.state === "ok" ? number.format(v.value) : undefined); const stats = messages.state === "ok" ? messages.value : null; const unrecorded = stats !== null && !stats.recorded; const every: Array<{ id: DashboardCard; loaded: Loaded; node: ReactNode }> = [ { id: "users", loaded: users, node: } label={t("Users")} value={count(users)} caption={t("User accounts")} loaded={users} href={sections.includes("accounts") ? "/admin/accounts" : undefined} /> }, { id: "domains", loaded: domains, node: } label={t("Domains")} value={count(domains)} caption={t("Mail domains")} loaded={domains} href={sections.includes("domains") ? "/admin/domains" : undefined} /> }, { id: "pending", loaded: pending, node: } label={t("Pending")} value={count(pending)} caption={t("Waiting in the delivery queue")} loaded={pending} /> }, { id: "memory", loaded: messages, node: ( } label={t("Server memory")} value={stats?.memory ? formatSize(stats.memory.bytes) : undefined} caption={stats?.memory ? t("As of {time}", { time: formatDayMonthTime(new Date(stats.memory.at)) }) : unrecorded ? t("Not recorded on this server") : t("Last 24 hours")} loaded={messages} /> ), }, { id: "received", loaded: messages, node: } label={t("Received")} value={stats?.recorded ? number.format(stats.received) : undefined} caption={unrecorded ? t("Not recorded on this server") : t("Last 24 hours")} loaded={messages} /> }, { id: "sent", loaded: messages, node: } label={t("Sent")} value={stats?.recorded ? number.format(stats.sent) : undefined} caption={unrecorded ? t("Not recorded on this server") : t("Last 24 hours")} loaded={messages} /> }, ]; const shown = every.filter((c) => cards.includes(c.id) && c.loaded.state !== "refused"); const columns = balancedColumns(shown.length); return ( // The column counts are set here rather than on the grid, so the heading -- // and its Refresh button -- end where the cards do.

{t("Dashboard")}

{t("The numbers your role can see, as the server reports them.")}

{legacyOff && ( // INBUXA legacy-protocols LP-18: while it's off for the organization

{t("Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.", { app })}

)} {shown.length ? (
{shown.map((c) =>
{c.node}
)}
) : ( } title={t("Nothing to show")} /> )} {/* The line between the two interfaces, said where someone looking for more numbers will be: this is a glance, and operating the server is Stalwart's own administration. The link is the operator's to give. */}

{t("Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.")} {adminUrl && ( <> {" "} {t("Open INBUXA Admin")} )}

); } function Card({ icon, label, value, caption, loaded, href }: { icon: ReactNode; label: string; value: string | undefined; caption: string; loaded: Loaded; href?: string }) { const body = ( <>
{label}
{loaded.state === "loading" ? : (value ?? "—")}
{loaded.state === "error" ? t("Could not be loaded") : caption}
); return href ? ( {body} ) : (
{body}
); }