From 0054b8a3ce63400f9132c89122cff8f886587378 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 15 Sep 2026 08:06:59 -0700 Subject: [PATCH] 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. --- FEATURES.md | 55 +++++-- KNOWN-ISSUES.md | 9 ++ README.md | 9 +- ROADMAP.md | 2 +- server/src/adminGate.test.ts | 7 +- server/src/adminGate.ts | 7 +- server/src/mock/directory.test.ts | 40 +++++- server/src/mock/directory.ts | 67 ++++++++- server/src/mock/index.ts | 1 + web/src/lib/__tests__/adminAccess.test.ts | 22 ++- web/src/lib/__tests__/adminDashboard.test.ts | 87 ++++++++++++ web/src/lib/adminAccess.ts | 27 +++- web/src/lib/adminDashboard.ts | 128 +++++++++++++++++ web/src/locales/de.ts | 14 ++ web/src/locales/es.ts | 14 ++ web/src/locales/fr.ts | 14 ++ web/src/locales/ja.ts | 14 ++ web/src/locales/nl.ts | 14 ++ web/src/locales/pt-BR.ts | 14 ++ web/src/locales/ru.ts | 14 ++ web/src/locales/uk.ts | 14 ++ web/src/locales/zh-Hans.ts | 14 ++ web/src/styles/app.css | 16 +++ web/src/views/admin/AdminDashboard.tsx | 134 ++++++++++++++++++ web/src/views/admin/AdminNav.tsx | 3 +- web/src/views/admin/AdminView.tsx | 6 +- .../admin/__tests__/admin-dashboard.test.tsx | 113 +++++++++++++++ .../views/admin/__tests__/admin-nav.test.tsx | 16 ++- 28 files changed, 838 insertions(+), 37 deletions(-) create mode 100644 web/src/lib/__tests__/adminDashboard.test.ts create mode 100644 web/src/lib/adminDashboard.ts create mode 100644 web/src/views/admin/AdminDashboard.tsx create mode 100644 web/src/views/admin/__tests__/admin-dashboard.test.tsx diff --git a/FEATURES.md b/FEATURES.md index 81d042e..3abd1ae 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1108,8 +1108,10 @@ redirects them to their mail if they type its address in. At sign-in the server already asks Stalwart's `GET /api/account` for the edition; it now keeps the account's **permissions** from the same answer and hands them to the browser with the session. The menu appears for an account -that can query and read accounts (`sysAccountQuery`, `sysAccountGet`) or -domains (`sysDomainQuery`, `sysDomainGet`), and each control inside is there only when the matching permission is: +that can count something the dashboard shows — accounts (`sysAccountQuery`), +domains (`sysDomainQuery`), the delivery queue (`sysQueuedMessageQuery`) or the +metric history (`sysMetricQuery` with `sysMetricGet`) — and each section and +control inside is there only when the matching permission is: **New account** with `sysAccountCreate`, editing with `sysAccountUpdate`, **Delete** with `sysAccountDestroy`. A system administrator, a tenant administrator and a custom helpdesk role each see the same screen shaped to @@ -1121,6 +1123,35 @@ account, and Stalwart decides each one — scoping a tenant administrator's queries to their own tenant and refusing anything the role does not allow. The client's gating only avoids offering what would fail. +## Dashboard + +Administration opens on a grid of cards, one for each number the role can read: + +| Card | What it counts | Needs | +|---|---|---| +| **Users** | user accounts, not groups | `sysAccountQuery` | +| **Domains** | mail domains | `sysDomainQuery` | +| **Pending** | messages waiting in the delivery queue | `sysQueuedMessageQuery` | +| **Server memory** | the latest reading, and when it was taken | `sysMetricQuery`, `sysMetricGet` | +| **Received** | messages queued for delivery in the last 24 hours | the same | +| **Sent** | authenticated submissions, bounces and reports queued in the last 24 hours | the same | + +Users and Domains open their sections when the role can. The counts are what +Stalwart answers for the signed-in account, so a **tenant administrator sees +their tenancy**: its accounts, its domains, and the queued messages that touch +them. The last three come from Stalwart's metric history, which has no tenant +in it and which the Tenant Administrator role Stalwart creates does not hold, +so a tenant's dashboard is Users, Domains and Pending. A helpdesk role that can +read accounts and domains sees those two cards. + +The history is an Enterprise feature that has to be switched on. A server that +refuses it — Community does — leaves those three cards off rather than showing +them broken, and one that records nothing says *Not recorded on this server* +rather than showing a day of zeroes. Received and sent add up the same metric +names Stalwart's own dashboard uses. The columns follow the number of cards, +so rows come out even: six are three over three, and fall to two and then one +as the space narrows. **Refresh** reads everything again; nothing is polled. + ## Accounts - **List and search** by name or address, fifty to a page, newest first — the @@ -1202,9 +1233,10 @@ session information already kept for thirty minutes — so a role granted or taken away shows in the menu at the next sign-in or within half an hour, and in the meantime Stalwart refuses what is no longer allowed. -Accounts and domains are the first two sections. Groups, mailing lists, roles -and tenants are Stalwart capabilities the same screen is laid out to take; -reporting, queues, logs and server settings are deliberately out of scope. +The dashboard, accounts and domains are the first three sections. Groups, +mailing lists, roles and tenants are Stalwart capabilities the same screen is +laid out to take. Beyond the dashboard's counts, managing queues, logs and +server settings is deliberately out of scope. --- @@ -1662,11 +1694,14 @@ moves an occurrence renumbering the ids around it. Two switches: tested. Administration works against it too, with a directory of about thirty accounts, -three domains with their DKIM keys and zone files, behind the same permission -names Stalwart uses. `MOCK_ROLE` decides who the -demo user is: `admin` (the default), `tenant-admin`, `helpdesk` — a custom role -that may view and edit accounts but not create or delete them — or `user`, who -is not offered the menu at all. +three domains with their DKIM keys and zone files, nine queued messages and +thirty hours of metric history ending in the current hour, behind the same +permission names Stalwart uses. `MOCK_ROLE` decides who the +demo user is: `admin` (the default), `tenant-admin` (the queue but not the +history), `helpdesk` — a custom role that may view and edit accounts but not +create or delete them, and read domains — or `user`, who is not offered the +menu at all. `MOCK_METRICS=off` refuses the history the way a Community server +does. --- diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 51a6ca1..25e164f 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -56,6 +56,15 @@ works the same way — and dropped where 0.15 was the whole subject. Support for The last two were then tried by hand on the live server the same day and behaved as described. **A password set by an administrator** — written to the account's existing credential, `credentials//secret` — signs in. **The outranking guard** held: an account with more rights than the viewer's role opens read-only. The guard exists because the source shows Stalwart skipping its grant check when only a password changes and on a delete, and it stays for that reason. +- **The dashboard's feeds were settled on the live server before the code was written (2026-09-15, 0.16.22 Enterprise, read-only calls from an administrator's session).** The first probes guessed two of these wrong — filtering on `timestamp`, and counting received mail from `message-ingest.*` — and the server and the 0.16.22 source agreed on the answers below: + + - **The metric history filters on comparison names.** `x:Metric/query` accepts `{"timestampIsGreaterThanOrEqual": …, "metric": [names]}`; a bare `timestamp`, `after` or `metric` as a string is `unsupportedFilter`. Sorting on `timestamp` works. At the default interval a day is about 80 records for the six metrics the dashboard reads, and a get takes at most 500. + - **Received and sent are `queue.*` counters**, not `message-ingest.*`: `queue.message-queued` for received, and `queue.authenticated-message-queued` + `queue.dsn-queued` + `queue.report-queued` for sent, which is what Stalwart's own dashboard adds up. A Counter holds its interval's count and a zero one is not written; the `*-time` histograms are cumulative, which is why nothing reads them. + - **Memory is the `server.memory` Gauge**, in bytes, one per interval. **Counts** come from `/query` with `calculateTotal: true` and `limit: 0`, which returned the whole total for `x:Account` (users only, via `@type`), `x:Domain` and `x:QueuedMessage`. + - **`x:Metrics/get` is not the history.** It is the singleton holding the collection settings (Prometheus and OpenTelemetry export, the metrics policy); the history is `x:Metric`. + + **Not confirmed live:** that a tenant administrator's counts are scoped to the tenancy, and that a Community server refuses `x:Metric` as `forbidden`. Both are read from the 0.16.22 source (`query.rs`, `queued_message.rs`, `registry/mod.rs`); the production server has no tenants and is Enterprise, so neither could be tried there without writing. The dashboard's handling of both is covered by tests against the refusal Stalwart's source gives. + - **A refused password shows the server's reason in English.** Every other refusal from the registry is said in the reader's language: each error type has its own message, and a value one of Stalwart's validators refused — a domain name, an address, an empty field — is recognised by the validator's wording and explained again rather than shown. A password policy is the exception, on purpose. Its rule is the server's to set, so there is nothing to translate it from in advance, and its reason follows a translated sentence rather than being dropped, which would leave "not accepted" with no way to find out why. - **Administration is off for a device not marked as your own, and for an installation that says so.** Both are enforced by the server rather than hidden by the menu: such a session is sent no permissions, and the JMAP proxy refuses registry methods beyond the account's own. That is worth stating because the proxy otherwise forwards whatever the browser sends, and before these gates an administrator's console could make any registry call their role allowed. For a session that may not administer, the proxy reads a request body only when it could name a registry method — a `"x:` in the text, or a `\u` escape that could spell one — so ordinary mail traffic is forwarded untouched. diff --git a/README.md b/README.md index aa22f44..0830cfc 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree - **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong - **Twelve themes** — Classic and ihasmail's own, plus Catppuccin, Dracula, Gruvbox, Rosé Pine, Tokyo Night, Solarized, Ayu, Kanagawa, Everforest and Primer, each with the light and dark half its own project publishes. Palette and light-or-dark are separate choices, and the accent colour still sits on top of any of them. Only published colour values are used, taken from each project's own repository; the shades between them are derived and every text colour is measured against the surface it sits on, so a palette that would not meet the contrast this app claims is not written at all — see [Themes](FEATURES.md#themes) - **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation -- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: create, edit and delete accounts and set their passwords; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration) +- **Administration** — for an account whose Stalwart role manages accounts or domains, from the account menu: a dashboard of users, domains, queued mail, memory and the last day's received and sent, scoped to a tenant administrator's own tenancy; create, edit and delete accounts and set their passwords; add domains, copy their DNS records one at a time or as a zone file, see their DKIM keys, and remove them once nothing uses them. Each control is there only when the role allows it, and Stalwart decides every call. Only for a session signed in with *This is my own device* ticked, and `ADMINISTRATION=0` turns it off for everyone — see [Administration](FEATURES.md#administration) - **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy The long version is on [ihasmail.org](https://ihasmail.org/#features); how to @@ -399,12 +399,13 @@ without a real mailbox. It reproduces the things a naive fake would get wrong, because each cost a live debugging session: `urn:stalwart:jmap` advertised **per-account** rather than session-level, identity signatures capped at 2047 **bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than -RFC 8984's. Four switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE +RFC 8984's. Five switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a calendar write that asks for scheduling messages, the way an account without -that permission is refused; and `MOCK_ROLE` decides who the demo user is for -Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`. +that permission is refused; `MOCK_ROLE` decides who the demo user is for +Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`; +and `MOCK_METRICS=off` refuses the dashboard's metric history, as Community does. It tracks the current release rather than 0.16 in general, and each behaviour is confirmed against a real server before it is copied here — the comments say diff --git a/ROADMAP.md b/ROADMAP.md index af5e57e..9a2fa21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ the rest is here because the answer is "no", not "not yet". See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about. -- **Administration beyond accounts and domains.** The Administration menu manages accounts and domains today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. Reporting, queues, logs and server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it. +- **Administration beyond accounts and domains.** The Administration menu opens on a dashboard and manages accounts and domains today — see [FEATURES.md](FEATURES.md#administration). Groups, mailing lists, roles, DNS and ACME providers and tenants are Stalwart registry objects behind the same permission model, and each is a section to add rather than a design to invent; so is switching a domain's DNS, DKIM or certificate management, which is shown but not yet changed from ihasmail. The dashboard reads a handful of numbers and stops there. Managing queues, reading logs and changing server settings are not planned: they are operating the server, which is Stalwart's own interface's job, not managing the people on it. - **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works. - **A scheduling view of its own**, for asking "when is everyone free next week?" without an event in hand. The grid itself is built and lives in the event editor — a row per participant, steppable, and clickable to place the event — which is where the question gets asked while you are arranging something. What is not built is the same thing as a destination you can visit with nothing in progress. Came out of [#172](https://github.com/Coffey-Labs/ihasmail/issues/172), which asked for a separate view and is closed by the panel: the reasoning for putting it in the editor is that a separate surface can only ever tell you a time you then retype, whereas one beside the event can set it. It stays here rather than in the tracker because nobody has yet said they want to ask the question on its own. - **Per-message actions from the message list on a touchscreen.** Reply, Forward and compose-as-new are on the list row's context menu, which is a right-click — and holding a row on a phone starts selection instead, so none of them are reachable there. They are all available inside a thread, which is where the actions on a single message belong; what is missing is the shortcut from the list. Fixing it means deciding what a long press should do when it already means something, which is a bigger question than the actions themselves. diff --git a/server/src/adminGate.test.ts b/server/src/adminGate.test.ts index 89e9ee7..4b39ede 100644 --- a/server/src/adminGate.test.ts +++ b/server/src/adminGate.test.ts @@ -57,7 +57,12 @@ test("administration needs both the installation and a device marked as the pers test("an account counts as an administrator by the same test the menu makes", () => { assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true); assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true); - assert.equal(grantsAdministration(["sysAccountQuery", "sysDomainGet"]), false); + // The dashboard opens on less than a list: a count is only a query. + assert.equal(grantsAdministration(["sysAccountQuery"]), true); + assert.equal(grantsAdministration(["sysQueuedMessageQuery"]), true); + assert.equal(grantsAdministration(["sysMetricQuery", "sysMetricGet"]), true); + assert.equal(grantsAdministration(["sysMetricQuery"]), false); + assert.equal(grantsAdministration(["sysAccountGet", "sysDomainGet"]), false); assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false); }); diff --git a/server/src/adminGate.ts b/server/src/adminGate.ts index 46f28c9..3f9b699 100644 --- a/server/src/adminGate.ts +++ b/server/src/adminGate.ts @@ -41,10 +41,15 @@ export function administrationAllowed(enabled: boolean, remember: boolean): bool * Whether an account's permissions would put Administration in its menu -- * the same test the client makes, so the server can say why it is missing * without handing over the permissions themselves. + * + * The client's test is whether any section opens, and the dashboard opens on + * less than a list does: a count needs only the query, the metric history its + * query and get. The account and domain lists need more than their counts, so + * they add nothing here. */ export function grantsAdministration(permissions: readonly string[]): boolean { const has = new Set(permissions); - return (has.has("sysAccountQuery") && has.has("sysAccountGet")) || (has.has("sysDomainQuery") && has.has("sysDomainGet")); + return has.has("sysAccountQuery") || has.has("sysDomainQuery") || has.has("sysQueuedMessageQuery") || (has.has("sysMetricQuery") && has.has("sysMetricGet")); } /** diff --git a/server/src/mock/directory.test.ts b/server/src/mock/directory.test.ts index 9855986..520c0e1 100644 --- a/server/src/mock/directory.test.ts +++ b/server/src/mock/directory.test.ts @@ -6,7 +6,7 @@ class Refused extends Error { constructor(readonly type: string, description?: string) { super(description ?? type); } } -const make = (role: MockRole) => createDirectory({ accountId: "a1", user: "demo@example.com", locale: "en_US", role, fail: (t, d) => new Refused(t, d) }); +const make = (role: MockRole, extra: { metricsOff?: boolean; now?: Date } = {}) => createDirectory({ accountId: "a1", user: "demo@example.com", locale: "en_US", role, fail: (t, d) => new Refused(t, d), ...extra }); /** * The mock stands in for a server that decides what each account may do, so @@ -109,3 +109,41 @@ test("the domain validators refuse what the live server refused, in its words", const updated = set({ update: { d2: { catchAllAddress: "postmaster" } } }) as { notUpdated?: Record }; assert.deepEqual([updated.notUpdated?.d2?.type, updated.notUpdated?.d2?.description], ["invalidPatch", "Invalid email address"]); }); + +/** The dashboard's feeds: counts, the queue, and the metric history. */ +test("counts come back with no ids when the client asks for a total and no page", () => { + const dir = make("admin"); + const r = dir.handlers["x:QueuedMessage/query"]!({ limit: 0, calculateTotal: true }) as { ids: string[]; total: number }; + assert.deepEqual(r.ids, []); + assert.equal(r.total, 9); +}); + +test("the metric history answers the filter the dashboard sends, newest first", () => { + const dir = make("admin", { now: new Date("2026-09-15T14:25:00Z") }); + const q = dir.handlers["x:Metric/query"]!({ + filter: { timestampIsGreaterThanOrEqual: "2026-09-14T14:25:00Z", metric: ["server.memory"] }, + sort: [{ property: "timestamp", isAscending: false }], + }) as { ids: string[] }; + const { list } = dir.handlers["x:Metric/get"]!({ ids: q.ids }) as { list: Array<{ metric: string; timestamp: string }> }; + assert.equal(list.length, 24); + assert.ok(list.every((m) => m.metric === "server.memory")); + const newest = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[0]] }) as { list: Array<{ timestamp: string }> }).list[0]!; + const next = (dir.handlers["x:Metric/get"]!({ ids: [q.ids[1]] }) as { list: Array<{ timestamp: string }> }).list[0]!; + assert.equal(newest.timestamp, "2026-09-15T14:00:00Z"); + assert.ok(newest.timestamp > next.timestamp); + // A bare timestamp is what a live server refuses. + assert.throws(() => dir.handlers["x:Metric/query"]!({ filter: { timestamp: "2026-09-15T00:00:00Z" } }), (e: Refused) => e.type === "unsupportedFilter"); +}); + +test("a tenant administrator gets the queue but not the history, and Community refuses the history", () => { + const tenant = make("tenant-admin"); + assert.equal((tenant.handlers["x:QueuedMessage/query"]!({ calculateTotal: true }) as { total: number }).total, 9); + assert.throws(() => tenant.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden"); + const community = make("admin", { metricsOff: true }); + assert.throws(() => community.handlers["x:Metric/query"]!({}), (e: Refused) => e.type === "forbidden" && /Enterprise/.test(e.message)); +}); + +test("helpdesk may count domains, which is what the demo's helpdesk may do", () => { + assert.ok(permissionsFor("helpdesk").includes("sysDomainQuery")); + assert.ok(!permissionsFor("helpdesk").includes("sysMetricQuery")); +}); diff --git a/server/src/mock/directory.ts b/server/src/mock/directory.ts index 66250f5..988fde2 100644 --- a/server/src/mock/directory.ts +++ b/server/src/mock/directory.ts @@ -13,6 +13,11 @@ * credential's own pointer, `credentials//secret`; * - `x:Account/query` understands AND and nothing else. * + * It also answers the two feeds Administration's dashboard reads: a short + * outbound queue (`x:QueuedMessage`) and a day and a bit of hourly metric + * history (`x:Metric`), dated from when the mock started. MOCK_METRICS=off + * refuses the history the way a Community server does. + * * What it does not reproduce is tenancy: every caller sees every record. The * real server scopes a tenant administrator's queries, and nothing in the client * relies on seeing more or less than it is given. @@ -29,17 +34,22 @@ export type MockRole = "admin" | "tenant-admin" | "helpdesk" | "user"; const OPS = ["Get", "Query", "Create", "Update", "Destroy"] as const; const all = (...objects: string[]) => objects.flatMap((o) => OPS.map((op) => `sys${o}${op}`)); +/** What the dashboard reads beyond the directory. */ +const READ_SERVER = ["sysQueuedMessageGet", "sysQueuedMessageQuery", "sysMetricGet", "sysMetricQuery"]; + /** A few of the ordinary ones, so the list looks like what a server sends. */ const USER_PERMISSIONS = ["jmapEmailGet", "jmapEmailSet", "jmapMailboxGet", "sysAccountSettingsGet"]; export function permissionsFor(role: MockRole): string[] { switch (role) { case "admin": - return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), "impersonate"]; + return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer", "Tenant"), ...READ_SERVER, "impersonate"]; case "tenant-admin": - return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer")]; + // The queue but not the metric history: Stalwart scopes the one to a + // tenant's domains, and the other has no tenant to scope it by. + return [...USER_PERMISSIONS, ...all("Account", "Domain", "Role", "MailingList", "DkimSignature", "DnsServer"), "sysQueuedMessageGet", "sysQueuedMessageQuery"]; case "helpdesk": - return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate"]; + return [...USER_PERMISSIONS, "sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "sysDomainGet", "sysDomainQuery"]; default: return USER_PERMISSIONS; } @@ -61,6 +71,10 @@ interface Options { role: MockRole; /** Build the error a method fails with; the mock server owns the type. */ fail: (type: string, description?: string) => Error; + /** Refuse the metric history, as a Community server does. */ + metricsOff?: boolean; + /** When the history ends; the newest hour is the one this falls in. */ + now?: Date; } export function createDirectory(opts: Options) { @@ -168,6 +182,38 @@ export function createDirectory(opts: Options) { user({ name, domain: i % 3 === 0 ? "d2" : "d1", description, used: (i % 7) * 0.6, quota: i % 4 === 0 ? 0 : 5 }); }); + // Nine messages waiting, which is what a small live server had queued on the + // day this was written: a few retries and the odd report. + const queue: Obj[] = Array.from({ length: 9 }, (_, i) => ({ id: `q${i + 1}`, createdAt: new Date(Date.UTC(2026, 8, 15, 6 + i)).toISOString(), size: 2400 + i * 310, priority: 0, flags: {} })); + + /** + * Thirty hours of history ending in the current hour: a Counter per hour for + * what was queued, and a memory Gauge. Counters that would be zero are left + * out, as Stalwart leaves them out. + */ + const metrics: Obj[] = []; + { + const hour = 3600_000; + const end = Math.floor((opts.now ?? new Date()).getTime() / hour) * hour; + for (let h = 29; h >= 0; h--) { + const at = end - h * hour; + const timestamp = new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z"); + const seq = (29 - h) * 10; + const push = (n: number, type: string, metric: string, count: number) => { + if (type === "Counter" && !count) return; + metrics.push({ id: `m${String(seq + n).padStart(4, "0")}`, "@type": type, metric, count, timestamp }); + }; + push(0, "Gauge", "server.memory", 360_000_000 + ((h * 7_919_000) % 40_000_000)); + push(1, "Counter", "queue.message-queued", (h * 5 + 3) % 9); + push(2, "Counter", "queue.authenticated-message-queued", h % 3); + push(3, "Counter", "queue.dsn-queued", h % 11 === 0 ? 1 : 0); + push(4, "Counter", "queue.report-queued", h % 4 === 1 ? 2 : 0); + } + } + const refuseMetrics = () => { + if (opts.metricsOff) throw opts.fail("forbidden", "This feature is only available in the Enterprise edition of Stalwart."); + }; + const demand = (perm: string) => { if (!permissions.has(perm)) throw opts.fail("forbidden", `You do not have the ${perm} permission.`); }; @@ -394,6 +440,21 @@ export function createDirectory(opts: Options) { demand("sysDnsServerGet"); return { accountId: opts.accountId, state: "1", list: ((a.ids as string[]) ?? ["ns1"]).filter((id) => id === "ns1").map((id) => ({ id, "@type": "Cloudflare", description: "Cloudflare (main zone)" })), notFound: [] }; }, + "x:QueuedMessage/get": get(queue, "sysQueuedMessageGet"), + "x:QueuedMessage/query": query(() => queue, "sysQueuedMessageQuery", [], () => true), + "x:Metric/get": (a) => { + refuseMetrics(); + return get(metrics, "sysMetricGet")(a); + }, + // Ids sort the way timestamps do, so the helper's newest-first order is the + // `timestamp` descending the dashboard asks for. + "x:Metric/query": (a) => { + refuseMetrics(); + return query(() => metrics, "sysMetricQuery", ["timestampIsGreaterThanOrEqual", "timestampIsLessThanOrEqual", "metric"], (o, f) => + (f.timestampIsGreaterThanOrEqual === undefined || String(o.timestamp) >= String(f.timestampIsGreaterThanOrEqual)) && + (f.timestampIsLessThanOrEqual === undefined || String(o.timestamp) <= String(f.timestampIsLessThanOrEqual)) && + (!Array.isArray(f.metric) || (f.metric as string[]).includes(o.metric as string)))(a); + }, "x:Role/get": get(roles, "sysRoleGet"), "x:Role/query": query(() => roles, "sysRoleQuery", ["text", "description", "memberTenantId"], (o, f) => matchText(o, f.description)), }; diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 991c5b8..6f18ac8 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -892,6 +892,7 @@ const directory = createDirectory({ user: USER, locale: MOCK_LOCALE, role: mockRole(process.env.MOCK_ROLE), + metricsOff: process.env.MOCK_METRICS === "off", fail: (type, description) => new MethodError(type, description), }); diff --git a/web/src/lib/__tests__/adminAccess.test.ts b/web/src/lib/__tests__/adminAccess.test.ts index 4ec7786..2eb0eb7 100644 --- a/web/src/lib/__tests__/adminAccess.test.ts +++ b/web/src/lib/__tests__/adminAccess.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ADMIN_BASELINE, adminSections, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess"; +import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess"; const set = (...p: string[]) => permissionSet(p); const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate"); @@ -12,17 +12,27 @@ const roles = new Map([ ]); describe("who is offered administration", () => { - it("needs both halves of reading the account list", () => { - expect(hasAdministration(set("sysAccountQuery", "sysAccountGet"))).toBe(true); - expect(hasAdministration(set("sysAccountQuery"))).toBe(false); + it("needs both halves of reading the account list to list accounts", () => { + expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts"]); + // A query alone is a count on the dashboard, not a list. + expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]); expect(hasAdministration(set("sysAccountGet"))).toBe(false); expect(hasAdministration(permissionSet(undefined))).toBe(false); }); it("offers each section only with both halves of reading it", () => { - expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["domains"]); + expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]); expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true); - expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["accounts"]); + expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts"]); + }); + + it("gives the dashboard a card for each number the role can read", () => { + expect(dashboardCards(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"))).toEqual(["users", "domains"]); + expect(dashboardCards(set("sysQueuedMessageQuery"))).toEqual(["pending"]); + // The history takes its get as well: the query only finds the records. + expect(dashboardCards(set("sysMetricQuery"))).toEqual([]); + expect(dashboardCards(set("sysMetricQuery", "sysMetricGet"))).toEqual(["memory", "received", "sent"]); + expect(adminSections(set("jmapEmailGet"))).toEqual([]); }); it("reads one permission per object and operation", () => { diff --git a/web/src/lib/__tests__/adminDashboard.test.ts b/web/src/lib/__tests__/adminDashboard.test.ts new file mode 100644 index 0000000..2d34cda --- /dev/null +++ b/web/src/lib/__tests__/adminDashboard.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { client, JmapMethodError } from "@/jmap/client"; +import { balancedColumns, countObjects, isRefused, loadMetrics, summariseMetrics, type MetricRecord } from "@/lib/adminDashboard"; + +const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp }); + +describe("the dashboard's message numbers", () => { + it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => { + const stats = summariseMetrics([ + counter("queue.message-queued", 6), + counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"), + counter("queue.authenticated-message-queued", 2), + counter("queue.dsn-queued", 1), + counter("queue.report-queued", 3), + // Recorded, but not either number. + counter("message-ingest.ham", 50), + ]); + expect(stats.received).toBe(10); + expect(stats.sent).toBe(6); + }); + + it("reads memory from the newest gauge, not the first one listed", () => { + const stats = summariseMetrics([ + { "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" }, + { "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" }, + { "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" }, + ]); + expect(stats.memory).toEqual({ bytes: 300, at: "2026-09-15T14:00:00Z" }); + }); + + it("tells a history that records nothing from a quiet day", () => { + expect(summariseMetrics([]).recorded).toBe(false); + const quiet = summariseMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]); + expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 }); + }); +}); + +describe("the dashboard's queries", () => { + it("counts users rather than accounts, and asks for no ids", async () => { + const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 5 }); + expect(await countObjects("Account")).toBe(5); + expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User" }, limit: 0, calculateTotal: true }); + await countObjects("QueuedMessage"); + expect(call).toHaveBeenLastCalledWith("x:QueuedMessage/query", { limit: 0, calculateTotal: true }); + call.mockRestore(); + }); + + it("filters the history with Stalwart's comparison names, and pages the gets", async () => { + // A bare `timestamp` or `after` is unsupportedFilter on a live server. + vi.spyOn(client, "maxObjectsInGet", "get").mockReturnValue(2); + const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => { + if (method === "x:Metric/query") return (args as { position: number }).position === 0 ? { ids: ["a", "b"] } : { ids: ["c"] }; + return { list: ((args as { ids: string[] }).ids).map((id) => counter("queue.message-queued", 1, id)) }; + }); + const records = await loadMetrics(new Date("2026-09-14T15:30:00.123Z")); + expect(records).toHaveLength(3); + expect(call.mock.calls[0]).toEqual([ + "x:Metric/query", + { + filter: { timestampIsGreaterThanOrEqual: "2026-09-14T15:30:00Z", metric: ["queue.message-queued", "queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued", "server.memory"] }, + sort: [{ property: "timestamp", isAscending: false }], + position: 0, + limit: 2, + }, + ]); + vi.restoreAllMocks(); + }); + + it("treats only a forbidden answer as the server refusing", () => { + expect(isRefused(new JmapMethodError("x:Metric/query", { type: "forbidden" }))).toBe(true); + expect(isRefused(new JmapMethodError("x:Metric/query", { type: "serverFail" }))).toBe(false); + expect(isRefused(new Error("offline"))).toBe(false); + }); +}); + +describe("the card grid", () => { + it("never leaves a row short when the cards can be divided evenly", () => { + for (const n of [1, 2, 3, 4, 6]) { + const { wide, mid } = balancedColumns(n); + expect(n % wide, `${n} cards across ${wide}`).toBe(0); + expect(n % mid, `${n} cards across ${mid}`).toBe(0); + expect(wide).toBeLessThanOrEqual(4); + } + expect(balancedColumns(6)).toEqual({ wide: 3, mid: 2 }); + expect(balancedColumns(3)).toEqual({ wide: 3, mid: 1 }); + }); +}); diff --git a/web/src/lib/adminAccess.ts b/web/src/lib/adminAccess.ts index b6683b2..444bd0f 100644 --- a/web/src/lib/adminAccess.ts +++ b/web/src/lib/adminAccess.ts @@ -14,7 +14,7 @@ * for a check Stalwart does not make. See there. */ -export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant"; +export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant" | "QueuedMessage" | "Metric"; export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy"; export type Permissions = ReadonlySet; @@ -27,16 +27,39 @@ export function can(perms: Permissions, object: AdminObject, op: AdminOp): boole return perms.has(`sys${object}${op}`); } -export type AdminSection = "accounts" | "domains"; +export type AdminSection = "dashboard" | "accounts" | "domains"; + +export type DashboardCard = "users" | "domains" | "pending" | "memory" | "received" | "sent"; + +/** + * The dashboard's cards an account may see. + * + * A count is a query with `calculateTotal`, so a query alone earns one. The + * three read from the metric history need the get as well, since the query + * only finds the records. Stalwart scopes the first three to a tenant + * administrator's own tenancy; the metric history has no tenant in it at all, + * and the Tenant Administrator role Stalwart creates does not hold it -- which + * is how a tenant's dashboard comes to show only what is theirs. + */ +export function dashboardCards(perms: Permissions): DashboardCard[] { + const out: DashboardCard[] = []; + if (can(perms, "Account", "Query")) out.push("users"); + if (can(perms, "Domain", "Query")) out.push("domains"); + if (can(perms, "QueuedMessage", "Query")) out.push("pending"); + if (can(perms, "Metric", "Query") && can(perms, "Metric", "Get")) out.push("memory", "received", "sent"); + return out; +} /** * The sections an account may open, in the order they are listed. * * A list that cannot be read is not worth an entry, so each takes both halves * of reading one: the query that finds the objects and the get that shows them. + * The dashboard comes first, and is there whenever it has a card to show. */ export function adminSections(perms: Permissions): AdminSection[] { const out: AdminSection[] = []; + if (dashboardCards(perms).length) out.push("dashboard"); if (can(perms, "Account", "Query") && can(perms, "Account", "Get")) out.push("accounts"); if (can(perms, "Domain", "Query") && can(perms, "Domain", "Get")) out.push("domains"); return out; diff --git a/web/src/lib/adminDashboard.ts b/web/src/lib/adminDashboard.ts new file mode 100644 index 0000000..b766bca --- /dev/null +++ b/web/src/lib/adminDashboard.ts @@ -0,0 +1,128 @@ +import { client, JmapMethodError } from "@/jmap/client"; + +/** + * The numbers on Administration's dashboard, over the ordinary JMAP proxy. + * + * Counts are queries with `calculateTotal` and `limit: 0`: Stalwart lifts its + * own limit when asked for a total, so the number is the whole count, and no + * ids come back to be thrown away. For a tenant administrator the server scopes + * all three to the tenancy -- accounts and domains to its members, the queue to + * messages touching its domains. + * + * The rest is read from `x:Metric`, the history Stalwart records once per + * collection interval (hourly by default): a Counter holds what happened in + * that interval, a Gauge the reading at its end. Received and sent are the sums + * Stalwart's own dashboard shows, over the same metric names. The history is + * Enterprise-only and has to be switched on (`x:MetricsStore`); a Community + * server refuses the query as `forbidden`, and one that records nothing + * answers with nothing -- the two cases the dashboard tells apart. + */ + +export const RECEIVED_METRICS = ["queue.message-queued"] as const; +export const SENT_METRICS = ["queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued"] as const; +export const MEMORY_METRIC = "server.memory"; + +/** The window received and sent cover. */ +export const DASHBOARD_WINDOW_MS = 24 * 60 * 60 * 1000; + +export interface MetricRecord { + "@type": "Counter" | "Gauge" | "Histogram"; + metric: string; + count: number; + timestamp: string; + sum?: number; +} + +export interface MessageStats { + received: number; + sent: number; + /** The latest memory reading, or null when none was recorded in the window. */ + memory: { bytes: number; at: string } | null; + /** + * Whether the server recorded anything in the window. Memory is written every + * interval, so a window with no records at all is a history that is switched + * off -- not a quiet day, which would still say zero. + */ + recorded: boolean; +} + +export function summariseMetrics(records: readonly MetricRecord[]): MessageStats { + let received = 0; + let sent = 0; + let memory: MessageStats["memory"] = null; + const receivedNames = new Set(RECEIVED_METRICS); + const sentNames = new Set(SENT_METRICS); + for (const r of records) { + if (r["@type"] === "Counter") { + if (receivedNames.has(r.metric)) received += r.count; + else if (sentNames.has(r.metric)) sent += r.count; + } else if (r["@type"] === "Gauge" && r.metric === MEMORY_METRIC) { + if (!memory || r.timestamp > memory.at) memory = { bytes: r.count, at: r.timestamp }; + } + } + return { received, sent, memory, recorded: records.length > 0 }; +} + +type CountedObject = "Account" | "Domain" | "QueuedMessage"; + +/** How many there are. Accounts are counted as users: groups are accounts too. */ +export async function countObjects(object: CountedObject): Promise { + const res = await client.call<{ total?: number; ids?: string[] }>(`x:${object}/query`, { + ...(object === "Account" ? { filter: { "@type": "User" } } : {}), + limit: 0, + calculateTotal: true, + }); + return res.total ?? res.ids?.length ?? 0; +} + +/** + * Every record of the dashboard's metrics since `since`, newest first. + * + * The filter keys are Stalwart's comparison names for the property -- a bare + * `timestamp` is `unsupportedFilter`. A day at the default hourly interval is + * well under one page; the paging is for a server that collects far more often. + */ +export async function loadMetrics(since: Date): Promise { + const filter = { + timestampIsGreaterThanOrEqual: since.toISOString().replace(/\.\d{3}Z$/, "Z"), + metric: [...RECEIVED_METRICS, ...SENT_METRICS, MEMORY_METRIC], + }; + const step = client.maxObjectsInGet; + const out: MetricRecord[] = []; + for (let position = 0; ; position += step) { + const q = await client.call<{ ids?: string[] }>("x:Metric/query", { filter, sort: [{ property: "timestamp", isAscending: false }], position, limit: step }); + const ids = q.ids ?? []; + if (ids.length) out.push(...(await client.call<{ list: MetricRecord[] }>("x:Metric/get", { ids })).list); + if (ids.length < step) return out; + } +} + +/** + * How many columns the cards take, so no row is left short. + * + * `wide` is the most that divides the cards evenly without going past four; + * `mid` is what they drop to when that no longer fits, again only a count the + * cards divide into -- three cards go to one column rather than two and one. + * Five is the one count nothing divides, and takes three over two. A phone + * always gets one column, which the stylesheet decides. + */ +export function balancedColumns(cards: number): { wide: number; mid: number } { + switch (cards) { + case 0: + case 1: + return { wide: 1, mid: 1 }; + case 2: + return { wide: 2, mid: 2 }; + case 3: + return { wide: 3, mid: 1 }; + case 4: + return { wide: 4, mid: 2 }; + default: + return { wide: 3, mid: 2 }; + } +} + +/** A refusal from the server itself, as opposed to a failure to reach it. */ +export function isRefused(err: unknown): boolean { + return err instanceof JmapMethodError && err.error.type === "forbidden"; +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 2131e50..3967642 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -133,6 +133,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.", "Administration": "Verwaltung", "Directory": "Verzeichnis", + "Overview": "Übersicht", + "Dashboard": "Dashboard", + "Users": "Benutzer", + "User accounts": "Benutzerkonten", + "Mail domains": "E-Mail-Domains", + "Pending": "Ausstehend", + "Waiting in the delivery queue": "In der Zustellwarteschlange", + "Server memory": "Arbeitsspeicher des Servers", + "As of {time}": "Stand: {time}", + "Not recorded on this server": "Auf diesem Server nicht erfasst", + "Last 24 hours": "Letzte 24 Stunden", + "The numbers your role can see, as the server reports them.": "Die Zahlen, die Ihre Rolle sehen darf, so wie der Server sie meldet.", + "Nothing to show": "Nichts anzuzeigen", + "Could not be loaded": "Konnte nicht geladen werden", "User": "Benutzer", "Administrator": "Administrator", "Custom role": "Eigene Rolle", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index b6d8dfd..e049325 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -125,6 +125,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.", "Administration": "Administración", "Directory": "Directorio", + "Overview": "Resumen", + "Dashboard": "Panel", + "Users": "Usuarios", + "User accounts": "Cuentas de usuario", + "Mail domains": "Dominios de correo", + "Pending": "Pendientes", + "Waiting in the delivery queue": "En la cola de entrega", + "Server memory": "Memoria del servidor", + "As of {time}": "A las {time}", + "Not recorded on this server": "No se registra en este servidor", + "Last 24 hours": "Últimas 24 horas", + "The numbers your role can see, as the server reports them.": "Las cifras que su rol puede ver, tal como las informa el servidor.", + "Nothing to show": "Nada que mostrar", + "Could not be loaded": "No se pudo cargar", "User": "Usuario", "Administrator": "Administrador", "Custom role": "Rol personalizado", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 676cf33..5192fb5 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -130,6 +130,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.", "Administration": "Administration", "Directory": "Annuaire", + "Overview": "Vue d’ensemble", + "Dashboard": "Tableau de bord", + "Users": "Utilisateurs", + "User accounts": "Comptes utilisateurs", + "Mail domains": "Domaines de messagerie", + "Pending": "En attente", + "Waiting in the delivery queue": "Dans la file de distribution", + "Server memory": "Mémoire du serveur", + "As of {time}": "Au {time}", + "Not recorded on this server": "Non enregistré sur ce serveur", + "Last 24 hours": "Dernières 24 heures", + "The numbers your role can see, as the server reports them.": "Les chiffres que votre rôle permet de voir, tels que le serveur les indique.", + "Nothing to show": "Rien à afficher", + "Could not be loaded": "Chargement impossible", "User": "Utilisateur", "Administrator": "Administrateur", "Custom role": "Rôle personnalisé", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index c597628..91666f0 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -124,6 +124,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。", "Administration": "管理", "Directory": "ディレクトリ", + "Overview": "概要", + "Dashboard": "ダッシュボード", + "Users": "ユーザー", + "User accounts": "ユーザーアカウント", + "Mail domains": "メールドメイン", + "Pending": "保留中", + "Waiting in the delivery queue": "配送キューで待機中", + "Server memory": "サーバーのメモリ", + "As of {time}": "{time} 時点", + "Not recorded on this server": "このサーバーでは記録されていません", + "Last 24 hours": "過去 24 時間", + "The numbers your role can see, as the server reports them.": "あなたのロールで見られる数値を、サーバーの報告どおりに表示します。", + "Nothing to show": "表示するものはありません", + "Could not be loaded": "読み込めませんでした", "User": "ユーザー", "Administrator": "管理者", "Custom role": "カスタムロール", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 6b6883b..3572498 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -121,6 +121,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.", "Administration": "Beheer", "Directory": "Adreslijst", + "Overview": "Overzicht", + "Dashboard": "Dashboard", + "Users": "Gebruikers", + "User accounts": "Gebruikersaccounts", + "Mail domains": "E-maildomeinen", + "Pending": "In behandeling", + "Waiting in the delivery queue": "In de bezorgwachtrij", + "Server memory": "Servergeheugen", + "As of {time}": "Stand van {time}", + "Not recorded on this server": "Niet vastgelegd op deze server", + "Last 24 hours": "Afgelopen 24 uur", + "The numbers your role can see, as the server reports them.": "De cijfers die uw rol mag zien, zoals de server ze meldt.", + "Nothing to show": "Niets om te tonen", + "Could not be loaded": "Kon niet worden geladen", "User": "Gebruiker", "Administrator": "Beheerder", "Custom role": "Aangepaste rol", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 1767f1f..aefafdf 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -128,6 +128,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "Altere sua própria senha em {settings}.", "Administration": "Administração", "Directory": "Diretório", + "Overview": "Visão geral", + "Dashboard": "Painel", + "Users": "Usuários", + "User accounts": "Contas de usuário", + "Mail domains": "Domínios de e-mail", + "Pending": "Pendentes", + "Waiting in the delivery queue": "Na fila de entrega", + "Server memory": "Memória do servidor", + "As of {time}": "Em {time}", + "Not recorded on this server": "Não registrado neste servidor", + "Last 24 hours": "Últimas 24 horas", + "The numbers your role can see, as the server reports them.": "Os números que sua função pode ver, como o servidor os informa.", + "Nothing to show": "Nada para mostrar", + "Could not be loaded": "Não foi possível carregar", "User": "Usuário", "Administrator": "Administrador", "Custom role": "Função personalizada", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 8bdf1ae..9d534a0 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -127,6 +127,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.", "Administration": "Администрирование", "Directory": "Каталог", + "Overview": "Обзор", + "Dashboard": "Панель", + "Users": "Пользователи", + "User accounts": "Учётные записи пользователей", + "Mail domains": "Почтовые домены", + "Pending": "В очереди", + "Waiting in the delivery queue": "Ожидают в очереди доставки", + "Server memory": "Память сервера", + "As of {time}": "На {time}", + "Not recorded on this server": "На этом сервере не записывается", + "Last 24 hours": "За последние 24 часа", + "The numbers your role can see, as the server reports them.": "Показатели, доступные вашей роли, в том виде, в каком их сообщает сервер.", + "Nothing to show": "Нечего показать", + "Could not be loaded": "Не удалось загрузить", "User": "Пользователь", "Administrator": "Администратор", "Custom role": "Особая роль", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 0aa6e7e..a15a58a 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -121,6 +121,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.", "Administration": "Адміністрування", "Directory": "Каталог", + "Overview": "Огляд", + "Dashboard": "Панель", + "Users": "Користувачі", + "User accounts": "Облікові записи користувачів", + "Mail domains": "Поштові домени", + "Pending": "У черзі", + "Waiting in the delivery queue": "Очікують у черзі доставки", + "Server memory": "Пам’ять сервера", + "As of {time}": "Станом на {time}", + "Not recorded on this server": "На цьому сервері не записується", + "Last 24 hours": "За останні 24 години", + "The numbers your role can see, as the server reports them.": "Показники, доступні вашій ролі, у тому вигляді, як їх повідомляє сервер.", + "Nothing to show": "Нічого показати", + "Could not be loaded": "Не вдалося завантажити", "User": "Користувач", "Administrator": "Адміністратор", "Custom role": "Власна роль", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index e4035fd..cc70957 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -123,6 +123,20 @@ export const catalog: Catalog = { "Change your own password in {settings}.": "请在{settings}中更改您自己的密码。", "Administration": "管理", "Directory": "目录", + "Overview": "概览", + "Dashboard": "仪表板", + "Users": "用户", + "User accounts": "用户账户", + "Mail domains": "邮件域名", + "Pending": "待处理", + "Waiting in the delivery queue": "在投递队列中等待", + "Server memory": "服务器内存", + "As of {time}": "截至 {time}", + "Not recorded on this server": "此服务器未记录", + "Last 24 hours": "过去 24 小时", + "The numbers your role can see, as the server reports them.": "您的角色可以查看的数字,按服务器报告显示。", + "Nothing to show": "没有可显示的内容", + "Could not be loaded": "无法加载", "User": "用户", "Administrator": "管理员", "Custom role": "自定义角色", diff --git a/web/src/styles/app.css b/web/src/styles/app.css index c6f7216..74d6df1 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1745,6 +1745,22 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .admin-dns-type { flex: 0 0 48px; font-family: var(--font-mono); font-size: .78em; font-weight: 700; color: var(--accent-soft-fg); padding-top: 2px; } .admin-dns-name { font-size: .88em; font-weight: 550; word-break: break-all; } .admin-dns-value { display: block; font-family: var(--font-mono); font-size: .8em; color: var(--fg-muted); word-break: break-all; margin-top: 2px; max-height: 4.8em; overflow: hidden; } +/* Column counts come from balancedColumns, so rows stay even; the widths + follow the space the grid actually has, not the window, since the folder + pane beside it can be any width. */ +.admin-dashboard { max-width: calc(var(--cols-wide, 3) * 300px + (var(--cols-wide, 3) - 1) * 12px); } +.admin-cards-wrap { container: admin-cards / inline-size; margin-top: 8px; } +.admin-cards { display: grid; gap: 12px; grid-template-columns: repeat(var(--cols-wide, 3), minmax(0, 1fr)); max-width: calc(var(--cols-wide, 3) * 300px + (var(--cols-wide, 3) - 1) * 12px); } +@container admin-cards (max-width: 760px) { .admin-cards { grid-template-columns: repeat(var(--cols-mid, 2), minmax(0, 1fr)); max-width: calc(var(--cols-mid, 2) * 300px + (var(--cols-mid, 2) - 1) * 12px); } } +@container admin-cards (max-width: 480px) { .admin-cards { grid-template-columns: minmax(0, 1fr); max-width: none; } } +.admin-card { display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-elev); color: var(--fg); text-decoration: none; } +.admin-card.link:hover { background: var(--bg-hover); } +.admin-card.link:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.admin-card-top { display: flex; align-items: center; gap: 8px; color: var(--fg-muted); } +.admin-card-icon { display: inline-flex; color: var(--accent-soft-fg); } +.admin-card-label { font-size: .85em; font-weight: 650; text-transform: uppercase; letter-spacing: .05em; } +.admin-card-value { font-size: 1.9em; font-weight: 650; line-height: 1.15; font-variant-numeric: tabular-nums; min-height: 1.15em; } +.admin-card-placeholder { display: inline-block; width: 3.5ch; height: .9em; border-radius: var(--radius-sm); background: var(--bg-sunken); vertical-align: middle; } .admin-kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; align-items: center; margin: 0; font-size: .92em; } .admin-kv dt { color: var(--fg-muted); } .admin-kv dd { margin: 0; } diff --git a/web/src/views/admin/AdminDashboard.tsx b/web/src/views/admin/AdminDashboard.tsx new file mode 100644 index 0000000..580ac70 --- /dev/null +++ b/web/src/views/admin/AdminDashboard.tsx @@ -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 = { 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 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 cancelled = false; + const into = (set: (v: Loaded) => void, work: () => Promise) => { + 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) => (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.")}

+
+ +
+ {shown.length ? ( +
+
+ {shown.map((c) =>
{c.node}
)} +
+
+ ) : ( + } title={t("Nothing to show")} /> + )} +
+ ); +} + +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}
+ ); +} diff --git a/web/src/views/admin/AdminNav.tsx b/web/src/views/admin/AdminNav.tsx index b3d0bd5..93c174b 100644 --- a/web/src/views/admin/AdminNav.tsx +++ b/web/src/views/admin/AdminNav.tsx @@ -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 = { + dashboard: { group: "Overview", label: "Dashboard", icon: }, accounts: { group: "Directory", label: "Accounts", icon: }, domains: { group: "Mail", label: "Domains", icon: }, }; diff --git a/web/src/views/admin/AdminView.tsx b/web/src/views/admin/AdminView.tsx index f8f4abc..29dd867 100644 --- a/web/src/views/admin/AdminView.tsx +++ b/web/src/views/admin/AdminView.tsx @@ -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 ReactNode> = { + dashboard: () => , accounts: (id) => , domains: (id) => , }; @@ -16,8 +18,8 @@ const RENDER: Record 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()); diff --git a/web/src/views/admin/__tests__/admin-dashboard.test.tsx b/web/src/views/admin/__tests__/admin-dashboard.test.tsx new file mode 100644 index 0000000..ed52af4 --- /dev/null +++ b/web/src/views/admin/__tests__/admin-dashboard.test.tsx @@ -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, + metrics: (): Promise => Promise.resolve([]), +})); + +vi.mock("@/lib/adminDashboard", async (original) => ({ + ...(await original()), + 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: "a@example.com", 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(); + }); + 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"]); + }); +}); diff --git a/web/src/views/admin/__tests__/admin-nav.test.tsx b/web/src/views/admin/__tests__/admin-nav.test.tsx index dbd03d7..54bbeeb 100644 --- a/web/src/views/admin/__tests__/admin-nav.test.tsx +++ b/web/src/views/admin/__tests__/admin-nav.test.tsx @@ -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"]); }); });