From 5bf09ceed558cb6effbee9ca1745bcae5933ae67 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 01:50:07 -0700 Subject: [PATCH] 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. --- .../dashboard/components/DashboardChart.tsx | 3 + .../dashboard/components/DashboardView.tsx | 33 ++- src/features/dashboard/components/GoLink.tsx | 26 +++ .../dashboard/components/Greeting.tsx | 8 +- .../dashboard/components/QueueWaiting.tsx | 96 +++++++++ .../dashboard/components/StatCard.tsx | 43 +++- .../dashboard/components/StatusLine.tsx | 114 +++++++++++ .../dashboard/components/StorageTreemap.tsx | 153 ++++++++++++++ .../dashboard/components/WeeklyHeatmap.tsx | 70 +++++++ src/features/dashboard/insights.test.ts | 70 +++++++ src/features/dashboard/links.ts | 72 +++++++ src/features/dashboard/rhythm.ts | 28 +++ src/features/dashboard/serverFacts.ts | 191 ++++++++++++++++++ src/features/dashboard/treemap.test.ts | 36 ++++ src/features/dashboard/treemap.ts | 82 ++++++++ src/pages/AdminPanel.tsx | 6 +- 16 files changed, 1021 insertions(+), 10 deletions(-) create mode 100644 src/features/dashboard/components/GoLink.tsx create mode 100644 src/features/dashboard/components/QueueWaiting.tsx create mode 100644 src/features/dashboard/components/StatusLine.tsx create mode 100644 src/features/dashboard/components/StorageTreemap.tsx create mode 100644 src/features/dashboard/components/WeeklyHeatmap.tsx create mode 100644 src/features/dashboard/insights.test.ts create mode 100644 src/features/dashboard/links.ts create mode 100644 src/features/dashboard/rhythm.ts create mode 100644 src/features/dashboard/serverFacts.ts create mode 100644 src/features/dashboard/treemap.test.ts create mode 100644 src/features/dashboard/treemap.ts diff --git a/src/features/dashboard/components/DashboardChart.tsx b/src/features/dashboard/components/DashboardChart.tsx index 61e162c..d95f980 100644 --- a/src/features/dashboard/components/DashboardChart.tsx +++ b/src/features/dashboard/components/DashboardChart.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 }: )} + x.metrics)} /> diff --git a/src/features/dashboard/components/DashboardView.tsx b/src/features/dashboard/components/DashboardView.tsx index 537eb8f..84c72bc 100644 --- a/src/features/dashboard/components/DashboardView.tsx +++ b/src/features/dashboard/components/DashboardView.tsx @@ -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 = { + '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(() => schema?.dashboards ?? [], [schema]); const dashboard = dashboards.find((d) => d.id === dashboardId); @@ -107,6 +120,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) { return (
+
{dashboards.length > 1 && ( navigate(`/${section}/Dashboard/${id}`)}> @@ -129,7 +143,10 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) { {/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}
@@ -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 + } /> ))}
)} + {dashboard.id === 'overview' && facts && (facts.storage || facts.waiting) && ( +
+ {facts.waiting && } + {facts.storage && } +
+ )} + + {dashboard.id === 'overview' && } + {dashboard.charts && dashboard.charts.length > 0 && (
{dashboard.charts.map((chart, i) => ( diff --git a/src/features/dashboard/components/GoLink.tsx b/src/features/dashboard/components/GoLink.tsx new file mode 100644 index 0000000..fc788a9 --- /dev/null +++ b/src/features/dashboard/components/GoLink.tsx @@ -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 ( + + {t(`dashLink.${link.viewName}`, link.label)} + + + ); +} diff --git a/src/features/dashboard/components/Greeting.tsx b/src/features/dashboard/components/Greeting.tsx index 3ebfeb6..eaed44d 100644 --- a/src/features/dashboard/components/Greeting.tsx +++ b/src/features/dashboard/components/Greeting.tsx @@ -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); diff --git a/src/features/dashboard/components/QueueWaiting.tsx b/src/features/dashboard/components/QueueWaiting.tsx new file mode 100644 index 0000000..eaaf8da --- /dev/null +++ b/src/features/dashboard/components/QueueWaiting.tsx @@ -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 ( + + +
+ {t('queue.title', 'Where mail is waiting')} +

+ {t('queue.subtitle', 'Outgoing recipients still in the queue, by destination')} +

+
+ + {t('queue.open', 'Open the queue')} + + +
+ + {rows.length === 0 ? ( +
+ + {t('queue.empty', 'Nothing waiting. Everything has gone out.')} +
+ ) : ( +
+ {rows.map((r) => { + const total = r.scheduled + r.retrying + r.failed; + const seg = (n: number) => `${(n / max) * 100}%`; + return ( + + {r.domain} + + + + + + {total} + + ); + })} +
+ {legend.map((l) => ( + + + {l.label} + + ))} + {waiting.length > MAX_ROWS && + t('queue.more', '+{{count}} more destinations', { count: waiting.length - MAX_ROWS })} +
+
+ )} +
+
+ ); +} diff --git a/src/features/dashboard/components/StatCard.tsx b/src/features/dashboard/components/StatCard.tsx index 3bb8d55..ca59e00 100644 --- a/src/features/dashboard/components/StatCard.tsx +++ b/src/features/dashboard/components/StatCard.tsx @@ -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 ( - + const body = ( +
{card.title} + {link && ( + + )} {card.description && ( @@ -102,4 +121,16 @@ export function StatCard({ card, historySamples, historyWindow }: StatCardProps) ); + + return link ? ( + + {body} + + ) : ( + body + ); } diff --git a/src/features/dashboard/components/StatusLine.tsx b/src/features/dashboard/components/StatusLine.tsx new file mode 100644 index 0000000..7001c05 --- /dev/null +++ b/src/features/dashboard/components/StatusLine.tsx @@ -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
; + + 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) => ( + + {i > 0 && (i === visible.length - 1 ? t('status.and', ' and ') : ', ')} + + {p.text} + + + )); + + return ( +
+ {needsLook ? ( + + ) : ( + + )} +

+ {lead} {list}. +

+
+ ); +} diff --git a/src/features/dashboard/components/StorageTreemap.tsx b/src/features/dashboard/components/StorageTreemap.tsx new file mode 100644 index 0000000..94ee125 --- /dev/null +++ b/src/features/dashboard/components/StorageTreemap.tsx @@ -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(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 ( + + +
+ {t('storage.title', 'Who uses the space')} +

+ {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 && ( + + {t('storage.nearFull', { + count: nearFull, + defaultValue_one: '{{count}} nearly full', + defaultValue_other: '{{count}} nearly full', + })} + + )} +

+
+ + {t('storage.seePeople', 'See people')} + + +
+ +
+ {total === 0 && ( +
+ + {t('storage.emptyHint', 'Tiles appear here as people store mail and files.')} +
+ )} + {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 && ( + <> + {item.name} + + {formatValue(item.used, 'bytes')} + {pct !== null && ` · ${pct}%`} + + + )} + + ); + 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 ? ( + + {body} + + ) : ( +
+ {body} +
+ ); + })} +
+ {others > 0 && ( +

+ {t('storage.topOnly', 'The {{count}} biggest are shown on their own.', { count: MAX_TILES })} +

+ )} +
+
+ ); +} diff --git a/src/features/dashboard/components/WeeklyHeatmap.tsx b/src/features/dashboard/components/WeeklyHeatmap.tsx new file mode 100644 index 0000000..54d96ff --- /dev/null +++ b/src/features/dashboard/components/WeeklyHeatmap.tsx @@ -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 ( + + + {t('rhythm.title', 'Your mail’s weekly rhythm')} +

+ {t('rhythm.subtitle', 'Messages handled by hour and day, over the period above')} +

+
+ +
+
+ {grid.map((row, d) => ( +
+ {days[d]} + {row.map((v, h) => ( + + ))} +
+ ))} + + {Array.from({ length: 24 }, (_, h) => ( + + {h % 6 === 0 ? h : ''} + + ))} +
+
+
+
+ ); +} diff --git a/src/features/dashboard/insights.test.ts b/src/features/dashboard/insights.test.ts new file mode 100644 index 0000000..44ef4e2 --- /dev/null +++ b/src/features/dashboard/insights.test.ts @@ -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: { + 'a@gmail.com': { status: { '@type': 'TemporaryFailure' } }, + 'b@gmail.com': { status: { '@type': 'Scheduled' } }, + 'c@example.org': { status: { '@type': 'Completed' } }, + }, + }, + { recipients: { 'd@Example.org': { status: { '@type': 'PermanentFailure' } } } }, + { recipients: { 'e@gmail.com': {} } }, + ]); + 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); + }); +}); diff --git a/src/features/dashboard/links.ts b/src/features/dashboard/links.ts new file mode 100644 index 0000000..6c877f0 --- /dev/null +++ b/src/features/dashboard/links.ts @@ -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; + /** 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; +} diff --git a/src/features/dashboard/rhythm.ts b/src/features/dashboard/rhythm.ts new file mode 100644 index 0000000..1cca509 --- /dev/null +++ b/src/features/dashboard/rhythm.ts @@ -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(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; +} diff --git a/src/features/dashboard/serverFacts.ts b/src/features/dashboard/serverFacts.ts new file mode 100644 index 0000000..3fadf66 --- /dev/null +++ b/src/features/dashboard/serverFacts.ts @@ -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?][] = [ + ['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; + const disk = q.maxDiskQuota ?? q.diskQuota ?? q.disk; + return typeof disk === 'number' && disk > 0 ? disk : null; +} + +interface QueuedRow { + recipients?: Record; +} + +export function summarizeQueue(list: QueuedRow[]): { waiting: WaitingDomain[]; retrying: number } { + const byDomain = new Map(); + 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 { + 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; + if (tag.startsWith('c:') && typeof b.total === 'number') { + (facts as Record)[tag.slice(2)] = b.total; + } else if (tag === 'g:storage') { + facts.storage = ((b.list as Record[]) ?? []).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(null); + const [error, setError] = useState(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 }; +} diff --git a/src/features/dashboard/treemap.test.ts b/src/features/dashboard/treemap.test.ts new file mode 100644 index 0000000..30cdfec --- /dev/null +++ b/src/features/dashboard/treemap.test.ts @@ -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([]); + }); +}); diff --git a/src/features/dashboard/treemap.ts b/src/features/dashboard/treemap.ts new file mode 100644 index 0000000..62f380d --- /dev/null +++ b/src/features/dashboard/treemap.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export interface Tile { + 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(items: T[], value: (item: T) => number, w: number, h: number): Tile[] { + 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[] = []; + 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; +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 292e2a4..9e2dda7 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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);