import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Modal, Select, Tabs, Tag } from 'antd'; import { HttpUtil, Msg, SizeFormatter } from '@/utils'; import Sparkline from '@/components/Sparkline'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import './XrayMetricsModal.css'; const OBS_KEY = 'xrObs'; interface XrayMetricsModalProps { open: boolean; onClose: () => void; } interface MetricDef { key: string; tab: string; unit: 'B' | 'ns' | 'ms' | ''; stroke: string; } interface XrayState { enabled: boolean; listen: string; reason: string; } interface ObservatoryTag { tag: string; alive: boolean; delay: number; lastSeenTime: number; lastTryTime: number; } const METRICS: MetricDef[] = [ { key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' }, { key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' }, { key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' }, { key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' }, { key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' }, { key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' }, ]; function unitFormatter(unit: string): (v: number) => string { if (unit === 'B') return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)); if (unit === 'ns') { return (v) => { const n = Math.max(0, Number(v) || 0); if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`; if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`; return `${n.toFixed(0)} ns`; }; } if (unit === 'ms') return (v) => `${Math.round(Number(v) || 0)} ms`; return (v) => { const n = Number(v) || 0; return Math.round(n).toLocaleString(); }; } function fmtTimestamp(unixSec: number): string { if (!unixSec) return '—'; const d = new Date(unixSec * 1000); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); 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(); const [activeKey, setActiveKey] = useState('xrAlloc'); const [bucket, setBucket] = useState(2); const [points, setPoints] = useState([]); const [labels, setLabels] = useState([]); const [timestamps, setTimestamps] = useState([]); const [state, setState] = useState({ enabled: false, listen: '', reason: '' }); const [obsTags, setObsTags] = useState([]); const [obsActiveTag, setObsActiveTag] = useState(''); const obsTimerRef = useRef(null); const openRef = useRef(open); const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]); const isObservatory = activeKey === OBS_KEY; const strokeColor = activeMetric?.stroke || '#008771'; const yFormatter = useMemo(() => unitFormatter(activeMetric?.unit ?? ''), [activeMetric]); const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null; 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 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'); const mm = String(d.getMinutes()).padStart(2, '0'); 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([]); } }, []); const fetchState = useCallback(async () => { try { const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState'); if (msg?.success && msg.obj) setState(msg.obj); } catch (e) { console.error('Failed to fetch xray metrics state', e); } }, []); const fetchObservatory = useCallback(async () => { try { const msg = await HttpUtil.get('/panel/api/server/xrayObservatory'); if (msg?.success && Array.isArray(msg.obj)) { const tags = msg.obj; setObsTags(tags); setObsActiveTag((prev) => { if (tags.find((tg) => tg.tag === prev)) return prev; return tags[0]?.tag || ''; }); } else { setObsTags([]); } } catch (e) { console.error('Failed to fetch observatory snapshot', e); setObsTags([]); } }, []); const fetchMetricBucket = useCallback(async () => { if (!activeMetric) return; try { const url = `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`; const msg = await HttpUtil.get<{ t: number; v: number }[]>(url); applyHistory(msg, bucket); } catch (e) { console.error('Failed to fetch xray metrics bucket', e); setLabels([]); setPoints([]); setTimestamps([]); } }, [activeMetric, bucket, applyHistory]); const fetchObsBucket = useCallback(async () => { if (!obsActiveTag) { setLabels([]); setPoints([]); setTimestamps([]); return; } try { const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`; const msg = await HttpUtil.get<{ t: number; v: number }[]>(url); applyHistory(msg, bucket); } catch (e) { console.error('Failed to fetch observatory bucket', e); setLabels([]); setPoints([]); setTimestamps([]); } }, [obsActiveTag, bucket, applyHistory]); const stopObsPolling = useCallback(() => { if (obsTimerRef.current != null) { window.clearInterval(obsTimerRef.current); obsTimerRef.current = null; } }, []); useEffect(() => { openRef.current = open; if (open) { setActiveKey('xrAlloc'); fetchState(); } else { stopObsPolling(); } }, [open, fetchState, stopObsPolling]); useEffect(() => { if (!open) return; if (isObservatory) { fetchObservatory(); fetchObsBucket(); stopObsPolling(); obsTimerRef.current = window.setInterval(async () => { if (!openRef.current || !isObservatory) return; await fetchObservatory(); fetchObsBucket(); }, 2000); } else { stopObsPolling(); fetchMetricBucket(); } return () => { stopObsPolling(); }; }, [open, activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]); useEffect(() => { if (!open) return; if (isObservatory) { fetchObsBucket(); } else { fetchMetricBucket(); } }, [open, bucket, isObservatory, fetchObsBucket, fetchMetricBucket]); useEffect(() => { if (open && isObservatory) fetchObsBucket(); }, [open, obsActiveTag, isObservatory, fetchObsBucket]); return ( {t('pages.index.xrayMetricsTitle')} ({ value: tg.tag, label: ( <> {tg.tag} ), }))} /> {activeObsTag && (
{activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead')} {activeObsTag.delay} ms {t('pages.index.xrayObservatoryLastSeen')}: {fmtTimestamp(activeObsTag.lastSeenTime)} {t('pages.index.xrayObservatoryLastTry')}: {fmtTimestamp(activeObsTag.lastTryTime)}
)} )} )}
); }