3x-ui/frontend/src/pages/index/XrayMetricsModal.tsx
MHSanaei 31845fa8f6
refactor(frontend): tighten HttpUtil generics from any to unknown
Switch the class-level default on Msg<T> and the per-method defaults on
HttpUtil.get/post/postWithModal from `any` to `unknown`, so callers that
don't pass an explicit T get a narrowed response that must be schema-
checked or type-cast before its shape is trusted.

Drops the four file-level eslint-disable comments these defaults
required. Fixes the nine direct `.obj.field` consumers that surfaced
(IndexPage, XrayMetricsModal, NordModal, WarpModal, LogModal,
VersionModal, XrayLogModal, CustomGeoSection) by giving each call site
the explicit T it should have had from the start — typically a small
ad-hoc shape, sometimes a string for the JSON-text-in-Msg.obj pattern
used by NordModal/WarpModal/Xray nord/warp endpoints.

PR3 of the planned Zod end-to-end rollout — schemas/inbound.ts and
schemas/client.ts loose() removal stays parked until the protocol
schemas land in Phase 3 to avoid silently dropping fields.
2026-05-25 19:51:39 +02:00

342 lines
11 KiB
TypeScript

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}`;
}
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<number[]>([]);
const [labels, setLabels] = useState<string[]>([]);
const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
const [obsActiveTag, setObsActiveTag] = useState('');
const obsTimerRef = useRef<number | null>(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 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[] = [];
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);
}
setLabels(labs);
setPoints(vals);
} else {
setLabels([]);
setPoints([]);
}
}, []);
const fetchState = useCallback(async () => {
try {
const msg = await HttpUtil.get<XrayState>('/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<ObservatoryTag[]>('/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([]);
}
}, [activeMetric, bucket, applyHistory]);
const fetchObsBucket = useCallback(async () => {
if (!obsActiveTag) {
setLabels([]);
setPoints([]);
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([]);
}
}, [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 (
<Modal
open={open}
footer={null}
width={isMobile ? '95vw' : 900}
onCancel={onClose}
title={
<>
{t('pages.index.xrayMetricsTitle')}
<Select
value={bucket}
size="small"
className="bucket-select"
onChange={setBucket}
options={[
{ value: 2, label: '2m' },
{ value: 30, label: '30m' },
{ value: 60, label: '1h' },
{ value: 120, label: '2h' },
{ value: 180, label: '3h' },
{ value: 300, label: '5h' },
]}
/>
</>
}
>
{!state.enabled && (
<Alert
type="warning"
showIcon
className="metrics-alert"
title={t('pages.index.xrayMetricsDisabled')}
description={state.reason || t('pages.index.xrayMetricsHint')}
/>
)}
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
size="small"
className="history-tabs"
items={METRICS.map((m) => ({ key: m.key, label: m.tab }))}
/>
{isObservatory && (
<div className="obs-pane">
{state.enabled && obsTags.length === 0 ? (
<Alert
type="info"
showIcon
className="metrics-alert"
title={t('pages.index.xrayObservatoryEmpty')}
description={t('pages.index.xrayObservatoryHint')}
/>
) : (
<div className="obs-controls">
<Select
value={obsActiveTag}
size="small"
className="obs-select"
placeholder={t('pages.index.xrayObservatoryTagPlaceholder')}
onChange={setObsActiveTag}
options={obsTags.map((tg) => ({
value: tg.tag,
label: (
<>
<span className={`obs-dot ${tg.alive ? 'is-alive' : 'is-dead'}`} />
{tg.tag}
</>
),
}))}
/>
{activeObsTag && (
<div className="obs-stats">
<Tag color={activeObsTag.alive ? 'green' : 'red'}>
{activeObsTag.alive
? t('pages.index.xrayObservatoryAlive')
: t('pages.index.xrayObservatoryDead')}
</Tag>
<Tag color="blue">{activeObsTag.delay} ms</Tag>
<span className="obs-stamp">
{t('pages.index.xrayObservatoryLastSeen')}: {fmtTimestamp(activeObsTag.lastSeenTime)}
</span>
<span className="obs-stamp">
{t('pages.index.xrayObservatoryLastTry')}: {fmtTimestamp(activeObsTag.lastTryTime)}
</span>
</div>
)}
</div>
)}
</div>
)}
<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}
stroke={strokeColor}
strokeWidth={2.2}
showGrid
showAxes
tickCountX={5}
maxPoints={points.length || 1}
fillOpacity={0.18}
markerRadius={3.2}
showTooltip
valueMin={0}
valueMax={null}
yFormatter={yFormatter}
/>
</div>
</Modal>
);
}