Files
ihasmail-inbuxa/web/src/lib/admin/adminDashboard.ts
T
jcoffey-dev f7712b1c1e Group the admin and calendar modules, and stop calling screenshots docs
web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.

  lib/admin/     adminAccess, adminDashboard, adminDirectory, adminDomains,
                 adminGroups, adminLists, adminRoles, adminTenants
  lib/calendar/  appointment, availabilityWindow, eventDrag, ics, recurrence

Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.

recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.

docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.

No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
2026-09-15 22:44:53 -07:00

129 lines
5.1 KiB
TypeScript

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 summarizeMetrics(records: readonly MetricRecord[]): MessageStats {
let received = 0;
let sent = 0;
let memory: MessageStats["memory"] = null;
const receivedNames = new Set<string>(RECEIVED_METRICS);
const sentNames = new Set<string>(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<number> {
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<MetricRecord[]> {
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";
}