Files
inbuxa-admin/src/features/dashboard/components/StatCard.tsx
T
jcoffey-dev 5bf09ceed5 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.
2026-09-19 01:50:07 -07:00

137 lines
5.0 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { IconTile } from '@/components/common/IconTile';
import { useMemo } from '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';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { Card as CardSchema } from '../types/schema';
import type { Metric } from '../types/metrics';
import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers';
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
import { getChartColor } from '@/components/ui/chart';
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, 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, fallback, liveStatus]);
// 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;
const sparkline = useMemo(() => {
if (card.source !== 'history' || !card.sparkline) return null;
return sparklineData(card, historySamples, from, to).map((v, i) => ({
v,
i,
}));
}, [card, historySamples, from, to]);
const delta = useMemo(() => {
if (card.source !== 'history' || !card.delta) return null;
return computeDelta(card, historySamples, from, to);
}, [card, historySamples, from, to]);
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>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">{card.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="mt-3 font-display text-3xl font-semibold tracking-tight">{formattedValue}</div>
{(delta || sparkline) && (
<div className="mt-1 flex items-center gap-2">
{delta && (
<Badge variant="secondary" className="text-xs font-normal text-muted-foreground">
{delta.direction === 'up'
? `\u2191 ${Math.abs(delta.pct)}%`
: delta.direction === 'down'
? `\u2193 ${Math.abs(delta.pct)}%`
: '\u2013'}
</Badge>
)}
{sparkline && (
<LineChart width={64} height={32} data={sparkline}>
<Line
type="monotone"
dataKey="v"
stroke={getChartColor(0)}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</LineChart>
)}
</div>
)}
</CardContent>
</Card>
);
return link ? (
<Link
to={hrefFor(link)}
className="group block focus-visible:outline-none"
aria-label={`${card.title}: ${link.label}`}
>
{body}
</Link>
) : (
body
);
}