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