Open Administration on a dashboard of what the role can read

Administration used to open on its first section. It opens on a grid of
cards now: users, domains, messages waiting in the delivery queue, server
memory, and the last 24 hours' received and sent. Each card is there only
when the role holds what its number needs -- a count is a query, the
metric history a query and a get -- so a helpdesk role that reads accounts
and domains sees those two cards and nothing about the server.

What the cards count is whatever Stalwart answers for the signed-in
account, which scopes a tenant administrator's accounts, domains and queue
to the tenancy. The metric history has no tenant in it, and Stalwart's
Tenant Administrator role does not hold it, so a tenant's dashboard is
users, domains and pending.

The history is Enterprise-only and switched off by default. A server that
refuses it leaves those cards off; one that records nothing says so rather
than showing zeroes. Received and sent add up the queue counters Stalwart's
own dashboard uses, filtered with the comparison names the live server
accepts (a bare timestamp is unsupportedFilter). The column count follows
the number of cards so rows stay even, and falls back by the grid's own
width rather than the window's.

The server's test for whether an account is offered Administration matches
the client's again, now that a count is enough. The mock answers the queue
and an hourly history ending in the current hour; MOCK_METRICS=off refuses
the history as Community does, a tenant administrator gets the queue, and
helpdesk reads domains, as the demo's does.

ROADMAP and FEATURES said reporting and queues were out of scope; they say
the dashboard reads a handful of numbers and that managing queues, logs
and settings stays out. KNOWN-ISSUES records what was settled on the live
server and what was only read from source.

Fourteen new strings, in all nine catalogues.
This commit is contained in:
2026-09-15 08:06:59 -07:00
parent bb8d6eb92d
commit 0054b8a3ce
28 changed files with 838 additions and 37 deletions
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter";
import { ArrowDownToLine, ArrowUpFromLine, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summariseMetrics, type MessageStats } from "@/lib/adminDashboard";
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
import { formatSize } from "@/lib/format";
import { t } from "@/lib/i18n";
import { Empty } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
/** Loading, a number, refused by the server (the card goes), or failed (the card says so). */
type Loaded<T> = { state: "loading" } | { state: "ok"; value: T } | { state: "refused" } | { state: "error" };
const LOADING = { state: "loading" } as const;
async function settle<T>(work: Promise<T>): Promise<Loaded<T>> {
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 cards = dashboardCards(perms);
const sections = adminSections(perms);
const [reload, setReload] = useState(0);
const [users, setUsers] = useState<Loaded<number>>(LOADING);
const [domains, setDomains] = useState<Loaded<number>>(LOADING);
const [pending, setPending] = useState<Loaded<number>>(LOADING);
const [messages, setMessages] = useState<Loaded<MessageStats>>(LOADING);
const key = cards.join(",");
useEffect(() => {
let cancelled = false;
const into = <T,>(set: (v: Loaded<T>) => void, work: () => Promise<T>) => {
set(LOADING);
void settle(work()).then((v) => !cancelled && 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 () => summariseMetrics(await loadMetrics(new Date(Date.now() - DASHBOARD_WINDOW_MS))));
return () => {
cancelled = 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<number>) => (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<unknown>; node: ReactNode }> = [
{ id: "users", loaded: users, node: <Card icon={<Users size={20} />} label={t("Users")} value={count(users)} caption={t("User accounts")} loaded={users} href={sections.includes("accounts") ? "/admin/accounts" : undefined} /> },
{ id: "domains", loaded: domains, node: <Card icon={<Globe size={20} />} label={t("Domains")} value={count(domains)} caption={t("Mail domains")} loaded={domains} href={sections.includes("domains") ? "/admin/domains" : undefined} /> },
{ id: "pending", loaded: pending, node: <Card icon={<Hourglass size={20} />} label={t("Pending")} value={count(pending)} caption={t("Waiting in the delivery queue")} loaded={pending} /> },
{
id: "memory",
loaded: messages,
node: (
<Card
icon={<MemoryStick size={20} />}
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: <Card icon={<ArrowDownToLine size={20} />} 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: <Card icon={<ArrowUpFromLine size={20} />} 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.
<div className="admin-dashboard" style={{ "--cols-wide": columns.wide, "--cols-mid": columns.mid } as React.CSSProperties}>
<div className="admin-head">
<div className="grow">
<h1>{t("Dashboard")}</h1>
<p className="lead">{t("The numbers your role can see, as the server reports them.")}</p>
</div>
<button className="icon-btn" aria-label={t("Refresh")} title={t("Refresh")} onClick={() => setReload((n) => n + 1)}>
<RefreshCw size={18} />
</button>
</div>
{shown.length ? (
<div className="admin-cards-wrap">
<div className="admin-cards">
{shown.map((c) => <div key={c.id}>{c.node}</div>)}
</div>
</div>
) : (
<Empty icon={<LayoutDashboard size={32} />} title={t("Nothing to show")} />
)}
</div>
);
}
function Card({ icon, label, value, caption, loaded, href }: { icon: ReactNode; label: string; value: string | undefined; caption: string; loaded: Loaded<unknown>; href?: string }) {
const body = (
<>
<div className="admin-card-top">
<span className="admin-card-icon" aria-hidden="true">{icon}</span>
<span className="admin-card-label">{label}</span>
</div>
<div className="admin-card-value" aria-busy={loaded.state === "loading"}>
{loaded.state === "loading" ? <span className="admin-card-placeholder" /> : (value ?? "—")}
</div>
<div className="hint">{loaded.state === "error" ? t("Could not be loaded") : caption}</div>
</>
);
return href ? (
<Link href={href} className="admin-card link">{body}</Link>
) : (
<div className="admin-card">{body}</div>
);
}
+2 -1
View File
@@ -1,11 +1,12 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Globe, User } from "lucide-react";
import { Globe, LayoutDashboard, User } from "lucide-react";
import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { t } from "@/lib/i18n";
import { usePermissions } from "./usePermissions";
export const ADMIN_SECTIONS: Record<AdminSection, { group: string; label: string; icon: ReactNode }> = {
dashboard: { group: "Overview", label: "Dashboard", icon: <LayoutDashboard size={20} /> },
accounts: { group: "Directory", label: "Accounts", icon: <User size={20} /> },
domains: { group: "Mail", label: "Domains", icon: <Globe size={20} /> },
};
+4 -2
View File
@@ -2,11 +2,13 @@ import type { ReactNode } from "react";
import { Redirect } from "wouter";
import { adminSections, type AdminSection } from "@/lib/adminAccess";
import { AccountsAdmin } from "./AccountsAdmin";
import { AdminDashboard } from "./AdminDashboard";
import { DomainsAdmin } from "./DomainsAdmin";
import { currentAdminSection } from "./AdminNav";
import { usePermissions } from "./usePermissions";
const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
dashboard: () => <AdminDashboard />,
accounts: (id) => <AccountsAdmin selectedId={id} />,
domains: (id) => <DomainsAdmin selectedId={id} />,
};
@@ -16,8 +18,8 @@ const RENDER: Record<AdminSection, (id?: string) => ReactNode> = {
*
* The page is only the open section. Its list of sections is in the folder
* pane (see AdminNav), so the tables here get the width Settings spends on a
* second column. A section the role cannot read -- typed into the address bar,
* say -- opens the first one it can.
* second column. A bare /admin opens the dashboard, and a section the role
* cannot read -- typed into the address bar, say -- opens the first one it can.
*/
export function AdminView({ section, id }: { section?: string; id?: string }) {
const allowed = adminSections(usePermissions());
@@ -0,0 +1,113 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { JmapMethodError } from "@/jmap/client";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
import type { MetricRecord } from "@/lib/adminDashboard";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const feeds = vi.hoisted(() => ({
counts: { Account: 35, Domain: 3, QueuedMessage: 9 } as Record<string, number>,
metrics: (): Promise<MetricRecord[]> => Promise.resolve([]),
}));
vi.mock("@/lib/adminDashboard", async (original) => ({
...(await original<typeof import("@/lib/adminDashboard")>()),
countObjects: vi.fn(async (object: string) => feeds.counts[object]),
loadMetrics: vi.fn(() => feeds.metrics()),
}));
const { AdminDashboard } = await import("../AdminDashboard");
const signIn = (permissions: string[]) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions } } as unknown as JmapSession });
const HELPDESK = ["sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"];
const TENANT = [...HELPDESK, "sysAccountCreate", "sysDomainCreate", "sysQueuedMessageGet", "sysQueuedMessageQuery"];
const ADMIN = [...TENANT, "sysMetricGet", "sysMetricQuery"];
/** The dashboard shows what the role may read, and nothing a server refuses. */
describe("the Administration dashboard", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
const { hook } = memoryLocation({ path: "/admin" });
await act(async () => {
root.render(<Router hook={hook}><AdminDashboard /></Router>);
});
await act(async () => {});
};
const cards = () => [...host.querySelectorAll(".admin-card")].map((c) => [c.querySelector(".admin-card-label")?.textContent, c.querySelector(".admin-card-value")?.textContent, c.querySelector(".hint")?.textContent]);
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
feeds.metrics = () => Promise.resolve([]);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("gives a helpdesk role its two counts and nothing about the server", async () => {
signIn(HELPDESK);
await render();
expect(cards()).toEqual([
["Users", "35", "User accounts"],
["Domains", "3", "Mail domains"],
]);
});
it("gives a tenant administrator the queue as well, but no history", async () => {
signIn(TENANT);
await render();
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
});
it("adds the history for a role that reads metrics", async () => {
feeds.metrics = () =>
Promise.resolve([
{ "@type": "Gauge", metric: "server.memory", count: 360_000_000, timestamp: "2026-09-15T15:00:00Z" },
{ "@type": "Counter", metric: "queue.message-queued", count: 93, timestamp: "2026-09-15T15:00:00Z" },
{ "@type": "Counter", metric: "queue.report-queued", count: 39, timestamp: "2026-09-15T15:00:00Z" },
]);
signIn(ADMIN);
await render();
expect(cards().map((c) => [c[0], c[1]])).toEqual([
["Users", "35"],
["Domains", "3"],
["Pending", "9"],
["Server memory", "343 MB"],
["Received", "93"],
["Sent", "39"],
]);
});
it("leaves the history off where the server refuses it, as Community does", async () => {
feeds.metrics = () => Promise.reject(new JmapMethodError("x:Metric/query", { type: "forbidden", description: "This feature is only available in the Enterprise edition" }));
signIn(ADMIN);
await render();
expect(cards().map((c) => c[0])).toEqual(["Users", "Domains", "Pending"]);
});
it("says the history is not recorded rather than showing a quiet day", async () => {
signIn(ADMIN);
await render();
expect(cards().slice(3)).toEqual([
["Server memory", "—", "Not recorded on this server"],
["Received", "—", "Not recorded on this server"],
["Sent", "—", "Not recorded on this server"],
]);
});
it("keeps a card that failed for another reason, and says so", async () => {
feeds.metrics = () => Promise.reject(new Error("offline"));
signIn(ADMIN);
await render();
expect(cards()[4]).toEqual(["Received", "—", "Could not be loaded"]);
});
});
@@ -35,20 +35,26 @@ describe("the Administration list in the folder pane", () => {
it("lists each readable section under its group and marks the open one", async () => {
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
await render("/admin/domains/d1");
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Directory", "Mail"]);
expect([...host.querySelectorAll(".nav-section")].map((e) => e.textContent)).toEqual(["Overview", "Directory", "Mail"]);
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
});
it("treats a bare /admin as the first section, which is what the page opens", async () => {
it("treats a bare /admin as the dashboard, which is what the page opens", async () => {
signIn(["sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"]);
await render("/admin");
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Accounts");
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Dashboard");
});
it("leaves out what the role cannot read", async () => {
signIn(["sysDomainQuery", "sysDomainGet"]);
await render("/admin");
await render("/admin/accounts");
expect(host.textContent).not.toContain("Accounts");
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Domains");
expect(host.querySelector(".nav-item.active")?.textContent).toBe("Dashboard");
});
it("offers the dashboard alone to a role that can only count", async () => {
signIn(["sysQueuedMessageQuery"]);
await render("/admin");
expect([...host.querySelectorAll(".nav-item")].map((e) => e.textContent)).toEqual(["Dashboard"]);
});
});