Przeglądaj źródła

refactor(metrics-modal): mark min/max on chart + improve grid contrast

Drop the Current/Min/Avg/Max stats row and Live auto-refresh toggle —
clutter that didn't earn its space. Min/max are now rendered as colored
dots on the chart itself (green ▼ for min, orange ▲ for max), which
exposes both the value AND the time-axis position of each extremum at a
glance. Tooltip now formats the timestamp fully (with date prefix when
the sample crosses a day boundary).

Switch CartesianGrid stroke from var(--ant-color-border-secondary) to
rgba(128,128,140,0.35) so the gridlines stay readable in light theme
against the chart-wrap's faint primary tint — the AntD variable
resolved to near-zero alpha and the gridlines disappeared.

XrayMetricsModal keeps its implicit 2s observatory polling.
MHSanaei 7 godzin temu
rodzic
commit
2bba1d21d2

+ 103 - 5
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 (
     <ResponsiveContainer width="100%" height={height} className="sparkline-svg">
       <AreaChart data={points} margin={{ top: 6, right: 6, bottom: showAxes ? 14 : 4, left: showAxes ? 4 : 4 }}>
@@ -113,7 +154,7 @@ export default function Sparkline({
           </linearGradient>
         </defs>
         {showGrid && (
-          <CartesianGrid stroke="var(--ant-color-border-secondary)" strokeDasharray="2 4" vertical={false} />
+          <CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
         )}
         <XAxis
           dataKey="label"
@@ -140,16 +181,73 @@ export default function Sparkline({
             contentStyle={{
               background: 'var(--ant-color-bg-elevated)',
               border: '1px solid var(--ant-color-border-secondary)',
-              borderRadius: 4,
+              borderRadius: 6,
               fontSize: 12,
-              padding: '4px 8px',
+              padding: '6px 10px',
+              boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
             }}
-            labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 2 }}
-            itemStyle={{ color: 'var(--ant-color-text)', padding: 0 }}
+            labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
+            itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
             formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
+            labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
             separator=""
           />
         )}
+        {referenceLines?.map((rl, idx) => (
+          <ReferenceLine
+            key={`ref-${idx}-${rl.y}`}
+            y={rl.y}
+            stroke={rl.color || stroke}
+            strokeDasharray={rl.dash || '5 4'}
+            strokeWidth={1.4}
+            label={rl.label ? {
+              value: rl.label,
+              position: 'insideTopRight',
+              fill: rl.color || stroke,
+              fontSize: 10,
+              fontWeight: 600,
+            } : undefined}
+            ifOverflow="extendDomain"
+          />
+        ))}
+        {extremaPoints && (
+          <>
+            <ReferenceDot
+              x={extremaPoints.max.label}
+              y={extremaPoints.max.value}
+              r={4.5}
+              fill={maxColor}
+              stroke="var(--ant-color-bg-elevated)"
+              strokeWidth={2}
+              label={{
+                value: `▲ ${fmtExtrema(extremaPoints.max.value)}`,
+                position: 'top',
+                fontSize: 10.5,
+                fill: maxColor,
+                fontWeight: 600,
+                offset: 8,
+              }}
+              ifOverflow="extendDomain"
+            />
+            <ReferenceDot
+              x={extremaPoints.min.label}
+              y={extremaPoints.min.value}
+              r={4.5}
+              fill={minColor}
+              stroke="var(--ant-color-bg-elevated)"
+              strokeWidth={2}
+              label={{
+                value: `▼ ${fmtExtrema(extremaPoints.min.value)}`,
+                position: 'bottom',
+                fontSize: 10.5,
+                fill: minColor,
+                fontWeight: 600,
+                offset: 8,
+              }}
+              ifOverflow="extendDomain"
+            />
+          </>
+        )}
         <Area
           type="monotone"
           dataKey="value"

+ 7 - 9
frontend/src/pages/index/SystemHistoryModal.css

@@ -1,6 +1,12 @@
+.metric-modal-title {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px;
+}
+
 .bucket-select {
   width: 80px;
-  margin-left: 10px;
 }
 
 .history-tabs {
@@ -15,11 +21,3 @@
   border: 1px solid var(--ant-color-border-secondary);
   box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
 }
-
-.cpu-chart-meta {
-  margin-bottom: 12px;
-  font-size: 11.5px;
-  opacity: 0.65;
-  letter-spacing: 0.3px;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-}

+ 45 - 10
frontend/src/pages/index/SystemHistoryModal.tsx

@@ -47,6 +47,22 @@ function unitFormatter(unit: string, activeKey: string): (v: number) => 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<number[]>([]);
   const [labels, setLabels] = useState<string[]>([]);
+  const [timestamps, setTimestamps] = useState<number[]>([]);
 
   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<string, number>();
+    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')}
+        <div className="metric-modal-title">
+          <span>{t('pages.index.systemHistoryTitle')}</span>
           <Select
             value={bucket}
             size="small"
@@ -124,7 +160,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
               { value: 300, label: '5h' },
             ]}
           />
-        </>
+        </div>
       }
     >
       <Tabs
@@ -136,13 +172,10 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
       />
 
       <div className="cpu-chart-wrap">
-        <div className="cpu-chart-meta">
-          Timeframe: {bucket} sec per point (total {points.length} points)
-        </div>
         <Sparkline
           data={points}
           labels={labels}
-          height={220}
+          height={260}
           stroke={strokeColor}
           strokeWidth={2.2}
           showGrid
@@ -155,6 +188,8 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
           valueMin={0}
           valueMax={activeMetric?.valueMax ?? null}
           yFormatter={yFormatter}
+          tooltipLabelFormatter={tooltipLabelFormatter}
+          extrema={{ show: true, formatter: yFormatter }}
         />
       </div>
     </Modal>

+ 0 - 4
frontend/src/pages/index/XrayMetricsModal.css

@@ -63,7 +63,3 @@
   .obs-dot.is-alive { animation: none; }
 }
 
-.listen-tag {
-  opacity: 0.7;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-}

+ 46 - 10
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -70,6 +70,22 @@ function fmtTimestamp(unixSec: number): string {
   return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
 }
 
+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 XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
@@ -77,6 +93,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
   const [bucket, setBucket] = useState(2);
   const [points, setPoints] = useState<number[]>([]);
   const [labels, setLabels] = useState<string[]>([]);
+  const [timestamps, setTimestamps] = useState<number[]>([]);
   const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
   const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
   const [obsActiveTag, setObsActiveTag] = useState('');
@@ -90,10 +107,27 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null;
 
+  const tsLookup = useMemo(() => {
+    const m = new Map<string, number>();
+    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 applyHistory = useCallback((msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => {
     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');
@@ -101,12 +135,15 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
         const ss = String(d.getSeconds()).padStart(2, '0');
         labs.push(currentBucket >= 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([]);
     }
   }, []);
 
@@ -148,6 +185,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       console.error('Failed to fetch xray metrics bucket', e);
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
     }
   }, [activeMetric, bucket, applyHistory]);
 
@@ -155,6 +193,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
     if (!obsActiveTag) {
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
       return;
     }
     try {
@@ -165,6 +204,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       console.error('Failed to fetch observatory bucket', e);
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
     }
   }, [obsActiveTag, bucket, applyHistory]);
 
@@ -225,8 +265,8 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       width={isMobile ? '95vw' : 900}
       onCancel={onClose}
       title={
-        <>
-          {t('pages.index.xrayMetricsTitle')}
+        <div className="metric-modal-title">
+          <span>{t('pages.index.xrayMetricsTitle')}</span>
           <Select
             value={bucket}
             size="small"
@@ -241,7 +281,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
               { value: 300, label: '5h' },
             ]}
           />
-        </>
+        </div>
       }
     >
       {!state.enabled && (
@@ -313,16 +353,10 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       )}
 
       <div className="cpu-chart-wrap">
-        <div className="cpu-chart-meta">
-          Timeframe: {bucket} sec per point (total {points.length} points)
-          {state.enabled && state.listen && (
-            <span className="listen-tag"> · {state.listen}</span>
-          )}
-        </div>
         <Sparkline
           data={points}
           labels={labels}
-          height={220}
+          height={260}
           stroke={strokeColor}
           strokeWidth={2.2}
           showGrid
@@ -335,6 +369,8 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
           valueMin={0}
           valueMax={null}
           yFormatter={yFormatter}
+          tooltipLabelFormatter={tooltipLabelFormatter}
+          extrema={{ show: true, formatter: yFormatter }}
         />
       </div>
     </Modal>