chore(frontend): update dependencies and adapt to oxlint 1.79

npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7
and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11,
and npm would not move either. Neither npm update, a targeted install, nor
--package-lock-only broke the cycle, so node_modules and package-lock.json
were regenerated from scratch (601 packages, 0 vulnerabilities).

oxlint 1.79.0 then promoted five React Compiler rules into the correctness
category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree,
so nothing in our code changed - the rule set grew. They are fixed rather
than suppressed:

- refs (31): latest-value ref writes moved out of render into an effect.
  onlineClientsRef turned out to be write-only and is gone; expireDiffRef
  and trafficDiffRef were replaced by reading the values directly.
- set-state-in-effect (55): reset-on-open modals now adjust state during
  render; where an effect mixed a synchronous reset with an async fetch, the
  reset moved to render and the effect kept only the request. useMediaQuery
  became useSyncExternalStore.
- preserve-manual-memoization (11): optional-chained deps the compiler cannot
  match, hoisted to locals or dropped where the memo wrapped a string concat.
- purity (3): Date.now() in render replaced by a state-backed clock, which
  also refreshes the expiry tag every 60s instead of freezing it until the
  next unrelated re-render.
- immutability (1): applyClientStatsEvent merged websocket traffic into
  DBInbound rows in place; it now rebuilds only the rows it touches.

Two things fell out of that. clientCount is derived with useMemo instead of
an imperative rebuildClientCount() called from five sites, which also fixes a
staleness bug where changing the expiry or traffic threshold left the counts
alone until some later rebuild. statsVersion existed only to force a
re-render after an in-place mutation, is meaningless now that rows are
replaced, and nothing read it, so it is removed.

Also adds a lint:fix script - oxlint --fix was previously only reachable
through the lint-staged hook.
This commit is contained in:
Sanaei
2026-08-19 17:48:28 +02:00
parent 92fb94d856
commit b9eda09da9
54 changed files with 1497 additions and 1408 deletions
+107 -143
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Tabs, Tag } from 'antd';
@@ -147,19 +147,71 @@ function formatFullTimestamp(unixSec: number): string {
return `${MM}-${DD} ${time}`;
}
interface MetricsChart {
points: number[];
labels: string[];
timestamps: number[];
}
const EMPTY_CHART: MetricsChart = { points: [], labels: [], timestamps: [] };
function toChart(msg: Msg<{ t: number; v: number }[]> | null | undefined, bucket: number) {
if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
const points: number[] = [];
const labels: string[] = [];
const timestamps: 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');
labels.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
points.push(Number(p.v) || 0);
timestamps.push(Number(p.t) || 0);
}
return { points, labels, timestamps };
}
async function loadHistory(url: string | null, bucket: number): Promise<MetricsChart> {
if (!url) return EMPTY_CHART;
try {
return toChart(await HttpUtil.get<{ t: number; v: number }[]>(url), bucket);
} catch (e) {
console.error('Failed to fetch xray metrics bucket', e);
return EMPTY_CHART;
}
}
async function loadState(): Promise<XrayState | null> {
try {
const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
return msg?.success && msg.obj ? msg.obj : null;
} catch (e) {
console.error('Failed to fetch xray metrics state', e);
return null;
}
}
async function loadObservatory(): Promise<ObservatoryTag[]> {
try {
const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
return msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
} catch (e) {
console.error('Failed to fetch observatory snapshot', e);
return [];
}
}
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 [timestamps, setTimestamps] = useState<number[]>([]);
const [{ points, labels, timestamps }, setChart] = useState<MetricsChart>(EMPTY_CHART);
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 [obsTick, setObsTick] = useState(0);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const isObservatory = activeKey === OBS_KEY;
@@ -184,151 +236,63 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
[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<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([]);
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]);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setActiveKey('xrAlloc');
}
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();
}
let cancelled = false;
void (async () => {
const next = await loadState();
if (!cancelled && next) setState(next);
})();
return () => {
stopObsPolling();
cancelled = true;
};
}, [
open,
activeKey,
isObservatory,
fetchObservatory,
fetchObsBucket,
fetchMetricBucket,
stopObsPolling,
]);
}, [open]);
// The observatory snapshot is a live view, so it re-polls; obsTick then pulls
// the chart along with it.
useEffect(() => {
if (!open || !isObservatory) return;
let cancelled = false;
const tick = async () => {
const tags = await loadObservatory();
if (cancelled) return;
setObsTags(tags);
setObsActiveTag((prev) => (tags.find((tg) => tg.tag === prev) ? prev : tags[0]?.tag || ''));
setObsTick((n) => n + 1);
};
void tick();
const id = window.setInterval(() => void tick(), 2000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [open, isObservatory]);
const historyUrl = isObservatory
? obsActiveTag
? `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`
: null
: activeMetric
? `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`
: null;
useEffect(() => {
if (!open) return;
if (isObservatory) {
fetchObsBucket();
} else {
fetchMetricBucket();
}
}, [open, bucket, isObservatory, fetchObsBucket, fetchMetricBucket]);
useEffect(() => {
if (open && isObservatory) fetchObsBucket();
}, [open, obsActiveTag, isObservatory, fetchObsBucket]);
let cancelled = false;
void (async () => {
const next = await loadHistory(historyUrl, bucket);
if (!cancelled) setChart(next);
})();
return () => {
cancelled = true;
};
}, [open, historyUrl, bucket, obsTick]);
return (
<Modal