Initial commit
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
function ChartSizedContainer({
|
||||
height,
|
||||
children,
|
||||
}: {
|
||||
height: number;
|
||||
children: (width: number, height: number) => React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
if (w > 0) setWidth(w);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
const w = el.getBoundingClientRect().width;
|
||||
if (w > 0) setWidth(w);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ height }}>
|
||||
{width > 0 && children(width, height)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Info } from 'lucide-react';
|
||||
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';
|
||||
import { ChartTooltipContent } from '@/components/ui/chart';
|
||||
import type { Chart as ChartSchema } from '../types/schema';
|
||||
import type { Metric, Period } from '../types/metrics';
|
||||
import {
|
||||
bucketize,
|
||||
bucketTimestamps,
|
||||
getBucketCount,
|
||||
seriesBucketValue,
|
||||
formatTimeTick,
|
||||
formatValue,
|
||||
} from '../helpers';
|
||||
|
||||
interface DashboardChartProps {
|
||||
chart: ChartSchema;
|
||||
historySamples: Metric[];
|
||||
historyWindow: { from: Date; to: Date };
|
||||
period: Period;
|
||||
}
|
||||
|
||||
export function DashboardChart({ chart, historySamples, historyWindow, period }: DashboardChartProps) {
|
||||
const { from, to } = historyWindow;
|
||||
const bucketCount = getBucketCount(period);
|
||||
const valueFormat = chart.valueFormat ?? 'number';
|
||||
|
||||
const data = useMemo(() => {
|
||||
const buckets = bucketize(historySamples, from, to, bucketCount);
|
||||
const timestamps = bucketTimestamps(from, to, bucketCount);
|
||||
|
||||
const points = timestamps.map((ts, i) => {
|
||||
const point: Record<string, unknown> = {
|
||||
time: ts.getTime(),
|
||||
timeLabel: formatTimeTick(ts, period),
|
||||
};
|
||||
for (const series of chart.series) {
|
||||
point[series.label] = seriesBucketValue(series, buckets[i]);
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
if (chart.stacked) {
|
||||
const lastSeen: Record<string, number> = {};
|
||||
for (const point of points) {
|
||||
for (const series of chart.series) {
|
||||
const v = point[series.label];
|
||||
if (typeof v === 'number') {
|
||||
lastSeen[series.label] = v;
|
||||
} else {
|
||||
point[series.label] = lastSeen[series.label] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}, [historySamples, from, to, bucketCount, chart.series, chart.stacked, period]);
|
||||
|
||||
const tickFormatter = (value: number) => formatValue(value, valueFormat);
|
||||
|
||||
const tooltipFormatter = (value: number) => formatValue(value, valueFormat);
|
||||
|
||||
const renderChart = (chartWidth: number, chartHeight: number) => {
|
||||
const commonProps = {
|
||||
data,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
margin: { top: 5, right: 10, left: 10, bottom: 5 },
|
||||
};
|
||||
|
||||
const seriesElements = chart.series.map((s, i) => {
|
||||
const color = getChartColor(i);
|
||||
const key = s.label;
|
||||
|
||||
switch (chart.kind) {
|
||||
case 'line':
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
case 'area':
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
stackId={chart.stacked ? '1' : undefined}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={color}
|
||||
stackId={chart.stacked ? '1' : undefined}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const axes = (
|
||||
<>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="timeLabel"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={tickFormatter}
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<ChartTooltipContent
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label as string}
|
||||
formatter={tooltipFormatter}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: '12px' }} />
|
||||
</>
|
||||
);
|
||||
|
||||
switch (chart.kind) {
|
||||
case 'line':
|
||||
return (
|
||||
<LineChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</LineChart>
|
||||
);
|
||||
case 'area':
|
||||
return (
|
||||
<AreaChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</AreaChart>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</BarChart>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{chart.title}</CardTitle>
|
||||
{chart.description && (
|
||||
<TooltipProvider>
|
||||
<UiTooltip>
|
||||
<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">{chart.description}</p>
|
||||
</TooltipContent>
|
||||
</UiTooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartSizedContainer height={288}>{(width, height) => renderChart(width, height)}</ChartSizedContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
|
||||
import { collectHistoryMetricIds, collectLiveMetricIds, periodKey, periodWindow, deltaHistograms } from '../helpers';
|
||||
import { StatCard } from './StatCard';
|
||||
import { DashboardChart } from './DashboardChart';
|
||||
import { PeriodSelector } from './PeriodSelector';
|
||||
|
||||
interface DashboardViewProps {
|
||||
dashboardId: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const fetchHistory = useHistoryMetricsStore((s) => s.fetch);
|
||||
const refreshHistory = useHistoryMetricsStore((s) => s.refresh);
|
||||
const historyStatus = useHistoryMetricsStore((s) => s.status);
|
||||
const historyCache = useHistoryMetricsStore((s) => s.cache);
|
||||
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
|
||||
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||
const liveStatus = useLiveMetricsStore((s) => s.status);
|
||||
const liveError = useLiveMetricsStore((s) => s.error);
|
||||
|
||||
const dashboards = useMemo<Dashboard[]>(() => schema?.dashboards ?? [], [schema]);
|
||||
const dashboard = dashboards.find((d) => d.id === dashboardId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboard && dashboards.length > 0) {
|
||||
navigate(`/${section}/Dashboard/${dashboards[0].id}`, { replace: true });
|
||||
}
|
||||
}, [dashboard, dashboards, navigate, section]);
|
||||
|
||||
const historyIds = useMemo(
|
||||
() => (dashboard ? collectHistoryMetricIds(dashboard.cards, dashboard.charts) : new Set<string>()),
|
||||
[dashboard],
|
||||
);
|
||||
const liveIds = useMemo(() => (dashboard ? collectLiveMetricIds(dashboard.cards) : new Set<string>()), [dashboard]);
|
||||
|
||||
const cacheKey = dashboard ? `${dashboard.id}|${periodKey(period)}` : '';
|
||||
const [fetchVersion, setFetchVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboard || historyIds.size === 0) return;
|
||||
let cancelled = false;
|
||||
fetchHistory(dashboard.id, period, historyIds).then(() => {
|
||||
if (!cancelled) setFetchVersion((v) => v + 1);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dashboard, period, historyIds, fetchHistory]);
|
||||
|
||||
const { historySamples, historyWindow } = useMemo(() => {
|
||||
void fetchVersion;
|
||||
const raw = historyCache.get(cacheKey)?.metrics ?? [];
|
||||
return {
|
||||
historySamples: deltaHistograms(raw),
|
||||
historyWindow: periodWindow(period),
|
||||
};
|
||||
}, [historyCache, cacheKey, fetchVersion, period]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveIds.size > 0) {
|
||||
subscribeLive(liveIds);
|
||||
}
|
||||
return () => {
|
||||
unsubscribeLive();
|
||||
};
|
||||
}, [liveIds, subscribeLive, unsubscribeLive]);
|
||||
|
||||
const isLoading = historyStatus.get(cacheKey) === 'loading';
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (dashboard && historyIds.size > 0) {
|
||||
refreshHistory(dashboard.id, period, historyIds).then(() => setFetchVersion((v) => v + 1));
|
||||
}
|
||||
}, [dashboard, period, historyIds, refreshHistory]);
|
||||
|
||||
if (!dashboard) {
|
||||
if (dashboards.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">No dashboards configured.</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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}`)}>
|
||||
<TabsList>
|
||||
{dashboards.map((d) => (
|
||||
<TabsTrigger key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
{dashboards.length === 1 && <h1 className="text-xl font-semibold">{dashboard.label}</h1>}
|
||||
|
||||
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{liveStatus === 'error' && liveError && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{liveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.cards && dashboard.cards.length > 0 && (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
|
||||
{dashboard.cards.map((card, i) => (
|
||||
<StatCard
|
||||
key={`${card.title}-${i}`}
|
||||
card={card}
|
||||
historySamples={historySamples}
|
||||
historyWindow={historyWindow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.charts && dashboard.charts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{dashboard.charts.map((chart, i) => (
|
||||
<DashboardChart
|
||||
key={`${chart.title}-${i}`}
|
||||
chart={chart}
|
||||
historySamples={historySamples}
|
||||
historyWindow={historyWindow}
|
||||
period={period}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import type { PresetKey } from '../types/metrics';
|
||||
import { presetLabel, PRESET_KEYS } from '../types/metrics';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
onRefresh: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function PeriodSelector({ onRefresh, loading }: PeriodSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const setPreset = useDashboardStore((s) => s.setPreset);
|
||||
const setPeriod = useDashboardStore((s) => s.setPeriod);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
|
||||
const currentValue = period.kind === 'preset' ? period.preset : 'custom';
|
||||
|
||||
const handleSelectChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setCustomOpen(true);
|
||||
} else {
|
||||
setPreset(value as PresetKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyCustom = () => {
|
||||
if (customFrom && customTo) {
|
||||
setPeriod({
|
||||
kind: 'custom',
|
||||
from: new Date(customFrom),
|
||||
to: new Date(customTo),
|
||||
});
|
||||
setCustomOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={customOpen} onOpenChange={setCustomOpen}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={currentValue} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESET_KEYS.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{presetLabel(t, key)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{t('dashboard.customEllipsis', 'Custom...')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<PopoverTrigger asChild>
|
||||
<span />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
|
||||
<PopoverContent className="w-72 p-4" align="end">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">{t('dashboard.customRange', 'Custom range')}</h4>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-from" className="text-xs">
|
||||
{t('dashboard.from', 'From')}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-from"
|
||||
type="datetime-local"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-to" className="text-xs">
|
||||
{t('dashboard.to', 'To')}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-to"
|
||||
type="datetime-local"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="w-full" onClick={handleApplyCustom}>
|
||||
{t('common.apply', 'Apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={loading} className="h-9 w-9">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
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';
|
||||
|
||||
const warnedIcons = new Set<string>();
|
||||
|
||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||
const formatted = name
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||
if (!IconComp) {
|
||||
if (import.meta.env.DEV && !warnedIcons.has(name)) {
|
||||
warnedIcons.add(name);
|
||||
console.warn(`Unknown icon name: "${name}"`);
|
||||
}
|
||||
return <LucideIcons.HelpCircle className={className} />;
|
||||
}
|
||||
return <IconComp className={className} />;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
card: CardSchema;
|
||||
historySamples: Metric[];
|
||||
historyWindow: { from: Date; to: Date };
|
||||
}
|
||||
|
||||
export function StatCard({ card, historySamples, historyWindow }: StatCardProps) {
|
||||
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
|
||||
|
||||
const value = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const formattedValue = 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]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LucideIcon name={card.icon} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
|
||||
{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-2 text-2xl font-bold">{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user