Files
ihasmail/web/src/lib/adminDashboard.ts
T

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";
}