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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 mail’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -94,6 +94,10 @@ export default function AdminPanel() {
|
||||
if (!section) return t('dashboard.title', 'Dashboard');
|
||||
if (!viewName) return section;
|
||||
if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`;
|
||||
if (viewName.startsWith('Dashboard/')) {
|
||||
const board = schema?.dashboards?.find((d) => d.id === viewName.slice('Dashboard/'.length));
|
||||
return `${board?.label ?? t('dashboard.title', 'Dashboard')} · ${t('dashboard.title', 'Dashboard')}`;
|
||||
}
|
||||
let label: string | undefined;
|
||||
for (const entry of searchIndex) {
|
||||
if (entry.type !== 'link' || entry.viewName !== viewName) continue;
|
||||
@@ -106,7 +110,7 @@ export default function AdminPanel() {
|
||||
const name = label ?? friendlyName(viewName);
|
||||
const title = id === 'new' ? t('form.createTitle', 'Create {{name}}', { name }) : name;
|
||||
return `${title} · ${section}`;
|
||||
}, [section, viewName, id, searchIndex, t]);
|
||||
}, [section, viewName, id, searchIndex, schema, t]);
|
||||
|
||||
useDocumentTitle(pageTitle);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user