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
+13 -9
View File
@@ -38,21 +38,20 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
const [outbound, setOutbound] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<GeodataAssetRow[]>([]);
const [outboundTags, setOutboundTags] = useState<string[]>([]);
const templateRef = useRef<Record<string, unknown> | null>(null);
const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
const outboundTestUrlRef = useRef('');
const load = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success || typeof msg.obj !== 'string') return;
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const template = (payload.xraySetting || {}) as Record<string, unknown>;
templateRef.current = template;
const next = (payload.xraySetting || {}) as Record<string, unknown>;
setTemplate(next);
outboundTestUrlRef.current =
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
const geodata = (template.geodata || {}) as Record<string, unknown>;
const geodata = (next.geodata || {}) as Record<string, unknown>;
const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
setRows(
assets
@@ -67,7 +66,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
// Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it.
const tags = new Set<string>();
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
const outbounds = Array.isArray(next.outbounds) ? next.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
@@ -87,8 +86,14 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
}
}, []);
const [wasActive, setWasActive] = useState(false);
if (active !== wasActive) {
setWasActive(active);
if (active) setLoading(true);
}
useEffect(() => {
if (active) load();
if (active) void load();
}, [active, load]);
function setRow(index: number, patch: Partial<GeodataAssetRow>) {
@@ -102,7 +107,6 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
}
function save() {
const template = templateRef.current;
if (!template) return;
const assets = rows
.map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
@@ -213,7 +217,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
>
{t('pages.index.geodataAddFile')}
</Button>
<Button type="primary" onClick={save} disabled={loading || !templateRef.current}>
<Button type="primary" onClick={save} disabled={loading || !template}>
{t('pages.index.geodataSaveRestart')}
</Button>
</div>
+18 -11
View File
@@ -25,10 +25,8 @@ export default function LogModal({ open, onClose }: LogModalProps) {
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const openRef = useRef(open);
const refresh = useCallback(async () => {
setLoading(true);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
level,
@@ -43,19 +41,28 @@ export default function LogModal({ open, onClose }: LogModalProps) {
}
}, [rows, level, syslog]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open ? `${rows}\u0000${level}\u0000${syslog}` : null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
openRef.current = open;
if (open) refresh();
}, [open, refresh]);
useEffect(() => {
if (openRef.current) refresh();
}, [rows, level, syslog, refresh]);
if (open) void runRefresh();
}, [open, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
+79 -64
View File
@@ -200,16 +200,74 @@ function formatFullTimestamp(unixSec: number): string {
return `${MM}-${DD} ${time}`;
}
interface HistoryChart {
points: number[];
points2: number[];
points3: number[];
labels: string[];
timestamps: number[];
}
const EMPTY_CHART: HistoryChart = {
points: [],
points2: [],
points3: [],
labels: [],
timestamps: [],
};
async function loadBucket(metric: (typeof METRICS)[number], bucket: number): Promise<HistoryChart> {
try {
const msg = await HttpUtil.get(`/panel/api/server/history/${metric.key}/${bucket}`);
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 MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
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 >= 2880
? `${MM}-${DD} ${hh}:${mm}`
: bucket >= 60
? `${hh}:${mm}`
: `${hh}:${mm}:${ss}`,
);
points.push(Number(p.v) || 0);
timestamps.push(Number(p.t) || 0);
}
const fetchAligned = async (key?: string): Promise<number[]> => {
if (!key) return [];
const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
if (!m?.success || !Array.isArray(m.obj)) return [];
const byTs = new Map<number, number>();
for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
return timestamps.map((ts) => byTs.get(ts) ?? 0);
};
return {
labels,
points,
timestamps,
points2: await fetchAligned(metric.key2),
points3: await fetchAligned(metric.key3),
};
} catch (e) {
console.error('Failed to fetch history bucket', e);
return EMPTY_CHART;
}
}
export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [activeKey, setActiveKey] = useState('cpu');
const [bucket, setBucket] = useState(2);
const [points, setPoints] = useState<number[]>([]);
const [points2, setPoints2] = useState<number[]>([]);
const [points3, setPoints3] = useState<number[]>([]);
const [labels, setLabels] = useState<string[]>([]);
const [timestamps, setTimestamps] = useState<number[]>([]);
const [{ points, points2, points3, labels, timestamps }, setChart] =
useState<HistoryChart>(EMPTY_CHART);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n);
@@ -237,70 +295,27 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
const fetchBucket = useCallback(async () => {
if (!activeMetric) return;
try {
const url = `/panel/api/server/history/${activeMetric.key}/${bucket}`;
const msg = await HttpUtil.get(url);
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 MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
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 lab =
bucket >= 2880
? `${MM}-${DD} ${hh}:${mm}`
: bucket >= 60
? `${hh}:${mm}`
: `${hh}:${mm}:${ss}`;
labs.push(lab);
vals.push(Number(p.v) || 0);
tss.push(Number(p.t) || 0);
}
setLabels(labs);
setPoints(vals);
setTimestamps(tss);
const fetchAligned = async (key?: string): Promise<number[]> => {
if (!key) return [];
const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
if (m?.success && Array.isArray(m.obj)) {
const byTs = new Map<number, number>();
for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
return tss.map((ts) => byTs.get(ts) ?? 0);
}
return [];
};
setPoints2(await fetchAligned(activeMetric.key2));
setPoints3(await fetchAligned(activeMetric.key3));
} else {
setLabels([]);
setPoints([]);
setPoints2([]);
setPoints3([]);
setTimestamps([]);
}
} catch (e) {
console.error('Failed to fetch history bucket', e);
setLabels([]);
setPoints([]);
setPoints2([]);
setPoints3([]);
setTimestamps([]);
}
const next = await loadBucket(activeMetric, bucket);
setChart(next);
}, [activeMetric, bucket]);
useEffect(() => {
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setActiveKey('cpu');
}, [open]);
}
useEffect(() => {
if (open) fetchBucket();
}, [open, activeKey, bucket, fetchBucket]);
if (!open || !activeMetric) return;
let cancelled = false;
void (async () => {
const next = await loadBucket(activeMetric, bucket);
if (!cancelled) setChart(next);
})();
return () => {
cancelled = true;
};
}, [open, activeMetric, bucket]);
useEffect(() => {
if (!open) return undefined;
+7 -2
View File
@@ -38,7 +38,6 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
const [loading, setLoading] = useState(false);
const fetchVersions = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
if (msg?.success) setVersions(msg.obj || []);
@@ -47,8 +46,14 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
}
}, []);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setLoading(true);
}
useEffect(() => {
if (open) fetchVersions();
if (open) void fetchVersions();
}, [open, fetchVersions]);
function switchXrayVersion(version: string) {
+20 -11
View File
@@ -73,12 +73,10 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<XrayLogEntry[]>([]);
const openRef = useRef(open);
const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
const refresh = useCallback(async () => {
setLoading(true);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
filter,
@@ -93,19 +91,30 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
}
}, [rows, filter, showDirect, showBlocked, showProxy]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open
? `${rows}\u0000${showDirect}\u0000${showBlocked}\u0000${showProxy}`
: null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
openRef.current = open;
if (open) refresh();
}, [open, refresh]);
useEffect(() => {
if (openRef.current) refresh();
}, [rows, showDirect, showBlocked, showProxy, refresh]);
if (open) void runRefresh();
}, [open, rows, showDirect, showBlocked, showProxy, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
+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
@@ -128,8 +128,11 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
};
}, []);
useEffect(() => {
if (!hasData) return;
// Each polled status is appended during render; an effect would show the
// chart one sample behind the numbers beside it.
const [sampledStatus, setSampledStatus] = useState<Status | null>(null);
if (hasData && status !== sampledStatus) {
setSampledStatus(status);
setTrend((prev) => {
const point = sampleOf(status);
const next = emptyWindow();
@@ -139,7 +142,7 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
}
return next;
});
}, [status, hasData]);
}
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);