diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx index 15a9055f..dfb27775 100644 --- a/frontend/src/components/Sparkline.tsx +++ b/frontend/src/components/Sparkline.tsx @@ -3,6 +3,8 @@ import { Area, AreaChart, CartesianGrid, + ReferenceDot, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, @@ -10,6 +12,23 @@ import { } from 'recharts'; import './Sparkline.css'; +export interface SparklineReferenceLine { + y: number; + label?: string; + color?: string; + dash?: string; +} + +export interface SparklineExtrema { + show?: boolean; + formatter?: (v: number) => string; + minColor?: string; + maxColor?: string; +} + +const DEFAULT_MIN_COLOR = '#52c41a'; +const DEFAULT_MAX_COLOR = '#fa541c'; + interface SparklineProps { data: number[]; labels?: (string | number)[]; @@ -29,6 +48,9 @@ interface SparklineProps { valueMax?: number | null; yFormatter?: (v: number) => string; tooltipFormatter?: ((v: number) => string) | null; + tooltipLabelFormatter?: ((label: string) => string) | null; + referenceLines?: SparklineReferenceLine[]; + extrema?: SparklineExtrema; } interface ChartPoint { @@ -56,6 +78,9 @@ export default function Sparkline({ valueMax = 100, yFormatter = (v: number) => `${Math.round(v)}%`, tooltipFormatter = null, + tooltipLabelFormatter = null, + referenceLines, + extrema, }: SparklineProps) { const reactId = useId(); const safeId = reactId.replace(/[^a-zA-Z0-9]/g, ''); @@ -103,6 +128,22 @@ export default function Sparkline({ const fmtTooltip = tooltipFormatter ?? yFormatter; + const extremaPoints = useMemo(() => { + if (!extrema?.show || points.length < 2) return null; + let minIdx = 0; + let maxIdx = 0; + for (let i = 1; i < points.length; i++) { + if (points[i].value < points[minIdx].value) minIdx = i; + if (points[i].value > points[maxIdx].value) maxIdx = i; + } + if (minIdx === maxIdx) return null; + return { min: points[minIdx], max: points[maxIdx] }; + }, [points, extrema?.show]); + + const fmtExtrema = extrema?.formatter ?? yFormatter; + const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR; + const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR; + return ( @@ -113,7 +154,7 @@ export default function Sparkline({ {showGrid && ( - + )} [fmtTooltip(Number(v) || 0), '']} + labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))} separator="" /> )} + {referenceLines?.map((rl, idx) => ( + + ))} + {extremaPoints && ( + <> + + + + )} string { }; } +function formatFullTimestamp(unixSec: number): string { + const d = new Date(unixSec * 1000); + const today = new Date(); + const sameDay = d.getFullYear() === today.getFullYear() + && d.getMonth() === today.getMonth() + && d.getDate() === today.getDate(); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const time = `${hh}:${mm}:${ss}`; + if (sameDay) return time; + const MM = String(d.getMonth() + 1).padStart(2, '0'); + const DD = String(d.getDate()).padStart(2, '0'); + return `${MM}-${DD} ${time}`; +} + export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); @@ -54,6 +70,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist const [bucket, setBucket] = useState(2); const [points, setPoints] = useState([]); const [labels, setLabels] = useState([]); + const [timestamps, setTimestamps] = useState([]); const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]); const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771'; @@ -62,6 +79,22 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist [activeMetric, activeKey], ); + const tsLookup = useMemo(() => { + const m = new Map(); + for (let i = 0; i < labels.length; i++) { + m.set(labels[i], timestamps[i]); + } + return m; + }, [labels, timestamps]); + + const tooltipLabelFormatter = useCallback( + (label: string) => { + const ts = tsLookup.get(label); + return ts ? formatFullTimestamp(ts) : label; + }, + [tsLookup], + ); + const fetchBucket = useCallback(async () => { if (!activeMetric) return; try { @@ -70,6 +103,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist if (msg?.success && Array.isArray(msg.obj)) { const vals: number[] = []; const labs: string[] = []; + const tss: number[] = []; for (const p of msg.obj) { const d = new Date(p.t * 1000); const hh = String(d.getHours()).padStart(2, '0'); @@ -77,24 +111,26 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist const ss = String(d.getSeconds()).padStart(2, '0'); labs.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`); vals.push(Number(p.v) || 0); + tss.push(Number(p.t) || 0); } setLabels(labs); setPoints(vals); + setTimestamps(tss); } else { setLabels([]); setPoints([]); + setTimestamps([]); } } catch (e) { console.error('Failed to fetch history bucket', e); setLabels([]); setPoints([]); + setTimestamps([]); } }, [activeMetric, bucket]); useEffect(() => { - if (open) { - setActiveKey('cpu'); - } + if (open) setActiveKey('cpu'); }, [open]); useEffect(() => { @@ -108,8 +144,8 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist width={isMobile ? '95vw' : 900} onCancel={onClose} title={ - <> - {t('pages.index.systemHistoryTitle')} +
+ {t('pages.index.systemHistoryTitle')} - +
} > {!state.enabled && ( @@ -313,16 +353,10 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp )}
-
- Timeframe: {bucket} sec per point (total {points.length} points) - {state.enabled && state.listen && ( - · {state.listen} - )} -