Dashboard: every number leads somewhere, real counts, and new charts

- Cards and charts link to the page they're about: pending messages to the
  queue, bans to blocked IPs, report warnings to the reports, and so on,
  shown only to viewers who may open that page.
- A one-line status under the greeting: what needs a look (failed tasks,
  messages retrying, recipients given up on) or, when nothing does, what's
  there. Each phrase is a link.
- Counts from the server's own objects stand in for live metrics it can't
  report, and a live number with no source reads as unknown, not zero.
- Who uses the space: a treemap of people sized by storage, colored by how
  near their quota they are, each tile opening the account.
- Where mail is waiting: queued recipients by destination, split into
  waiting, retrying and given up, each row opening the filtered queue.
- The weekly rhythm: messages by hour and weekday, shown once metric
  history exists.
- Dashboard tabs are titled by their label.
This commit is contained in:
2026-09-19 01:50:07 -07:00
parent 5641560a91
commit 5bf09ceed5
16 changed files with 1021 additions and 10 deletions
@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
@@ -51,6 +52,7 @@ function ChartSizedContainer({
);
}
import { Info } from 'lucide-react';
import { GoLink } from './GoLink';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tooltip as UiTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getChartColor } from '@/components/ui/chart';
@@ -242,6 +244,7 @@ export function DashboardChart({ chart, historySamples, historyWindow, period }:
</UiTooltip>
</TooltipProvider>
)}
<GoLink metrics={chart.series.flatMap((x) => x.metrics)} />
</div>
</CardHeader>
<CardContent>
@@ -20,6 +20,18 @@ import { collectHistoryMetricIds, collectLiveMetricIds, periodKey, periodWindow,
import { StatCard } from './StatCard';
import { DashboardChart } from './DashboardChart';
import { PeriodSelector } from './PeriodSelector';
import { StatusLine } from './StatusLine';
import { StorageTreemap } from './StorageTreemap';
import { QueueWaiting } from './QueueWaiting';
import { WeeklyHeatmap } from './WeeklyHeatmap';
import { useServerFacts, type ServerFacts } from '../serverFacts';
/** INBUXA: live-metric cards the server's own objects can stand in for. */
const FALLBACKS: Record<string, keyof ServerFacts> = {
'user.count': 'users',
'domain.count': 'domains',
'queue.count': 'queued',
};
interface DashboardViewProps {
dashboardId: string;
@@ -39,6 +51,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
const liveStatus = useLiveMetricsStore((s) => s.status);
const liveError = useLiveMetricsStore((s) => s.error);
const { facts } = useServerFacts();
const dashboards = useMemo<Dashboard[]>(() => schema?.dashboards ?? [], [schema]);
const dashboard = dashboards.find((d) => d.id === dashboardId);
@@ -107,6 +120,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
return (
<div className="space-y-6">
<Greeting />
<StatusLine facts={facts} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{dashboards.length > 1 && (
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
@@ -129,7 +143,10 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
<AlertCircle className="h-4 w-4 shrink-0 text-highlight" />
<span>
{/404/.test(liveError)
? t('dashboard.liveUnavailable', "Live numbers aren't available on this server yet. The rest of the dashboard still works.")
? t(
'dashboard.liveUnavailable',
"Live numbers aren't available on this server yet. The rest of the dashboard still works.",
)
: liveError}
</span>
</div>
@@ -143,11 +160,25 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
card={card}
historySamples={historySamples}
historyWindow={historyWindow}
fallback={
card.metrics.length === 1 && FALLBACKS[card.metrics[0]]
? (facts?.[FALLBACKS[card.metrics[0]]] as number | undefined)
: undefined
}
/>
))}
</div>
)}
{dashboard.id === 'overview' && facts && (facts.storage || facts.waiting) && (
<div className="grid gap-4 lg:grid-cols-2">
{facts.waiting && <QueueWaiting waiting={facts.waiting} />}
{facts.storage && <StorageTreemap storage={facts.storage} />}
</div>
)}
{dashboard.id === 'overview' && <WeeklyHeatmap samples={historySamples} />}
{dashboard.charts && dashboard.charts.length > 0 && (
<div className="space-y-4">
{dashboard.charts.map((chart, i) => (
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowUpRight } from 'lucide-react';
import { hrefFor, useDashLink } from '../links';
/** "Open the queue ↗": the way from a chart to the page it's about. */
export function GoLink({ metrics }: { metrics: string[] }) {
const { t } = useTranslation();
const link = useDashLink(metrics);
if (!link) return null;
return (
<Link
to={hrefFor(link)}
className="ml-auto inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
>
{t(`dashLink.${link.viewName}`, link.label)}
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
);
}
@@ -27,9 +27,13 @@ function useFullName(username: string | null): string | null {
if (!username) return;
let live = true;
const localPart = username.split('@')[0];
jmapQueryAndGet('x:Account', getAccountId('x:Account'), { filter: { name: localPart } }, ['description', 'emailAddress'])
jmapQueryAndGet('x:Account', getAccountId('x:Account'), { filter: { name: localPart } }, [
'description',
'emailAddress',
])
.then((responses) => {
const list = (responses[1]?.[1] as { list?: { description?: string | null; emailAddress?: string }[] }).list ?? [];
const list =
(responses[1]?.[1] as { list?: { description?: string | null; emailAddress?: string }[] }).list ?? [];
const own = list.find((a) => a.emailAddress?.toLowerCase() === username.toLowerCase());
const name = own?.description?.trim();
if (live && name) setFullName(name);
@@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowUpRight, PartyPopper } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { WaitingDomain } from '../serverFacts';
const MAX_ROWS = 8;
/**
* Where outgoing mail is waiting, by the domain it's going to: a bar per
* destination, split into waiting its turn, retrying after a refusal, and
* given up on. A stuck provider stands out at a glance. A click opens the
* queue filtered to that destination.
*/
export function QueueWaiting({ waiting }: { waiting: WaitingDomain[] }) {
const { t } = useTranslation();
const rows = waiting.slice(0, MAX_ROWS);
const max = Math.max(1, ...rows.map((r) => r.scheduled + r.retrying + r.failed));
const legend = [
{ cls: 'bg-[var(--chart-1)]', label: t('queue.scheduled', 'Waiting its turn') },
{ cls: 'bg-amber-500', label: t('queue.retrying', 'Retrying') },
{ cls: 'bg-rose-500', label: t('queue.failed', 'Gave up') },
];
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 pb-3">
<div>
<CardTitle className="text-base">{t('queue.title', 'Where mail is waiting')}</CardTitle>
<p className="mt-0.5 text-sm text-muted-foreground">
{t('queue.subtitle', 'Outgoing recipients still in the queue, by destination')}
</p>
</div>
<Link
to="/Management/x:QueuedMessage"
className="inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
>
{t('queue.open', 'Open the queue')}
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<div className="flex h-[228px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm text-muted-foreground">
<PartyPopper className="h-6 w-6 text-emerald-500" />
{t('queue.empty', 'Nothing waiting. Everything has gone out.')}
</div>
) : (
<div className="space-y-2.5">
{rows.map((r) => {
const total = r.scheduled + r.retrying + r.failed;
const seg = (n: number) => `${(n / max) * 100}%`;
return (
<Link
key={r.domain}
to={`/Management/x:QueuedMessage?f.to=${encodeURIComponent(r.domain)}`}
className="group grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_2.5rem] items-center gap-3 text-sm"
title={t('queue.rowTitle', '{{domain}}: {{s}} waiting, {{r}} retrying, {{f}} gave up', {
domain: r.domain,
s: r.scheduled,
r: r.retrying,
f: r.failed,
})}
>
<span className="truncate font-mono text-xs group-hover:text-primary">{r.domain}</span>
<span className="flex h-3 overflow-hidden rounded-full bg-muted">
<span className="h-full bg-[var(--chart-1)] transition-all" style={{ width: seg(r.scheduled) }} />
<span className="h-full bg-amber-500 transition-all" style={{ width: seg(r.retrying) }} />
<span className="h-full bg-rose-500 transition-all" style={{ width: seg(r.failed) }} />
</span>
<span className="text-right tabular-nums text-muted-foreground">{total}</span>
</Link>
);
})}
<div className="flex flex-wrap gap-4 pt-2 text-xs text-muted-foreground">
{legend.map((l) => (
<span key={l.label} className="inline-flex items-center gap-1.5">
<span className={`h-2.5 w-2.5 rounded-full ${l.cls}`} />
{l.label}
</span>
))}
{waiting.length > MAX_ROWS &&
t('queue.more', '+{{count}} more destinations', { count: waiting.length - MAX_ROWS })}
</div>
</div>
)}
</CardContent>
</Card>
);
}
+37 -6
View File
@@ -7,7 +7,10 @@
import { IconTile } from '@/components/common/IconTile';
import { useMemo } from 'react';
import { Info } from 'lucide-react';
import { ArrowUpRight, Info } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { hrefFor, useDashLink } from '../links';
import { LineChart, Line } from 'recharts';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -22,20 +25,27 @@ interface StatCardProps {
card: CardSchema;
historySamples: Metric[];
historyWindow: { from: Date; to: Date };
/** INBUXA: a value counted from the server's objects, used when live metrics aren't available. */
fallback?: number;
}
export function StatCard({ card, historySamples, historyWindow }: StatCardProps) {
export function StatCard({ card, historySamples, historyWindow, fallback }: StatCardProps) {
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
const liveStatus = useLiveMetricsStore((s) => s.status);
const link = useDashLink(card.metrics);
const value = useMemo(() => {
if (card.source === 'live' && fallback !== undefined && liveStatus !== 'open') return fallback;
if (card.source === 'live') {
const liveSamples = card.metrics.map((id) => liveSnapshot.get(id)).filter((m): m is Metric => m !== undefined);
return cardValue(card, liveSamples);
}
return cardValue(card, historySamples);
}, [card, liveSnapshot, historySamples]);
}, [card, liveSnapshot, historySamples, fallback, liveStatus]);
const formattedValue = formatValue(value, card.format);
// INBUXA: a live number the server can't report yet reads as unknown, not as zero.
const unknown = card.source === 'live' && liveStatus !== 'open' && fallback === undefined;
const formattedValue = unknown ? '—' : formatValue(value, card.format);
const { from, to } = historyWindow;
@@ -52,12 +62,21 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
return computeDelta(card, historySamples, from, to);
}, [card, historySamples, from, to]);
return (
<Card className="transition-shadow hover:shadow-md">
const body = (
<Card
className={cn(
'h-full transition-all hover:shadow-md',
link &&
'group-hover:-translate-y-0.5 group-hover:border-primary/50 group-focus-visible:ring-2 group-focus-visible:ring-ring',
)}
>
<CardContent className="p-5">
<div className="flex items-center gap-2">
<IconTile name={card.icon} size="sm" />
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
{link && (
<ArrowUpRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-primary" />
)}
{card.description && (
<TooltipProvider>
<Tooltip>
@@ -102,4 +121,16 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps)
</CardContent>
</Card>
);
return link ? (
<Link
to={hrefFor(link)}
className="group block focus-visible:outline-none"
aria-label={`${card.title}: ${link.label}`}
>
{body}
</Link>
) : (
body
);
}
@@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Fragment, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, CircleAlert } from 'lucide-react';
import { usePermissions } from '@/hooks/usePermissions';
import { cn } from '@/lib/utils';
import type { ServerFacts } from '../serverFacts';
import { hrefFor, type DashLink } from '../links';
interface Phrase {
text: string;
link: DashLink;
}
/**
* The server's state in one sentence. Things that need a look come first
* and turn it amber; otherwise it says what's there. Every phrase is a link
* to where you'd deal with it.
*/
export function StatusLine({ facts }: { facts: ServerFacts | null }) {
const { t } = useTranslation();
const { canViewObject } = usePermissions();
if (!facts) return <div className="h-12" aria-hidden />;
const n = (key: string, count: number, one: string, other: string) =>
t(key, { count, defaultValue_one: one, defaultValue_other: other });
const attention: Phrase[] = [];
if (facts.failedTasks)
attention.push({
text: n('status.failedTasks', facts.failedTasks, '{{count}} failed task', '{{count}} failed tasks'),
link: { viewName: 'x:Task/TaskFailed', section: 'Management', label: '' },
});
if (facts.retrying)
attention.push({
text: n('status.retrying', facts.retrying, '{{count}} message retrying', '{{count}} messages retrying'),
link: { viewName: 'x:QueuedMessage', section: 'Management', label: '' },
});
const bounced = facts.waiting?.reduce((s, w) => s + w.failed, 0) ?? 0;
if (bounced)
attention.push({
text: n('status.bounced', bounced, '{{count}} recipient failed', '{{count}} recipients failed'),
link: { viewName: 'x:QueuedMessage', section: 'Management', label: '' },
});
const present: Phrase[] = [];
if (facts.users !== undefined)
present.push({
text: n('status.people', facts.users, '{{count}} person', '{{count}} people'),
link: { viewName: 'x:Account/User', section: 'Management', label: '' },
});
if (facts.domains !== undefined)
present.push({
text: n('status.domains', facts.domains, '{{count}} domain', '{{count}} domains'),
link: { viewName: 'x:Domain', section: 'Management', label: '' },
});
if (facts.queued !== undefined)
present.push({
text: facts.queued
? n('status.queued', facts.queued, '{{count}} message waiting to send', '{{count}} messages waiting to send')
: t('status.queueEmpty', 'nothing waiting to send'),
link: { viewName: 'x:QueuedMessage', section: 'Management', label: '' },
});
if (facts.blockedIps)
present.push({
text: n('status.blocked', facts.blockedIps, '{{count}} address blocked', '{{count}} addresses blocked'),
link: { viewName: 'x:BlockedIp', section: 'Settings', label: '' },
});
const needsLook = attention.length > 0;
const phrases = needsLook ? attention : present;
const visible = phrases.filter((p) => canViewObject(p.link.viewName));
if (visible.length === 0) return null;
const lead = needsLook
? n('status.needsLook', attention.length, 'One thing needs a look:', '{{count}} things need a look:')
: t('status.allGood', 'All good:');
const list: ReactNode[] = visible.map((p, i) => (
<Fragment key={p.text}>
{i > 0 && (i === visible.length - 1 ? t('status.and', ' and ') : ', ')}
<Link
to={hrefFor(p.link)}
className="font-medium text-foreground underline decoration-primary/40 decoration-2 underline-offset-4 transition-colors hover:decoration-primary"
>
{p.text}
</Link>
</Fragment>
));
return (
<div
className={cn(
'flex items-center gap-3 rounded-2xl border px-5 py-3.5 text-[15px]',
needsLook ? 'border-highlight/40 bg-highlight-soft' : 'border-emerald-500/25 bg-emerald-500/5',
)}
>
{needsLook ? (
<CircleAlert className="h-5 w-5 shrink-0 text-highlight" />
) : (
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-500" />
)}
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{lead}</span> {list}.
</p>
</div>
);
}
@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowUpRight, HardDrive } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { formatValue } from '../helpers';
import { squarify } from '../treemap';
import type { StorageUse } from '../serverFacts';
const MAX_TILES = 24;
const HEIGHT = 260;
/** How full an account is, as a tile color: calm until it nears its quota. */
function fillClass(u: StorageUse): string {
if (!u.quota) return 'bg-[var(--chart-1)]/75 hover:bg-[var(--chart-1)]';
const pct = u.used / u.quota;
if (pct >= 0.9) return 'bg-rose-500/80 hover:bg-rose-500';
if (pct >= 0.75) return 'bg-amber-500/80 hover:bg-amber-500';
return 'bg-[var(--chart-1)]/75 hover:bg-[var(--chart-1)]';
}
/**
* Who uses the storage, as a map: every person is a tile sized by the space
* their mail, files and calendars take, and colored by how close they are to
* their quota. The biggest users are the biggest tiles; a click opens them.
*/
export function StorageTreemap({ storage }: { storage: StorageUse[] }) {
const { t } = useTranslation();
const box = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = box.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
ro.observe(el);
return () => ro.disconnect();
}, []);
const { tiles, total, others } = useMemo(() => {
const sorted = [...storage].filter((s) => s.used > 0).sort((a, b) => b.used - a.used);
const top = sorted.slice(0, MAX_TILES);
const rest = sorted.slice(MAX_TILES);
const restUse = rest.reduce((s, r) => s + r.used, 0);
const items: StorageUse[] = restUse
? [
...top,
{ id: '', name: t('storage.others', '{{count}} others', { count: rest.length }), used: restUse, quota: null },
]
: top;
return {
tiles: squarify(items, (i) => i.used, width, HEIGHT),
total: sorted.reduce((s, r) => s + r.used, 0),
others: rest.length,
};
}, [storage, width, t]);
const nearFull = storage.filter((s) => s.quota && s.used / s.quota >= 0.9).length;
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 pb-3">
<div>
<CardTitle className="text-base">{t('storage.title', 'Who uses the space')}</CardTitle>
<p className="mt-0.5 text-sm text-muted-foreground">
{total > 0
? t('storage.subtitle', '{{total}} across {{count}} people', {
total: formatValue(total, 'bytes'),
count: storage.length,
})
: t('storage.empty', 'No one has stored anything yet.')}
{nearFull > 0 && (
<span className="ml-2 font-medium text-rose-600 dark:text-rose-400">
{t('storage.nearFull', {
count: nearFull,
defaultValue_one: '{{count}} nearly full',
defaultValue_other: '{{count}} nearly full',
})}
</span>
)}
</p>
</div>
<Link
to="/Management/x:Account/User"
className="inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline"
>
{t('storage.seePeople', 'See people')}
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</CardHeader>
<CardContent>
<div ref={box} className="relative w-full overflow-hidden rounded-xl" style={{ height: HEIGHT }}>
{total === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-sm text-muted-foreground">
<HardDrive className="h-6 w-6" />
{t('storage.emptyHint', 'Tiles appear here as people store mail and files.')}
</div>
)}
{tiles.map(({ item, x, y, w, h }) => {
const pct = item.quota ? Math.round((item.used / item.quota) * 100) : null;
const label = `${item.name}: ${formatValue(item.used, 'bytes')}${pct !== null ? ` (${pct}%)` : ''}`;
const roomy = w > 90 && h > 44;
const style = { left: x + 1, top: y + 1, width: Math.max(0, w - 2), height: Math.max(0, h - 2) };
const body = (
<>
{roomy && (
<>
<span className="block truncate text-xs font-medium">{item.name}</span>
<span className="block text-[11px] opacity-80">
{formatValue(item.used, 'bytes')}
{pct !== null && ` · ${pct}%`}
</span>
</>
)}
</>
);
const cls = cn(
'absolute overflow-hidden rounded-md p-2 text-left text-white transition-colors',
item.id ? fillClass(item) : 'bg-muted-foreground/40',
);
return item.id ? (
<Link
key={item.id}
to={`/Management/x:Account/User/${item.id}`}
title={label}
className={cls}
style={style}
>
{body}
</Link>
) : (
<div key="others" title={label} className={cls} style={style}>
{body}
</div>
);
})}
</div>
{others > 0 && (
<p className="mt-2 text-xs text-muted-foreground">
{t('storage.topOnly', 'The {{count}} biggest are shown on their own.', { count: MAX_TILES })}
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { Metric } from '../types/metrics';
import { weeklyGrid } from '../rhythm';
/**
* The server's weekly rhythm: one square per hour of the week, darker the
* more mail moved through it. Busy mornings, quiet weekends and a 3 a.m.
* spike that shouldn't be there all show at a glance. Hidden until
* monitoring has recorded something.
*/
export function WeeklyHeatmap({ samples }: { samples: Metric[] }) {
const { t, i18n } = useTranslation();
const grid = useMemo(() => weeklyGrid(samples), [samples]);
const max = Math.max(...grid.flat());
const days = useMemo(() => {
const fmt = new Intl.DateTimeFormat(i18n.language, { weekday: 'short' });
// 2024-01-01 was a Monday.
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(2024, 0, 1 + i)));
}, [i18n.language]);
if (max <= 0) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('rhythm.title', 'Your mails weekly rhythm')}</CardTitle>
<p className="text-sm text-muted-foreground">
{t('rhythm.subtitle', 'Messages handled by hour and day, over the period above')}
</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="grid min-w-[560px] grid-cols-[2.5rem_repeat(24,minmax(0,1fr))] gap-1">
{grid.map((row, d) => (
<div key={d} className="contents">
<span className="self-center text-xs text-muted-foreground">{days[d]}</span>
{row.map((v, h) => (
<span
key={h}
title={t('rhythm.cell', '{{day}} {{hour}}:00, {{count}} messages', {
day: days[d],
hour: String(h).padStart(2, '0'),
count: v,
})}
className="aspect-square rounded-[3px] bg-[var(--chart-1)] transition-transform hover:scale-125"
style={{ opacity: v === 0 ? 0.08 : 0.2 + 0.8 * (v / max) }}
/>
))}
</div>
))}
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} className="text-center text-[10px] text-muted-foreground">
{h % 6 === 0 ? h : ''}
</span>
))}
</div>
</div>
</CardContent>
</Card>
);
}
+70
View File
@@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { hrefFor, linkForMetrics } from './links';
import { summarizeQueue } from './serverFacts';
import { weeklyGrid } from './rhythm';
describe('linkForMetrics', () => {
it('sends each number to where you act on it', () => {
expect(linkForMetrics(['queue.count'])?.viewName).toBe('x:QueuedMessage');
expect(linkForMetrics(['user.count'])?.viewName).toBe('x:Account/User');
expect(linkForMetrics(['queue.message-queued'])?.viewName).toBe('x:Trace/InboundDelivery');
expect(linkForMetrics(['queue.authenticated-message-queued'])?.viewName).toBe('x:Trace/OutboundDelivery');
expect(linkForMetrics(['security.scan-ban'])).toMatchObject({ viewName: 'x:BlockedIp', section: 'Settings' });
expect(linkForMetrics(['incoming-report.tls-report-with-warnings'])?.viewName).toBe('x:TlsExternalReport');
});
it('takes the first metric that has a home', () => {
expect(linkForMetrics(['no.such', 'domain.count'])?.viewName).toBe('x:Domain');
expect(linkForMetrics(['no.such'])).toBeNull();
});
it('carries filters the list page understands', () => {
expect(hrefFor({ viewName: 'x:QueuedMessage', section: 'Management', label: '', filters: { to: 'a.com' } })).toBe(
'/Management/x:QueuedMessage?f.to=a.com',
);
});
});
describe('summarizeQueue', () => {
it('groups outstanding recipients by destination, busiest first', () => {
const { waiting, retrying } = summarizeQueue([
{
recipients: {
'[email protected]': { status: { '@type': 'TemporaryFailure' } },
'[email protected]': { status: { '@type': 'Scheduled' } },
'[email protected]': { status: { '@type': 'Completed' } },
},
},
{ recipients: { '[email protected]': { status: { '@type': 'PermanentFailure' } } } },
{ recipients: { '[email protected]': {} } },
]);
expect(waiting).toEqual([
{ domain: 'gmail.com', scheduled: 2, retrying: 1, failed: 0 },
{ domain: 'example.org', scheduled: 0, retrying: 0, failed: 1 },
]);
expect(retrying).toBe(1);
});
});
describe('weeklyGrid', () => {
it('buckets samples by local weekday (Monday first) and hour', () => {
const at = (d: Date, count: number, metric = 'queue.message-queued') => ({
'@type': 'Counter' as const,
metric,
count,
timestamp: d.toISOString(),
});
const monday9 = new Date(2024, 0, 1, 9, 30);
const sunday23 = new Date(2024, 0, 7, 23, 5);
const grid = weeklyGrid([at(monday9, 3), at(monday9, 2), at(sunday23, 1), at(monday9, 50, 'other.metric')]);
expect(grid[0][9]).toBe(5);
expect(grid[6][23]).toBe(1);
expect(grid.flat().reduce((a, b) => a + b, 0)).toBe(6);
});
});
+72
View File
@@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Where each number on the dashboard leads: the page where you can act on
* what it counts. Matched on the metric's name, most specific first; a card
* or chart takes the link of its first metric that has one.
*/
import { usePermissions } from '@/hooks/usePermissions';
export interface DashLink {
/** A view name from the server's layout, e.g. x:QueuedMessage. */
viewName: string;
/** The layout section the view lives in. */
section: 'Management' | 'Settings';
/** Filters applied on arrival, as the list page's own f.* URL filters. */
filters?: Record<string, string>;
/** What the link says, in a few words: "Open the queue". */
label: string;
}
const RULES: [RegExp, DashLink][] = [
[/^user\.count$/, { viewName: 'x:Account/User', section: 'Management', label: 'See people' }],
[/^domain\.count$/, { viewName: 'x:Domain', section: 'Management', label: 'See domains' }],
[/^queue\.count$/, { viewName: 'x:QueuedMessage', section: 'Management', label: 'Open the queue' }],
[
/^queue\.message-queued$/,
{ viewName: 'x:Trace/InboundDelivery', section: 'Management', label: 'See deliveries in' },
],
[/^queue\./, { viewName: 'x:Trace/OutboundDelivery', section: 'Management', label: 'See deliveries out' }],
[/^delivery\./, { viewName: 'x:Trace/OutboundDelivery', section: 'Management', label: 'See deliveries out' }],
[
/^smtp\.connection-start$/,
{ viewName: 'x:Trace/InboundDelivery', section: 'Management', label: 'See deliveries in' },
],
[/^security\.|^auth\.failed$/, { viewName: 'x:BlockedIp', section: 'Settings', label: 'See blocked IPs' }],
[/^message-ingest\.(spam|ham)$/, { viewName: 'x:SpamSettings', section: 'Settings', label: 'Spam filter' }],
[
/^incoming-report\.dmarc/,
{ viewName: 'x:DmarcExternalReport', section: 'Management', label: 'Open DMARC reports' },
],
[/^incoming-report\.tls/, { viewName: 'x:TlsExternalReport', section: 'Management', label: 'Open TLS reports' }],
[/^server\.memory$|^store\./, { viewName: 'x:Log', section: 'Management', label: 'See the logs' }],
[/^message-ingest\.|^dns\./, { viewName: 'x:Log', section: 'Management', label: 'See the logs' }],
];
export function linkForMetrics(metrics: string[]): DashLink | null {
for (const m of metrics) {
const hit = RULES.find(([re]) => re.test(m));
if (hit) return hit[1];
}
return null;
}
/** The address of a link, with its filters as the list page reads them. */
export function hrefFor(link: DashLink): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(link.filters ?? {})) q.set(`f.${k}`, v);
const qs = q.toString();
return `/${link.section}/${link.viewName}${qs ? `?${qs}` : ''}`;
}
/** The page a set of metrics leads to, if the viewer may open it. */
export function useDashLink(metrics: string[]): DashLink | null {
const { canViewObject } = usePermissions();
const link = linkForMetrics(metrics);
return link && canViewObject(link.viewName) ? link : null;
}
+28
View File
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Metric } from './types/metrics';
/** Received plus sent: every message the server handled. */
export const RHYTHM_METRICS = [
'queue.message-queued',
'queue.authenticated-message-queued',
'queue.dsn-queued',
'queue.report-queued',
];
/** Sum the samples into a 7×24 grid, Monday first, in the viewer's time zone. */
export function weeklyGrid(samples: Metric[], metrics: string[] = RHYTHM_METRICS): number[][] {
const want = new Set(metrics);
const grid = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
for (const s of samples) {
if (!want.has(s.metric) || !s.timestamp) continue;
const d = new Date(s.timestamp);
if (Number.isNaN(d.getTime())) continue;
grid[(d.getDay() + 6) % 7][d.getHours()] += s.count;
}
return grid;
}
+191
View File
@@ -0,0 +1,191 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* What the dashboard can know from the server's ordinary objects, with no
* metrics at all: how many people, domains, queued messages, blocked
* addresses, failed tasks and reports there are, who uses how much storage,
* and where queued mail is stuck. One JMAP request, refreshed every minute.
* A query the viewer isn't allowed to run just leaves that fact out.
*/
import { useCallback, useEffect, useState } from 'react';
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import type { JmapMethodCall } from '@/types/jmap';
export interface StorageUse {
id: string;
name: string;
used: number;
/** The account's disk quota in bytes, when it has one. */
quota: number | null;
}
export type RecipientState = 'Scheduled' | 'TemporaryFailure' | 'PermanentFailure' | 'Completed';
export interface WaitingDomain {
domain: string;
scheduled: number;
retrying: number;
failed: number;
}
export interface ServerFacts {
users?: number;
groups?: number;
domains?: number;
queued?: number;
blockedIps?: number;
failedTasks?: number;
dmarcReports?: number;
tlsReports?: number;
storage?: StorageUse[];
waiting?: WaitingDomain[];
/** Messages with at least one recipient in temporary failure. */
retrying?: number;
}
const COUNTS: [keyof ServerFacts, string, Record<string, unknown>?][] = [
['users', 'x:Account', { '@type': 'User' }],
['groups', 'x:Account', { '@type': 'Group' }],
['domains', 'x:Domain'],
['queued', 'x:QueuedMessage'],
['blockedIps', 'x:BlockedIp'],
['failedTasks', 'x:Task', { status: 'Failed' }],
['dmarcReports', 'x:DmarcExternalReport'],
['tlsReports', 'x:TlsExternalReport'],
];
const DETAIL_LIMIT = 250;
function quotaOf(quotas: unknown): number | null {
if (!quotas || typeof quotas !== 'object') return null;
const q = quotas as Record<string, unknown>;
const disk = q.maxDiskQuota ?? q.diskQuota ?? q.disk;
return typeof disk === 'number' && disk > 0 ? disk : null;
}
interface QueuedRow {
recipients?: Record<string, { status?: { '@type'?: string } }>;
}
export function summarizeQueue(list: QueuedRow[]): { waiting: WaitingDomain[]; retrying: number } {
const byDomain = new Map<string, WaitingDomain>();
let retrying = 0;
for (const m of list) {
let anyRetry = false;
for (const [addr, r] of Object.entries(m.recipients ?? {})) {
const state = (r.status?.['@type'] ?? 'Scheduled') as RecipientState;
if (state === 'Completed') continue;
const domain = addr.split('@')[1]?.toLowerCase() ?? addr;
const row = byDomain.get(domain) ?? { domain, scheduled: 0, retrying: 0, failed: 0 };
if (state === 'TemporaryFailure') {
row.retrying++;
anyRetry = true;
} else if (state === 'PermanentFailure') row.failed++;
else row.scheduled++;
byDomain.set(domain, row);
}
if (anyRetry) retrying++;
}
const waiting = [...byDomain.values()].sort(
(a, b) => b.scheduled + b.retrying + b.failed - (a.scheduled + a.retrying + a.failed),
);
return { waiting, retrying };
}
async function fetchFacts(): Promise<ServerFacts> {
const calls: JmapMethodCall[] = [];
const accountFor = (obj: string) => {
try {
return getAccountId(obj);
} catch {
return null;
}
};
COUNTS.forEach(([key, obj, filter]) => {
const accountId = accountFor(obj);
if (!accountId) return;
calls.push([`${obj}/query`, { accountId, filter, limit: 1, calculateTotal: true }, `c:${key}`]);
});
const accountAcct = accountFor('x:Account');
if (accountAcct) {
calls.push([
'x:Account/query',
{ accountId: accountAcct, filter: { '@type': 'User' }, limit: DETAIL_LIMIT },
'q:storage',
]);
calls.push([
'x:Account/get',
{
accountId: accountAcct,
'#ids': { resultOf: 'q:storage', name: 'x:Account/query', path: '/ids' },
properties: ['emailAddress', 'name', 'usedDiskQuota', 'quotas'],
},
'g:storage',
]);
}
const queueAcct = accountFor('x:QueuedMessage');
if (queueAcct) {
calls.push(['x:QueuedMessage/query', { accountId: queueAcct, limit: DETAIL_LIMIT }, 'q:queue']);
calls.push([
'x:QueuedMessage/get',
{
accountId: queueAcct,
'#ids': { resultOf: 'q:queue', name: 'x:QueuedMessage/query', path: '/ids' },
properties: ['recipients'],
},
'g:queue',
]);
}
const responses = await jmapRequest(calls);
const facts: ServerFacts = {};
for (const [name, body, tag] of responses) {
if (name === 'error') continue;
const b = body as Record<string, unknown>;
if (tag.startsWith('c:') && typeof b.total === 'number') {
(facts as Record<string, unknown>)[tag.slice(2)] = b.total;
} else if (tag === 'g:storage') {
facts.storage = ((b.list as Record<string, unknown>[]) ?? []).map((a) => ({
id: String(a.id),
name: String(a.emailAddress ?? a.name ?? a.id),
used: typeof a.usedDiskQuota === 'number' ? a.usedDiskQuota : 0,
quota: quotaOf(a.quotas),
}));
} else if (tag === 'g:queue') {
Object.assign(facts, summarizeQueue((b.list as QueuedRow[]) ?? []));
}
}
return facts;
}
const REFRESH_MS = 60_000;
export function useServerFacts() {
const [facts, setFacts] = useState<ServerFacts | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
fetchFacts()
.then((f) => {
setFacts(f);
setError(null);
})
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
}, []);
useEffect(() => {
// Fetching syncs with the server; state lands from the promise, not here.
const first = setTimeout(refresh, 0);
const timer = setInterval(refresh, REFRESH_MS);
return () => {
clearTimeout(first);
clearInterval(timer);
};
}, [refresh]);
return { facts, error, refresh };
}
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { squarify } from './treemap';
describe('squarify', () => {
it('fills the box, with areas in proportion to the values', () => {
const tiles = squarify([6, 6, 4, 3, 2, 2, 1], (v) => v, 600, 400);
expect(tiles).toHaveLength(7);
const area = tiles.reduce((s, t) => s + t.w * t.h, 0);
expect(area).toBeCloseTo(600 * 400, 3);
for (const t of tiles) {
expect(t.w * t.h).toBeCloseTo((t.item / 24) * 600 * 400, 3);
expect(t.x).toBeGreaterThanOrEqual(-1e-9);
expect(t.y).toBeGreaterThanOrEqual(-1e-9);
expect(t.x + t.w).toBeLessThanOrEqual(600 + 1e-6);
expect(t.y + t.h).toBeLessThanOrEqual(400 + 1e-6);
}
});
it('keeps tiles reasonably square', () => {
const tiles = squarify([6, 6, 4, 3, 2, 2, 1], (v) => v, 600, 400);
const worst = Math.max(...tiles.map((t) => Math.max(t.w / t.h, t.h / t.w)));
expect(worst).toBeLessThan(4);
});
it('gives nothing for nothing', () => {
expect(squarify([0, 0], (v) => v, 100, 100)).toEqual([]);
expect(squarify([], (v: number) => v, 100, 100)).toEqual([]);
expect(squarify([1], (v) => v, 0, 100)).toEqual([]);
});
});
+82
View File
@@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface Tile<T> {
item: T;
x: number;
y: number;
w: number;
h: number;
}
/**
* Squarified treemap (Bruls, Huizing and van Wijk): lay the items out in a
* w×h box, each with area proportional to its value, keeping tiles as close
* to square as the values allow. Items with no value get no tile.
*/
export function squarify<T>(items: T[], value: (item: T) => number, w: number, h: number): Tile<T>[] {
const nodes = items
.map((item) => ({ item, v: Math.max(0, value(item)) }))
.filter((n) => n.v > 0)
.sort((a, b) => b.v - a.v);
const total = nodes.reduce((s, n) => s + n.v, 0);
if (total === 0 || w <= 0 || h <= 0) return [];
const scale = (w * h) / total;
const areas = nodes.map((n) => ({ item: n.item, a: n.v * scale }));
const out: Tile<T>[] = [];
let x = 0;
let y = 0;
let rw = w;
let rh = h;
const worst = (row: { a: number }[], side: number) => {
const sum = row.reduce((s, r) => s + r.a, 0);
const max = Math.max(...row.map((r) => r.a));
const min = Math.min(...row.map((r) => r.a));
return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min));
};
const place = (row: { item: T; a: number }[]) => {
const sum = row.reduce((s, r) => s + r.a, 0);
if (rw >= rh) {
// A column on the left.
const cw = sum / rh;
let cy = y;
for (const r of row) {
const th = r.a / cw;
out.push({ item: r.item, x, y: cy, w: cw, h: th });
cy += th;
}
x += cw;
rw -= cw;
} else {
// A row along the top.
const ch = sum / rw;
let cx = x;
for (const r of row) {
const tw = r.a / ch;
out.push({ item: r.item, x: cx, y, w: tw, h: ch });
cx += tw;
}
y += ch;
rh -= ch;
}
};
let row: { item: T; a: number }[] = [];
for (const node of areas) {
const side = Math.min(rw, rh);
if (row.length === 0 || worst([...row, node], side) <= worst(row, side)) {
row.push(node);
} else {
place(row);
row = [node];
}
}
if (row.length) place(row);
return out;
}