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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user