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
+538 -600
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -13,6 +13,7 @@
"build": "npm run gen:api && vite build", "build": "npm run gen:api && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "oxlint src tools", "lint": "oxlint src tools",
"lint:fix": "oxlint --fix src tools",
"lint:deprecated": "oxlint --type-aware -A all -D typescript/no-deprecated src", "lint:deprecated": "oxlint --type-aware -A all -D typescript/no-deprecated src",
"format": "oxfmt src tools", "format": "oxfmt src tools",
"format:check": "oxfmt --check src tools", "format:check": "oxfmt --check src tools",
@@ -36,13 +37,13 @@
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.7.1", "@hookform/resolvers": "^5.9.1",
"@noble/hashes": "^2.3.0", "@noble/hashes": "^2.3.0",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4", "@tanstack/react-query-devtools": "^5.101.4",
"antd": "^6.6.0", "antd": "^6.6.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.23",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.6", "persian-calendar-suite": "^1.5.6",
@@ -51,35 +52,35 @@
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-i18next": "^17.0.11", "react-i18next": "^17.0.11",
"react-router": "^8.3.0", "react-router": "^8.3.0",
"swagger-ui-react": "^5.32.13", "swagger-ui-react": "^5.32.14",
"uplot": "^1.6.32", "uplot": "^1.6.32",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-a11y": "^10.5.7", "@storybook/addon-a11y": "^10.5.9",
"@storybook/addon-docs": "^10.5.7", "@storybook/addon-docs": "^10.5.9",
"@storybook/addon-vitest": "^10.5.7", "@storybook/addon-vitest": "^10.5.9",
"@storybook/react-vite": "^10.5.7", "@storybook/react-vite": "^10.5.9",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"@vitest/browser-playwright": "4.1.10", "@vitest/browser-playwright": "4.1.11",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.11",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"lint-staged": "^17.3.0", "lint-staged": "^17.3.0",
"msw": "^2.15.0", "msw": "^2.15.0",
"oxfmt": "0.63.0", "oxfmt": "0.64.0",
"oxlint": "1.78.0", "oxlint": "1.79.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^7.0.2001",
"playwright": "^1.62.1", "playwright": "^1.62.1",
"storybook": "^10.5.7", "storybook": "^10.5.9",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.2.1", "vite": "8.2.1",
"vitest": "^4.1.10" "vitest": "^4.1.11"
}, },
"overrides": { "overrides": {
"dompurify": "^3.4.11", "dompurify": "^3.4.11",
@@ -33,15 +33,21 @@ export default function PromptModal({
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const inputRef = useRef<InputRef | null>(null); const inputRef = useRef<InputRef | null>(null);
const [openedWith, setOpenedWith] = useState<string | null>(null);
const openKey = open ? `${type}\u0000${initialValue}` : null;
if (openKey !== openedWith) {
setOpenedWith(openKey);
if (open) setValue(initialValue);
}
useEffect(() => { useEffect(() => {
if (open) { if (!open) return;
setValue(initialValue); const id = setTimeout(() => {
setTimeout(() => { if (type === 'textarea') textareaRef.current?.focus();
if (type === 'textarea') textareaRef.current?.focus(); else inputRef.current?.focus();
else inputRef.current?.focus(); }, 50);
}, 50); return () => clearTimeout(id);
} }, [open, type]);
}, [open, initialValue, type]);
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) { function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
if (type !== 'textarea' && e.key === 'Enter') { if (type !== 'textarea' && e.key === 'Enter') {
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
import { Button, Input, Modal, Tabs, message } from 'antd'; import { Button, Input, Modal, Tabs, message } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons'; import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -35,9 +35,12 @@ export default function TextModal({
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [activeKey, setActiveKey] = useState(''); const [activeKey, setActiveKey] = useState('');
useEffect(() => { // Reset on the way out so the next open starts on the first tab; activeTab
if (open && tabs && tabs.length > 0) setActiveKey(tabs[0].key); // falls back to tabs[0] whenever activeKey no longer matches.
}, [open, tabs]); const close = useCallback(() => {
setActiveKey('');
onClose();
}, [onClose]);
const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0]; const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0];
const activeContent = activeTab ? activeTab.content : content; const activeContent = activeTab ? activeTab.content : content;
@@ -46,7 +49,7 @@ export default function TextModal({
const ok = await ClipboardManager.copyText(activeContent || ''); const ok = await ClipboardManager.copyText(activeContent || '');
if (ok) { if (ok) {
messageApi.success(t('copied')); messageApi.success(t('copied'));
onClose(); close();
} }
} }
@@ -61,7 +64,7 @@ export default function TextModal({
<Modal <Modal
open={open} open={open}
title={title} title={title}
onCancel={onClose} onCancel={close}
destroyOnHidden destroyOnHidden
footer={ footer={
<> <>
@@ -749,8 +749,12 @@ const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
function BrowserDemo(props: GeoBrowserModalProps) { function BrowserDemo(props: GeoBrowserModalProps) {
const [open, setOpen] = useState(props.open); const [open, setOpen] = useState(props.open);
const [value, setValue] = useState(props.value); const [value, setValue] = useState(props.value);
useEffect(() => setOpen(props.open), [props.open]); const [synced, setSynced] = useState({ open: props.open, value: props.value });
useEffect(() => setValue(props.value), [props.value]); if (synced.open !== props.open || synced.value !== props.value) {
setSynced({ open: props.open, value: props.value });
setOpen(props.open);
setValue(props.value);
}
return ( return (
<Space orientation="vertical" size={12}> <Space orientation="vertical" size={12}>
<Space size={8}> <Space size={8}>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
@@ -69,13 +69,16 @@ export default function GeoBrowserModal({
const [entryPage, setEntryPage] = useState(1); const [entryPage, setEntryPage] = useState(1);
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const knownRef = useRef<Set<string>>(new Set()); const [known, setKnown] = useState<Set<string>>(() => new Set());
const seededFilesRef = useRef<Set<string>>(new Set()); const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
const filesQuery = useGeodataFiles(open); const filesQuery = useGeodataFiles(open);
const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]); const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
const activeFile = files.find((candidate) => candidate.name === file); const activeFile = useMemo(
const fileKind: GeoKind = activeFile?.kind ?? kind; () => files.find((candidate) => candidate.name === file),
[files, file],
);
const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
const categoriesQuery = useGeodataCategories(file, '', open && !!file); const categoriesQuery = useGeodataCategories(file, '', open && !!file);
// While a newly picked database loads, the query still serves the previous // While a newly picked database loads, the query still serves the previous
@@ -117,28 +120,27 @@ export default function GeoBrowserModal({
return () => window.clearTimeout(handle); return () => window.clearTimeout(handle);
}, [entryQuery, entryFilter]); }, [entryQuery, entryFilter]);
useEffect(() => { // Opening, picking the default database and seeding the selection are all
if (!open) return; // render-time adjustments — an effect would paint the previous state first.
knownRef.current = new Set(); const [wasOpen, setWasOpen] = useState(false);
seededFilesRef.current = new Set(); if (open !== wasOpen) {
setCategoryQuery(''); setWasOpen(open);
setEntryQuery(''); if (open) {
setEntryFilter(''); setKnown(new Set());
setActiveCode(undefined); setSeededFiles(new Set());
setEntryPage(1); setCategoryQuery('');
setSelected([]); setEntryQuery('');
}, [open]); setEntryFilter('');
setActiveCode(undefined);
useEffect(() => { setEntryPage(1);
if (!open || file || files.length === 0) return; setSelected([]);
}
} else if (open && !file && files.length > 0) {
setFile(preferredFile(files, kind)); setFile(preferredFile(files, kind));
}, [open, file, files, kind]); } else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
useEffect(() => {
if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return;
const tokens = categories.map((category) => tokenFor(file, category.code, fileKind)); const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
for (const token of tokens) knownRef.current.add(token); setKnown(new Set([...known, ...tokens]));
seededFilesRef.current.add(file); setSeededFiles(new Set(seededFiles).add(file));
const fromValue = selectionFromValue(value, new Set(tokens)); const fromValue = selectionFromValue(value, new Set(tokens));
if (fromValue.length > 0) { if (fromValue.length > 0) {
setSelected((previous) => [ setSelected((previous) => [
@@ -146,7 +148,7 @@ export default function GeoBrowserModal({
...fromValue.filter((token) => !previous.includes(token)), ...fromValue.filter((token) => !previous.includes(token)),
]); ]);
} }
}, [open, file, categories, fileKind, value]); }
const visibleCategories = useMemo(() => { const visibleCategories = useMemo(() => {
const query = categoryQuery.trim().toLowerCase(); const query = categoryQuery.trim().toLowerCase();
@@ -276,7 +278,7 @@ export default function GeoBrowserModal({
title={t('pages.xray.geoBrowser.title')} title={t('pages.xray.geoBrowser.title')}
width={880} width={880}
onCancel={onClose} onCancel={onClose}
onOk={() => onApply(mergeSelection(value, selected, knownRef.current))} onOk={() => onApply(mergeSelection(value, selected, known))}
okText={t('pages.xray.geoBrowser.apply')} okText={t('pages.xray.geoBrowser.apply')}
cancelText={t('close')} cancelText={t('close')}
className="geo-browser-modal" className="geo-browser-modal"
@@ -216,7 +216,11 @@ const withGeodata: Decorator = function GeodataBackend(Story) {
function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) { function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
const [current, setCurrent] = useState(value); const [current, setCurrent] = useState(value);
useEffect(() => setCurrent(value), [value]); const [synced, setSynced] = useState(value);
if (synced !== value) {
setSynced(value);
setCurrent(value);
}
return ( return (
<Space orientation="vertical" size={4} style={{ width: 460 }}> <Space orientation="vertical" size={4} style={{ width: 460 }}>
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label> <label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
@@ -49,13 +49,21 @@ export default function GeoTokenInput({
const validate = useValidateGeoTokens(); const validate = useValidateGeoTokens();
const { mutateAsync } = validate; const { mutateAsync } = validate;
useEffect(() => { // An empty field has nothing to validate, so it clears during render rather
const tokens = parseTokens(value); // than waiting a commit for the effect to catch up.
if (tokens.length === 0) { const isEmpty = parseTokens(value).length === 0;
const [wasEmpty, setWasEmpty] = useState(isEmpty);
if (isEmpty !== wasEmpty) {
setWasEmpty(isEmpty);
if (isEmpty) {
setIssues([]); setIssues([]);
setCheckFailed(false); setCheckFailed(false);
return;
} }
}
useEffect(() => {
const tokens = parseTokens(value);
if (tokens.length === 0) return;
let cancelled = false; let cancelled = false;
const timer = setTimeout(() => { const timer = setTimeout(() => {
mutateAsync({ tokens, kind }) mutateAsync({ tokens, kind })
@@ -1,4 +1,4 @@
import { Suspense, useEffect, useState, type ReactNode } from 'react'; import { Suspense, useState, type ReactNode } from 'react';
import { Spin } from 'antd'; import { Spin } from 'antd';
interface LazyMountProps { interface LazyMountProps {
@@ -13,9 +13,7 @@ interface LazyMountProps {
// on heavy list pages to keep the initial bundle small. // on heavy list pages to keep the initial bundle small.
export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) { export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
const [mounted, setMounted] = useState(when); const [mounted, setMounted] = useState(when);
useEffect(() => { if (when && !mounted) setMounted(true);
if (when && !mounted) setMounted(true);
}, [when, mounted]);
if (!mounted) return null; if (!mounted) return null;
return <Suspense fallback={fallback}>{children}</Suspense>; return <Suspense fallback={fallback}>{children}</Suspense>;
} }
+4 -2
View File
@@ -268,9 +268,11 @@ export default function Sparkline(props: SparklineProps) {
extrema, extrema,
}; };
const cfgRef = useRef(cfg); const cfgRef = useRef(cfg);
cfgRef.current = cfg;
const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints }); const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints }; useEffect(() => {
cfgRef.current = cfg;
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
});
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(null); const plotRef = useRef<uPlot | null>(null);
+3 -1
View File
@@ -700,7 +700,9 @@ export function useClients(options: UseClientsOptions = {}) {
// WS-driven in-place merges. Page wires these via useWebSocket; the bridge // WS-driven in-place merges. Page wires these via useWebSocket; the bridge
// covers coarse 'invalidate' and 'inbounds' events centrally. // covers coarse 'invalidate' and 'inbounds' events centrally.
const queryRef = useRef(query); const queryRef = useRef(query);
queryRef.current = query; useEffect(() => {
queryRef.current = query;
});
const applyTrafficEvent = useCallback( const applyTrafficEvent = useCallback(
(payload: unknown) => { (payload: unknown) => {
+14 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useSyncExternalStore } from 'react';
export const MOBILE_BREAKPOINT_PX = 768; export const MOBILE_BREAKPOINT_PX = 768;
@@ -11,17 +11,21 @@ export const MOBILE_BREAKPOINT_PX = 768;
*/ */
export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) { export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
const query = `(max-width: ${breakpoint}px)`; const query = `(max-width: ${breakpoint}px)`;
const [isMobile, setIsMobile] = useState<boolean>(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false, const subscribe = useCallback(
(onStoreChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', onStoreChange);
return () => mql.removeEventListener('change', onStoreChange);
},
[query],
); );
useEffect(() => { const isMobile = useSyncExternalStore(
const mql = window.matchMedia(query); subscribe,
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); () => window.matchMedia(query).matches,
mql.addEventListener('change', onChange); () => false,
setIsMobile(mql.matches); );
return () => mql.removeEventListener('change', onChange);
}, [query]);
return { isMobile }; return { isMobile };
} }
+17 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
export function useServerDraft<T>( export function useServerDraft<T>(
server: T | undefined, server: T | undefined,
@@ -6,37 +6,30 @@ export function useServerDraft<T>(
equals: (left: T, right: T) => boolean, equals: (left: T, right: T) => boolean,
) { ) {
const cloneRef = useRef(clone); const cloneRef = useRef(clone);
const equalsRef = useRef(equals); useEffect(() => {
cloneRef.current = clone; cloneRef.current = clone;
equalsRef.current = equals; });
const [draft, setDraft] = useState<T | undefined>(); const [draft, setDraft] = useState<T | undefined>();
const [baseline, setBaseline] = useState<T | undefined>(); const [baseline, setBaseline] = useState<T | undefined>();
const draftRef = useRef(draft); const [syncedServer, setSyncedServer] = useState<T | undefined>();
const baselineRef = useRef(baseline);
draftRef.current = draft;
baselineRef.current = baseline;
useEffect(() => { const isDirty = draft !== undefined && (baseline === undefined || !equals(draft, baseline));
if (server === undefined) return;
const currentDraft = draftRef.current; // Adopting the server value during render (not in an effect) keeps the
const currentBaseline = baselineRef.current; // returned draft and isDirty consistent within the very first render.
const isDirty = if (server !== syncedServer) {
currentDraft !== undefined && setSyncedServer(server);
(currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline)); if (server !== undefined) {
setBaseline(server); setBaseline(server);
if (isDirty && !equalsRef.current(currentDraft, server)) return; const keepLocalEdits = isDirty && !equals(draft as T, server);
setDraft(cloneRef.current(server)); if (!keepLocalEdits) setDraft(clone(server));
}, [server]); }
}
const markSaved = useCallback((value: T) => { const markSaved = useCallback((value: T) => {
setBaseline(cloneRef.current(value)); setBaseline(cloneRef.current(value));
}, []); }, []);
const isDirty = useMemo(
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
[baseline, draft],
);
return { draft, setDraft, isDirty, markSaved }; return { draft, setDraft, isDirty, markSaved };
} }
+33 -29
View File
@@ -139,10 +139,14 @@ export function useXraySetting(): UseXraySettingResult {
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL); const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState(''); const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL); const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]); const config = configQuery.data;
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]); const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]); const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]); const subscriptionOutbounds = useMemo<unknown[]>(
() => config?.subscriptionOutbounds || [],
[config],
);
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>( const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
{}, {},
); );
@@ -161,34 +165,34 @@ export function useXraySetting(): UseXraySettingResult {
const templateSettingsRef = useRef<XraySettingsValue | null>(null); const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]); const subscriptionOutboundsRef = useRef<unknown[]>([]);
xraySettingRef.current = xraySetting; const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
useEffect(() => { useEffect(() => {
if (!configQuery.data) return; xraySettingRef.current = xraySetting;
const obj = configQuery.data; outboundTestUrlRef.current = outboundTestUrl;
const pretty = JSON.stringify(obj.xraySetting, null, 2); savedXraySettingRef.current = savedXraySetting;
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || ''); savedOutboundTestUrlRef.current = savedOutboundTestUrl;
setInboundTags(obj.inboundTags || []); templateSettingsRef.current = templateSettings;
setClientReverseTags(obj.clientReverseTags || []); subscriptionOutboundsRef.current = subscriptionOutbounds;
setSubscriptionOutbounds(obj.subscriptionOutbounds || []); });
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
// Adopt a fetched config during render, so the editor never paints one frame
// of the previous config after a refetch. Local edits win over the refetch.
if (config && config !== syncedConfig) {
setSyncedConfig(config);
const isDirty = const isDirty =
savedXraySettingRef.current !== xraySettingRef.current || savedXraySetting !== xraySetting ||
savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current); savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
if (isDirty) return; if (!isDirty) {
syncingRef.current = true; const pretty = JSON.stringify(config.xraySetting, null, 2);
setXraySettingState(pretty); const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
setTemplateSettingsState(obj.xraySetting); setXraySettingState(pretty);
setSavedXraySetting(pretty); setTemplateSettingsState(config.xraySetting);
syncingRef.current = false; setSavedXraySetting(pretty);
setOutboundTestUrlState(nextUrl); setOutboundTestUrlState(nextUrl);
setSavedOutboundTestUrl(nextUrl); setSavedOutboundTestUrl(nextUrl);
}, [configQuery.data]); }
}
const fetched = configQuery.data !== undefined || configQuery.isError; const fetched = configQuery.data !== undefined || configQuery.isError;
const fetchError = configQuery.error ? (configQuery.error as Error).message : ''; const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
+3 -5
View File
@@ -287,11 +287,9 @@ export default function AppSidebar() {
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null; const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : [])); const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
useEffect(() => { if (openSubmenu && !openKeys.includes(openSubmenu)) {
if (openSubmenu) { setOpenKeys([...openKeys, openSubmenu]);
setOpenKeys((keys) => (keys.includes(openSubmenu) ? keys : [...keys, openSubmenu])); }
}
}, [openSubmenu]);
const toMenuItems = useCallback( const toMenuItems = useCallback(
(items: typeof tabs): MenuProps['items'] => (items: typeof tabs): MenuProps['items'] =>
@@ -24,7 +24,9 @@ export default function FinalMaskField({
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initial] = useState(() => value ?? EMPTY); const [initial] = useState(() => value ?? EMPTY);
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; useEffect(() => {
onChangeRef.current = onChange;
});
const lastEmitted = useRef(JSON.stringify(initial)); const lastEmitted = useRef(JSON.stringify(initial));
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined; const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
@@ -14,7 +14,9 @@ export default function SniffingField({ value, onChange, enableLabel }: Sniffing
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initial] = useState(() => value ?? SniffingSchema.parse({})); const [initial] = useState(() => value ?? SniffingSchema.parse({}));
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; useEffect(() => {
onChangeRef.current = onChange;
});
const lastEmitted = useRef(JSON.stringify(initial)); const lastEmitted = useRef(JSON.stringify(initial));
const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined; const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined;
@@ -13,7 +13,9 @@ export default function SockoptCustomField({ value, onChange }: SockoptCustomFie
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initial] = useState(() => value ?? []); const [initial] = useState(() => value ?? []);
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; useEffect(() => {
onChangeRef.current = onChange;
});
const lastEmitted = useRef(JSON.stringify(initial)); const lastEmitted = useRef(JSON.stringify(initial));
const list = Form.useWatch('customSockopt', form) as CustomSockopt[] | undefined; const list = Form.useWatch('customSockopt', form) as CustomSockopt[] | undefined;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd'; import { Alert, Modal, Select, Typography, message } from 'antd';
@@ -37,9 +37,13 @@ export default function BulkAttachInboundsModal({
const [targetIds, setTargetIds] = useState<number[]>([]); const [targetIds, setTargetIds] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => { // React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setTargetIds([]); if (open) setTargetIds([]);
}, [open]); }
const targetOptions = useMemo(() => { const targetOptions = useMemo(() => {
return (inbounds || []) return (inbounds || [])
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd'; import { Alert, Modal, Select, Typography, message } from 'antd';
@@ -37,9 +37,13 @@ export default function BulkDetachInboundsModal({
const [targetIds, setTargetIds] = useState<number[]>([]); const [targetIds, setTargetIds] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => { // React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setTargetIds([]); if (open) setTargetIds([]);
}, [open]); }
const targetOptions = useMemo(() => { const targetOptions = useMemo(() => {
return (inbounds || []) return (inbounds || [])
@@ -95,12 +95,14 @@ export default function ClientBulkAddModal({
const limitIpDisabled = !fail2ban.usable; const limitIpDisabled = !fail2ban.usable;
const limitIpNotice = getLimitIpNotice(fail2ban, t); const limitIpNotice = getLimitIpNotice(fail2ban, t);
useEffect(() => { const [wasOpen, setWasOpen] = useState(false);
if (!open) return; if (open !== wasOpen) {
setWasOpen(open);
methods.reset(EMPTY); if (open) {
setDelayedStart(false); methods.reset(EMPTY);
}, [open, methods]); setDelayedStart(false);
}
}
const flowCapableIds = useMemo(() => { const flowCapableIds = useMemo(() => {
const ids = new Set<number>(); const ids = new Set<number>();
+20 -20
View File
@@ -109,14 +109,20 @@ export default function ClientInfoModal({
keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null
>(null); >(null);
useEffect(() => { // Clearing on close happens during render; the effect owns only the fetch.
if (!open) { const openSubId = open ? (client?.subId ?? '') : null;
const [syncedSubId, setSyncedSubId] = useState(openSubId);
if (openSubId !== syncedSubId) {
setSyncedSubId(openSubId);
if (openSubId === null) {
setLinks([]); setLinks([]);
setClientIps([]); setClientIps([]);
setIpsModalOpen(false); setIpsModalOpen(false);
return;
} }
if (!client?.subId) return; }
useEffect(() => {
if (!open || !client?.subId) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const msg = (await HttpUtil.get( const msg = (await HttpUtil.get(
@@ -139,22 +145,16 @@ export default function ClientInfoModal({
return r > 0 ? r : 0; return r > 0 ? r : 0;
}, [totalBytes, used]); }, [totalBytes, used]);
const subLink = useMemo(() => { const subId = client?.subId;
if (!client?.subId || !subSettings?.subURI) return ''; const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
return subSettings.subURI + client.subId; const subJsonLink =
}, [client?.subId, subSettings?.subURI]); subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
? subSettings.subJsonURI + subId
const subJsonLink = useMemo(() => { : '';
if (!client?.subId) return ''; const subClashLink =
if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return ''; subId && subSettings?.subClashEnable && subSettings?.subClashURI
return subSettings.subJsonURI + client.subId; ? subSettings.subClashURI + subId
}, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]); : '';
const subClashLink = useMemo(() => {
if (!client?.subId) return '';
if (!subSettings?.subClashEnable || !subSettings?.subClashURI) return '';
return subSettings.subClashURI + client.subId;
}, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
const showSubscription = !!(subSettings?.enable && client?.subId); const showSubscription = !!(subSettings?.enable && client?.subId);
const wgInbound = useMemo( const wgInbound = useMemo(
+24 -22
View File
@@ -52,16 +52,13 @@ export default function ClientQrModal({
const [links, setLinks] = useState<string[]>([]); const [links, setLinks] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const subLink = useMemo(() => { const subId = client?.subId;
if (!client?.subId || !subSettings?.enable || !subSettings?.subURI) return ''; const subEnabled = !!subSettings?.enable;
return subSettings.subURI + client.subId; const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
}, [client?.subId, subSettings?.enable, subSettings?.subURI]); const subJsonLink =
subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
const subJsonLink = useMemo(() => { ? subSettings.subJsonURI + subId
if (!client?.subId || !subSettings?.enable) return ''; : '';
if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
return subSettings.subJsonURI + client.subId;
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
const wgInbound = useMemo( const wgInbound = useMemo(
() => findWireguardInbound(client, inboundsById), () => findWireguardInbound(client, inboundsById),
@@ -79,13 +76,18 @@ export default function ClientQrModal({
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0; const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
// The reset runs during render so the effect only carries the request.
const openSubId = open ? (client?.subId ?? '') : '';
const [syncedSubId, setSyncedSubId] = useState(openSubId);
if (openSubId !== syncedSubId) {
setSyncedSubId(openSubId);
setLinks([]);
setLoading(!!openSubId);
}
useEffect(() => { useEffect(() => {
if (!open || !client?.subId) { if (!open || !client?.subId) return;
setLinks([]);
return;
}
let cancelled = false; let cancelled = false;
setLoading(true);
(async () => { (async () => {
try { try {
const msg = (await HttpUtil.get( const msg = (await HttpUtil.get(
@@ -166,13 +168,13 @@ export default function ClientQrModal({
return out; return out;
}, [subLink, subJsonLink, wgConfigText, links, client?.email, t]); }, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
useEffect(() => { // Expanding the first panel is a render-time adjustment, not a side effect.
if (!open) { const firstKey = open && items.length > 0 ? items[0].key : null;
setActiveKey([]); const [syncedFirstKey, setSyncedFirstKey] = useState<string | null>(null);
return; if (firstKey !== syncedFirstKey) {
} setSyncedFirstKey(firstKey);
setActiveKey(items.length > 0 ? [items[0].key] : []); setActiveKey(firstKey ? [firstKey] : []);
}, [open, items]); }
return ( return (
<Modal <Modal
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd'; import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -46,11 +46,16 @@ export default function GroupAddClientsModal({
[candidates], [candidates],
); );
useEffect(() => { // React resets this during render rather than in an effect so the modal's
if (!open) return; // first open frame already shows cleared fields.
setSelectedEmails([]); const [wasOpen, setWasOpen] = useState(false);
setSearch(''); if (open !== wasOpen) {
}, [open]); setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd'; import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -44,11 +44,16 @@ export default function GroupRemoveClientsModal({
[members], [members],
); );
useEffect(() => { // React resets this during render rather than in an effect so the modal's
if (!open) return; // first open frame already shows cleared fields.
setSelectedEmails([]); const [wasOpen, setWasOpen] = useState(false);
setSearch(''); if (open !== wasOpen) {
}, [open]); setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
+8 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd'; import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
import { import {
@@ -94,12 +94,17 @@ export default function HostFormModal({
const showTls = security === 'tls' || security === 'reality'; const showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls'; const showTlsExtras = security === 'tls';
useEffect(() => { // React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const openHost = open ? host : null;
const [syncedHost, setSyncedHost] = useState(openHost);
if (openHost !== syncedHost) {
setSyncedHost(openHost);
if (open) { if (open) {
methods.reset(defaultsFor(host)); methods.reset(defaultsFor(host));
setLoading(false); setLoading(false);
} }
}, [open, host, methods]); }
const { nodes } = useNodesQuery(); const { nodes } = useNodesQuery();
@@ -35,7 +35,9 @@ export default function HostFinalMaskForm({
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initial] = useState(() => parseFinalMask(value)); const [initial] = useState(() => parseFinalMask(value));
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; useEffect(() => {
onChangeRef.current = onChange;
});
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined; const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd'; import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -57,14 +57,20 @@ export default function AttachClientsModal({
const [selectedEmails, setSelectedEmails] = useState<string[]>([]); const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
useEffect(() => { // React resets this during render rather than in an effect so the modal's
if (!open) return; // first open frame already shows cleared fields.
const rows = source ? readClientRows(source.settings) : []; const openSource = open ? source : null;
setClientRows(rows); const [syncedSource, setSyncedSource] = useState(openSource);
setSelectedEmails(rows.map((r) => r.email)); if (openSource !== syncedSource) {
setTargetIds([]); setSyncedSource(openSource);
setSearch(''); if (openSource) {
}, [open, source]); const rows = readClientRows(openSource.settings);
setClientRows(rows);
setSelectedEmails(rows.map((r) => r.email));
setTargetIds([]);
setSearch('');
}
}
const targetOptions = useMemo(() => { const targetOptions = useMemo(() => {
if (!source) return []; if (!source) return [];
@@ -49,12 +49,21 @@ export default function AttachExistingClientsModal({
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [groupFilter, setGroupFilter] = useState<string | undefined>(undefined); const [groupFilter, setGroupFilter] = useState<string | undefined>(undefined);
// Reset during render, not in an effect, so the first frame is already clean.
const openTarget = open ? target : null;
const [syncedTarget, setSyncedTarget] = useState(openTarget);
if (openTarget !== syncedTarget) {
setSyncedTarget(openTarget);
if (openTarget) {
setLoading(true);
setSearch('');
setGroupFilter(undefined);
}
}
useEffect(() => { useEffect(() => {
if (!open || !target) return; if (!open || !target) return;
let cancelled = false; let cancelled = false;
setLoading(true);
setSearch('');
setGroupFilter(undefined);
HttpUtil.get('/panel/api/clients/list', undefined, { silent: true }) HttpUtil.get('/panel/api/clients/list', undefined, { silent: true })
.then((msg) => { .then((msg) => {
if (cancelled) return; if (cancelled) return;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd'; import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -52,13 +52,17 @@ export default function DetachClientsModal({
const [selectedEmails, setSelectedEmails] = useState<string[]>([]); const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
useEffect(() => { // Reset during render, not in an effect, so the first frame is already clean.
if (!open) return; const openSource = open ? source : null;
const rows = source ? readClientRows(source.settings) : []; const [syncedSource, setSyncedSource] = useState(openSource);
setClientRows(rows); if (openSource !== syncedSource) {
setSelectedEmails([]); setSyncedSource(openSource);
setSearch(''); if (openSource) {
}, [open, source]); setClientRows(readClientRows(openSource.settings));
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd'; import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
@@ -29,9 +29,11 @@ export default function VlessFields({
const { control } = useFormContext(); const { control } = useFormContext();
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519'); const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
useEffect(() => { const [syncedAuthKind, setSyncedAuthKind] = useState(vlessAuthKind);
if (vlessAuthKind !== syncedAuthKind) {
setSyncedAuthKind(vlessAuthKind);
setAuthKind(vlessAuthKind ?? 'x25519'); setAuthKind(vlessAuthKind ?? 'x25519');
}, [vlessAuthKind]); }
const authOptions = (Object.entries(VLESS_AUTH_LABEL_KEYS) as [VlessAuthKind, string][]).map( const authOptions = (Object.entries(VLESS_AUTH_LABEL_KEYS) as [VlessAuthKind, string][]).map(
([value, labelKey]) => ({ value, label: t(labelKey) }), ([value, labelKey]) => ({ value, label: t(labelKey) }),
@@ -23,10 +23,11 @@ export default function RealityTargetScannerModal({
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<RealityScanResult[]>([]); const [results, setResults] = useState<RealityScanResult[]>([]);
const scanRef = useRef(scanRealityCandidates); const scanRef = useRef(scanRealityCandidates);
scanRef.current = scanRealityCandidates; useEffect(() => {
scanRef.current = scanRealityCandidates;
});
const runScan = useCallback(async (targets?: string) => { const applyScan = useCallback(async (targets?: string) => {
setLoading(true);
try { try {
setResults(await scanRef.current(targets)); setResults(await scanRef.current(targets));
} finally { } finally {
@@ -34,11 +35,29 @@ export default function RealityTargetScannerModal({
} }
}, []); }, []);
const runScan = useCallback(
(targets?: string) => {
setLoading(true);
setResults([]);
void applyScan(targets);
},
[applyScan],
);
// Clearing the previous results is done during render so the auto-scan effect
// carries only the request itself.
const [scannedOpen, setScannedOpen] = useState(false);
if (open !== scannedOpen) {
setScannedOpen(open);
if (open) {
setResults([]);
setLoading(true);
}
}
useEffect(() => { useEffect(() => {
if (!open) return; if (open) void applyScan();
setResults([]); }, [open, applyScan]);
runScan();
}, [open, runScan]);
const columns: ColumnsType<RealityScanResult> = [ const columns: ColumnsType<RealityScanResult> = [
{ {
@@ -99,8 +99,26 @@ export default function InboundInfoModal({
} }
}, [clientStats, t]); }, [clientStats, t]);
useEffect(() => { // The panel's contents are a pure function of the props, so they are adopted
if (!open || !dbInbound) return; // during render; only the IP lookup below stays asynchronous.
const [syncedProps, setSyncedProps] = useState<{
dbInbound: typeof dbInbound;
clientIndex: typeof clientIndex;
nodeAddress: typeof nodeAddress;
subSettings: typeof subSettings;
ipLimitEnable: typeof ipLimitEnable;
} | null>(null);
if (
open &&
dbInbound &&
(syncedProps === null ||
syncedProps.dbInbound !== dbInbound ||
syncedProps.clientIndex !== clientIndex ||
syncedProps.nodeAddress !== nodeAddress ||
syncedProps.subSettings !== subSettings ||
syncedProps.ipLimitEnable !== ipLimitEnable)
) {
setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable });
const info = buildInboundInfo(dbInbound); const info = buildInboundInfo(dbInbound);
setInbound(info); setInbound(info);
setActiveTab(info.clients.length > 0 ? 'client' : 'inbound'); setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
@@ -189,7 +207,16 @@ export default function InboundInfoModal({
} }
}); });
} }
}, [open, dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable, t]); }
// The expiry tag colours against the current time; a state-backed clock keeps
// render pure and still refreshes the tag while the modal stays open.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
const id = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(id);
}, [open]);
const isEnable = useMemo(() => { const isEnable = useMemo(() => {
if (clientSettings) return !!clientSettings.enable; if (clientSettings) return !!clientSettings.enable;
@@ -202,9 +229,9 @@ export default function InboundInfoModal({
const used = (clientStats.up ?? 0) + (clientStats.down ?? 0); const used = (clientStats.up ?? 0) + (clientStats.down ?? 0);
if (total > 0 && used >= total) return true; if (total > 0 && used >= total) return true;
const expiry = clientSettings.expiryTime ?? 0; const expiry = clientSettings.expiryTime ?? 0;
if (expiry > 0 && Date.now() >= expiry) return true; if (expiry > 0 && now >= expiry) return true;
return false; return false;
}, [clientStats, clientSettings]); }, [clientStats, clientSettings, now]);
const remainingStats = useMemo(() => { const remainingStats = useMemo(() => {
if (!clientStats || !clientSettings) return '-'; if (!clientStats || !clientSettings) return '-';
@@ -212,10 +239,12 @@ export default function InboundInfoModal({
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-'; return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
}, [clientStats, clientSettings]); }, [clientStats, clientSettings]);
const wgPubKey = useMemo(() => { const isWireguard = !!dbInbound?.isWireguard;
if (!dbInbound?.isWireguard || !inbound?.settings?.secretKey) return ''; const wgSecretKey = inbound?.settings?.secretKey as string | undefined;
return Wireguard.generateKeypair(inbound.settings.secretKey as string).publicKey; const wgPubKey = useMemo(
}, [dbInbound?.isWireguard, inbound?.settings?.secretKey]); () => (isWireguard && wgSecretKey ? Wireguard.generateKeypair(wgSecretKey).publicKey : ''),
[isWireguard, wgSecretKey],
);
const formatLastOnline = useCallback( const formatLastOnline = useCallback(
(email: string) => { (email: string) => {
@@ -438,9 +467,7 @@ export default function InboundInfoModal({
</td> </td>
<td> <td>
{(clientSettings?.expiryTime ?? 0) > 0 ? ( {(clientSettings?.expiryTime ?? 0) > 0 ? (
<Tag <Tag color={ColorUtils.usageColor(now, expireDiff, clientSettings!.expiryTime!)}>
color={ColorUtils.usageColor(Date.now(), expireDiff, clientSettings!.expiryTime!)}
>
{IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)} {IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)}
</Tag> </Tag>
) : (clientSettings?.expiryTime ?? 0) < 0 ? ( ) : (clientSettings?.expiryTime ?? 0) < 0 ? (
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal, Tag } from 'antd'; import { Modal, Tag } from 'antd';
@@ -40,6 +41,15 @@ export default function InboundStatsModal({
onClose, onClose,
}: InboundStatsModalProps) { }: InboundStatsModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// The expiry tag colours against the current time; a state-backed clock keeps
// render pure and still refreshes the tag while the modal stays open.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
const id = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(id);
}, [open]);
return ( return (
<Modal <Modal
open={open} open={open}
@@ -143,7 +153,7 @@ export default function InboundStatsModal({
<div className="stat-row"> <div className="stat-row">
<span className="stat-label">{t('pages.inbounds.expireDate')}</span> <span className="stat-label">{t('pages.inbounds.expireDate')}</span>
{record.expiryTime > 0 ? ( {record.expiryTime > 0 ? (
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)}> <Tag color={ColorUtils.usageColor(now, expireDiff, record._expiryTime)}>
{IntlUtil.formatRelativeTime(record.expiryTime)} {IntlUtil.formatRelativeTime(record.expiryTime)}
</Tag> </Tag>
) : ( ) : (
+26 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Collapse, Modal } from 'antd'; import { Collapse, Modal } from 'antd';
import type { CollapseProps } from 'antd'; import type { CollapseProps } from 'antd';
@@ -54,8 +54,24 @@ export default function QrCodeModal({
const [subJsonLink, setSubJsonLink] = useState(''); const [subJsonLink, setSubJsonLink] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([]); const [activeKey, setActiveKey] = useState<string[]>([]);
useEffect(() => { // Building the links is a pure function of the props, so it runs during
if (!open || !dbInbound) return; // render; an effect would paint the previous inbound's QR first.
const [syncedProps, setSyncedProps] = useState<{
dbInbound: typeof dbInbound;
client: typeof client;
nodeAddress: typeof nodeAddress;
subSettings: typeof subSettings;
} | null>(null);
if (
open &&
dbInbound &&
(syncedProps === null ||
syncedProps.dbInbound !== dbInbound ||
syncedProps.client !== client ||
syncedProps.nodeAddress !== nodeAddress ||
syncedProps.subSettings !== subSettings)
) {
setSyncedProps({ dbInbound, client, nodeAddress, subSettings });
const inbound = inboundFromDb(dbInbound); const inbound = inboundFromDb(dbInbound);
const fallbackHostname = preferPublicHost( const fallbackHostname = preferPublicHost(
window.location.hostname, window.location.hostname,
@@ -105,7 +121,7 @@ export default function QrCodeModal({
} }
setSubLink(nextSub); setSubLink(nextSub);
setSubJsonLink(nextSubJson); setSubJsonLink(nextSubJson);
}, [open, dbInbound, client, nodeAddress, subSettings]); }
const qrItems = useMemo<QrItem[]>(() => { const qrItems = useMemo<QrItem[]>(() => {
const items: QrItem[] = []; const items: QrItem[] = [];
@@ -158,13 +174,12 @@ export default function QrCodeModal({
[qrItems], [qrItems],
); );
useEffect(() => { const firstKey = open && qrItems.length > 0 ? qrItems[0].key : null;
if (!open) { const [syncedFirstKey, setSyncedFirstKey] = useState<string | null>(null);
setActiveKey([]); if (firstKey !== syncedFirstKey) {
return; setSyncedFirstKey(firstKey);
} setActiveKey(firstKey ? [firstKey] : []);
setActiveKey(qrItems.length > 0 ? [qrItems[0].key] : []); }
}, [open, qrItems]);
return ( return (
<Modal <Modal
+1 -1
View File
@@ -133,7 +133,7 @@ export default function QrPanel({
tabIndex={0} tabIndex={0}
aria-label={t('copy')} aria-label={t('copy')}
onClick={copyImage} onClick={copyImage}
onKeyDown={activateOnKey(copyImage)} onKeyDown={(event) => activateOnKey(copyImage)(event)}
> >
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<QRCode <QRCode
+198 -245
View File
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate'; import { parseMsg } from '@/utils/zodValidate';
import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound'; import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
import type { ClientStats, DBInboundInit } from '@/models/dbinbound';
import { Protocols } from '@/schemas/primitives'; import { Protocols } from '@/schemas/primitives';
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities'; import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { setDatepicker } from '@/hooks/useDatepicker'; import { setDatepicker } from '@/hooks/useDatepicker';
@@ -203,20 +204,13 @@ export function useInbounds() {
if (defaults.datepicker) setDatepicker(datepicker); if (defaults.datepicker) setDatepicker(datepicker);
}, [datepicker, defaults.datepicker]); }, [datepicker, defaults.datepicker]);
const expireDiffRef = useRef(expireDiff); // dbInbounds mirrors the slim query data wrapped as DBInbound instances. The
expireDiffRef.current = expireDiff; // WS handlers rebuild only the rows they touch, so no refetch is needed.
const trafficDiffRef = useRef(trafficDiff);
trafficDiffRef.current = trafficDiff;
// dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
// stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
// can merge per-row updates without invalidating the entire query.
const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]); const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
const dbInboundsRef = useRef<DBInboundInstance[]>([]); const dbInboundsRef = useRef<DBInboundInstance[]>([]);
dbInboundsRef.current = dbInbounds; useEffect(() => {
dbInboundsRef.current = dbInbounds;
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({}); });
const [statsVersion, setStatsVersion] = useState(0);
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>(() => const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>(() =>
Date.now() - inboundSpeedCache.at < SPEED_CACHE_TTL_MS ? inboundSpeedCache.data : {}, Date.now() - inboundSpeedCache.at < SPEED_CACHE_TTL_MS ? inboundSpeedCache.data : {},
@@ -226,20 +220,18 @@ export function useInbounds() {
}, [inboundSpeed]); }, [inboundSpeed]);
const [onlineClients, setOnlineClients] = useState<string[]>([]); const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
// Online emails keyed by the hosting node's panelGuid. The rollup reads this // Online emails keyed by the hosting node's panelGuid. The rollup reads this
// so each inbound only counts clients online on the node that physically // so each inbound only counts clients online on the node that physically
// hosts it, attributing a sub-node's clients to that sub-node (#4983). // hosts it, attributing a sub-node's clients to that sub-node (#4983).
const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map()); const [onlineByGuid, setOnlineByGuid] = useState<Map<string, Set<string>>>(() => new Map());
// Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID // Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
// missing from this map means "no per-inbound activity reported" (e.g. remote // missing from this map means "no per-inbound activity reported" (e.g. remote
// nodes), so the rollup leaves that node's inbounds ungated and falls back to // nodes), so the rollup leaves that node's inbounds ungated and falls back to
// the email signal. A present GUID gates: a client only counts online on an // the email signal. A present GUID gates: a client only counts online on an
// inbound whose tag carried traffic this window. // inbound whose tag carried traffic this window.
const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map()); const [activeByGuid, setActiveByGuid] = useState<Map<string, Set<string>>>(() => new Map());
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({}); const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
@@ -276,12 +268,12 @@ export function useInbounds() {
// the master-local synthetic id for an old-build node without one (#4983). // the master-local synthetic id for an old-build node without one (#4983).
const guid = const guid =
dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : ''); dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
const nodeOnline = onlineByGuidRef.current.get(guid); const nodeOnline = onlineByGuid.get(guid);
// A node absent from the active map reports no per-inbound activity, so // A node absent from the active map reports no per-inbound activity, so
// leave its inbounds ungated. When present, only mark a client online on // leave its inbounds ungated. When present, only mark a client online on
// this inbound if its tag actually carried traffic — that's what stops a // this inbound if its tag actually carried traffic — that's what stops a
// multi-inbound client lighting up every inbound it's attached to. // multi-inbound client lighting up every inbound it's attached to.
const activeForNode = activeByGuidRef.current.get(guid); const activeForNode = activeByGuid.get(guid);
const inboundActive = const inboundActive =
activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag); activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
@@ -312,8 +304,8 @@ export function useInbounds() {
if (inboundActive && nodeOnline?.has(client.email)) online.push(client.email); if (inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
if (stats) { if (stats) {
const expiringSoon = const expiringSoon =
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) || (stats.expiryTime > 0 && stats.expiryTime - now < expireDiff) ||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current); (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff);
if (expiringSoon) expiring.push(client.email); if (expiringSoon) expiring.push(client.email);
} }
} }
@@ -333,12 +325,14 @@ export function useInbounds() {
comments, comments,
}; };
}, },
[], [onlineByGuid, activeByGuid, expireDiff, trafficDiff],
); );
const rebuildClientCount = useCallback(() => { // Every write to a DBInbound row also replaces the dbInbounds array, so this
// recomputes on both a refetch and a WS-merged stats update.
const clientCount = useMemo(() => {
const counts: Record<number, ClientRollup> = {}; const counts: Record<number, ClientRollup> = {};
for (const dbInbound of dbInboundsRef.current) { for (const dbInbound of dbInbounds) {
const protocol = dbInbound.protocol; const protocol = dbInbound.protocol;
if (!TRACKED_PROTOCOLS.includes(protocol)) continue; if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
const settings = coerceInboundJsonField(dbInbound.settings) as { const settings = coerceInboundJsonField(dbInbound.settings) as {
@@ -348,60 +342,44 @@ export function useInbounds() {
if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue; if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients }); counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
} }
setClientCount(counts); return counts;
}, [rollupClients]); }, [dbInbounds, rollupClients]);
// Seed dbInbounds + clientCount from the slim query. Runs on first fetch and // Adopting fetched data during render (rather than in an effect) keeps the
// again every time the query refetches (e.g. invalidate from WS bridge). // list from painting one frame of the previous data after a refetch.
useEffect(() => { const [syncedSlim, setSyncedSlim] = useState<unknown>();
if (!slimQuery.data) return; if (slimQuery.data && slimQuery.data !== syncedSlim) {
const next: DBInboundInstance[] = []; setSyncedSlim(slimQuery.data);
const counts: Record<number, ClientRollup> = {}; setDbInbounds(
for (const row of slimQuery.data as { protocol: string; id: number }[]) { (slimQuery.data as { protocol: string; id: number }[]).map(
const dbInbound = new DBInbound(row) as DBInboundInstance; (row) => new DBInbound(row) as DBInboundInstance,
next.push(dbInbound); ),
if (TRACKED_PROTOCOLS.includes(row.protocol)) { );
const settings = coerceInboundJsonField(dbInbound.settings) as { }
method?: string;
clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
};
if (
row.protocol === Protocols.SHADOWSOCKS &&
!isSSMultiUser({ protocol: row.protocol, settings })
)
continue;
counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
}
}
dbInboundsRef.current = next;
setDbInbounds(next);
setClientCount(counts);
}, [slimQuery.data, rollupClients]);
useEffect(() => { const [syncedOnlines, setSyncedOnlines] = useState<unknown>();
if (onlinesQuery.data) { if (onlinesQuery.data && onlinesQuery.data !== syncedOnlines) {
onlineClientsRef.current = onlinesQuery.data; setSyncedOnlines(onlinesQuery.data);
setOnlineClients(onlinesQuery.data); setOnlineClients(onlinesQuery.data);
} }
}, [onlinesQuery.data]);
useEffect(() => { const [syncedOnlinesByGuid, setSyncedOnlinesByGuid] = useState<unknown>();
if (onlinesByGuidQuery.data) { if (onlinesByGuidQuery.data && onlinesByGuidQuery.data !== syncedOnlinesByGuid) {
onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data); setSyncedOnlinesByGuid(onlinesByGuidQuery.data);
rebuildClientCount(); setOnlineByGuid(toGuidOnlineMap(onlinesByGuidQuery.data));
} }
}, [onlinesByGuidQuery.data, rebuildClientCount]);
useEffect(() => { const [syncedActiveInbounds, setSyncedActiveInbounds] = useState<unknown>();
if (activeInboundsQuery.data) { if (activeInboundsQuery.data && activeInboundsQuery.data !== syncedActiveInbounds) {
activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data); setSyncedActiveInbounds(activeInboundsQuery.data);
rebuildClientCount(); setActiveByGuid(toGuidOnlineMap(activeInboundsQuery.data));
} }
}, [activeInboundsQuery.data, rebuildClientCount]);
useEffect(() => { const [syncedLastOnline, setSyncedLastOnline] = useState<unknown>();
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data); if (lastOnlineQuery.data && lastOnlineQuery.data !== syncedLastOnline) {
}, [lastOnlineQuery.data]); setSyncedLastOnline(lastOnlineQuery.data);
setLastOnlineMap(lastOnlineQuery.data);
}
const fetched = const fetched =
(slimQuery.data !== undefined || slimQuery.isError) && (slimQuery.data !== undefined || slimQuery.isError) &&
@@ -430,185 +408,161 @@ export function useInbounds() {
// uuid/password/flow/etc.) and swaps it into the cached list. Use this // uuid/password/flow/etc.) and swaps it into the cached list. Use this
// before opening edit / info / qr / export / clone flows — refresh() loads // before opening edit / info / qr / export / clone flows — refresh() loads
// the slim list which doesn't carry per-client secrets. // the slim list which doesn't carry per-client secrets.
const hydrateInbound = useCallback( const hydrateInbound = useCallback(async (id: number) => {
async (id: number) => { const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`); if (!msg?.success || !msg.obj) return null;
if (!msg?.success || !msg.obj) return null; const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`); if (!validated.obj) return null;
if (!validated.obj) return null; const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
const dbInbound = new DBInbound(validated.obj) as DBInboundInstance; setDbInbounds((prev) => {
setDbInbounds((prev) => { const next = prev.map((row) =>
const next = prev.map((row) => (row as unknown as { id: number }).id === id ? dbInbound : row,
(row as unknown as { id: number }).id === id ? dbInbound : row, );
); dbInboundsRef.current = next;
dbInboundsRef.current = next; return next;
});
return dbInbound;
}, []);
const applyTrafficEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as {
traffics?: TrafficDelta[];
nodeTraffics?: TrafficDelta[];
onlineClients?: string[];
onlineByGuid?: Record<string, string[]>;
activeInbounds?: Record<string, string[]>;
lastOnlineMap?: Record<string, number>;
};
if (Array.isArray(p.onlineClients)) {
setOnlineClients(p.onlineClients);
}
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
}
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
}
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}
// Speed arrives from two independent 5s polls: the local Xray poll sends
// `traffics` (local inbounds) and the node sync sends `nodeTraffics` (node
// inbounds). Each replaces speed only within its own scope so the two don't
// clobber each other; an idle in-scope inbound — absent from its payload —
// clears instead of showing a stale value.
const applyTraffics = (
traffics: TrafficDelta[],
inScope: (ib: DBInboundInstance) => boolean,
) => {
const byTag = new Map<string, TrafficDelta>();
for (const tr of traffics) {
if (!tr || typeof tr.Tag !== 'string') continue;
if (tr.IsInbound === false) continue;
byTag.set(tr.Tag, tr);
}
setInboundSpeed((prev) => {
const next = { ...prev };
for (const ib of dbInboundsRef.current) {
if (!inScope(ib)) continue;
const delta = byTag.get(ib.tag);
if (delta) {
next[ib.id] = {
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
};
} else {
delete next[ib.id];
}
}
return next; return next;
}); });
rebuildClientCount(); };
return dbInbound; if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
}, if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
[rebuildClientCount], }, []);
);
const applyTrafficEvent = useCallback( const applyClientStatsEvent = useCallback((payload: unknown) => {
(payload: unknown) => { if (!payload || typeof payload !== 'object') return;
if (!payload || typeof payload !== 'object') return; const p = payload as {
const p = payload as { inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
traffics?: TrafficDelta[]; clients?: {
nodeTraffics?: TrafficDelta[]; email: string;
onlineClients?: string[]; up?: number;
onlineByGuid?: Record<string, string[]>; down?: number;
activeInbounds?: Record<string, string[]>; total?: number;
lastOnlineMap?: Record<string, number>; expiryTime?: number;
}; enable?: boolean;
if (Array.isArray(p.onlineClients)) { }[];
onlineClientsRef.current = p.onlineClients; };
setOnlineClients(p.onlineClients);
}
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
}
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
}
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}
// Speed arrives from two independent 5s polls: the local Xray poll sends
// `traffics` (local inbounds) and the node sync sends `nodeTraffics` (node
// inbounds). Each replaces speed only within its own scope so the two don't
// clobber each other; an idle in-scope inbound — absent from its payload —
// clears instead of showing a stale value.
const applyTraffics = (
traffics: TrafficDelta[],
inScope: (ib: DBInboundInstance) => boolean,
) => {
const byTag = new Map<string, TrafficDelta>();
for (const tr of traffics) {
if (!tr || typeof tr.Tag !== 'string') continue;
if (tr.IsInbound === false) continue;
byTag.set(tr.Tag, tr);
}
setInboundSpeed((prev) => {
const next = { ...prev };
for (const ib of dbInboundsRef.current) {
if (!inScope(ib)) continue;
const delta = byTag.get(ib.tag);
if (delta) {
next[ib.id] = {
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
};
} else {
delete next[ib.id];
}
}
return next;
});
};
if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
rebuildClientCount();
},
[rebuildClientCount],
);
const applyClientStatsEvent = useCallback( const byId = new Map<
(payload: unknown) => { number,
if (!payload || typeof payload !== 'object') return; { id: number; up?: number; down?: number; total?: number; enable?: boolean }
const p = payload as { >();
inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[]; if (Array.isArray(p.inbounds)) {
clients?: { for (const row of p.inbounds) {
email: string; if (row && row.id != null) byId.set(row.id, row);
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
}[];
};
let touched = false;
if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
const byId = new Map<
number,
{ id: number; up?: number; down?: number; total?: number; enable?: boolean }
>();
for (const row of p.inbounds) {
if (row && row.id != null) byId.set(row.id, row);
}
for (const ib of dbInboundsRef.current) {
const upd = byId.get((ib as unknown as { id: number }).id);
if (!upd) continue;
const ibRec = ib as unknown as {
up: number;
down: number;
total: number;
enable: boolean;
};
if (typeof upd.up === 'number') ibRec.up = upd.up;
if (typeof upd.down === 'number') ibRec.down = upd.down;
if (typeof upd.total === 'number') ibRec.total = upd.total;
if (typeof upd.enable === 'boolean') ibRec.enable = upd.enable;
touched = true;
}
} }
}
if (Array.isArray(p.clients) && p.clients.length > 0) { const byEmail = new Map<
const byEmail = new Map< string,
string, {
{ email: string;
email: string; up?: number;
up?: number; down?: number;
down?: number; total?: number;
total?: number; expiryTime?: number;
expiryTime?: number; enable?: boolean;
enable?: boolean;
}
>();
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
for (const ib of dbInboundsRef.current) {
const stats = (
ib as unknown as {
clientStats: {
email: string;
up: number;
down: number;
total: number;
expiryTime: number;
enable: boolean;
}[];
}
).clientStats;
if (!Array.isArray(stats)) continue;
for (let i = 0; i < stats.length; i++) {
const stat = stats[i];
const upd = byEmail.get(stat.email);
if (!upd) continue;
if (typeof upd.up === 'number') stat.up = upd.up;
if (typeof upd.down === 'number') stat.down = upd.down;
if (typeof upd.total === 'number') stat.total = upd.total;
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
touched = true;
}
}
} }
>();
if (touched) { if (Array.isArray(p.clients)) {
setStatsVersion((v) => v + 1); for (const row of p.clients) {
setDbInbounds((prev) => { if (row && row.email) byEmail.set(row.email, row);
const next = [...prev];
dbInboundsRef.current = next;
return next;
});
rebuildClientCount();
} }
}, }
[rebuildClientCount], if (byId.size === 0 && byEmail.size === 0) return;
);
// Rows carrying an update are rebuilt rather than patched in place: the
// derived clientCount only recomputes when a row's identity changes.
let touched = false;
const next = dbInboundsRef.current.map((ib) => {
const upd = byId.get(ib.id);
const stats = Array.isArray(ib.clientStats) ? ib.clientStats : null;
let statsTouched = false;
const nextStats =
stats && byEmail.size > 0
? stats.map((stat) => {
const su = byEmail.get(stat.email);
if (!su) return stat;
statsTouched = true;
return {
...stat,
up: typeof su.up === 'number' ? su.up : stat.up,
down: typeof su.down === 'number' ? su.down : stat.down,
total: typeof su.total === 'number' ? su.total : stat.total,
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
} as ClientStats;
})
: null;
if (!upd && !statsTouched) return ib;
touched = true;
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
if (upd) {
if (typeof upd.up === 'number') row.up = upd.up;
if (typeof upd.down === 'number') row.down = upd.down;
if (typeof upd.total === 'number') row.total = upd.total;
if (typeof upd.enable === 'boolean') row.enable = upd.enable;
}
if (statsTouched && nextStats) row.clientStats = nextStats;
return row;
});
if (!touched) return;
dbInboundsRef.current = next;
setDbInbounds(next);
}, []);
const totals = useMemo(() => { const totals = useMemo(() => {
let up = 0; let up = 0;
@@ -629,7 +583,6 @@ export function useInbounds() {
onlineClients, onlineClients,
lastOnlineMap, lastOnlineMap,
inboundSpeed, inboundSpeed,
statsVersion,
totals, totals,
expireDiff, expireDiff,
trafficDiff, trafficDiff,
+13 -9
View File
@@ -38,21 +38,20 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
const [outbound, setOutbound] = useState<string | undefined>(undefined); const [outbound, setOutbound] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<GeodataAssetRow[]>([]); const [rows, setRows] = useState<GeodataAssetRow[]>([]);
const [outboundTags, setOutboundTags] = useState<string[]>([]); 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 outboundTestUrlRef = useRef('');
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true);
try { try {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success || typeof msg.obj !== 'string') return; if (!msg?.success || typeof msg.obj !== 'string') return;
const payload = JSON.parse(msg.obj) as Record<string, unknown>; const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const template = (payload.xraySetting || {}) as Record<string, unknown>; const next = (payload.xraySetting || {}) as Record<string, unknown>;
templateRef.current = template; setTemplate(next);
outboundTestUrlRef.current = outboundTestUrlRef.current =
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : ''; 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 : []; const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
setRows( setRows(
assets assets
@@ -67,7 +66,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
// Download outbound candidates: template outbounds + subscription outbounds. // Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it. // Skip blackhole outbounds — routing a download through one just drops it.
const tags = new Set<string>(); 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) { for (const o of outbounds) {
if (!o || typeof o !== 'object') continue; if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>; 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(() => { useEffect(() => {
if (active) load(); if (active) void load();
}, [active, load]); }, [active, load]);
function setRow(index: number, patch: Partial<GeodataAssetRow>) { function setRow(index: number, patch: Partial<GeodataAssetRow>) {
@@ -102,7 +107,6 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
} }
function save() { function save() {
const template = templateRef.current;
if (!template) return; if (!template) return;
const assets = rows const assets = rows
.map((r) => ({ url: r.url.trim(), file: r.file.trim() })) .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')} {t('pages.index.geodataAddFile')}
</Button> </Button>
<Button type="primary" onClick={save} disabled={loading || !templateRef.current}> <Button type="primary" onClick={save} disabled={loading || !template}>
{t('pages.index.geodataSaveRestart')} {t('pages.index.geodataSaveRestart')}
</Button> </Button>
</div> </div>
+18 -11
View File
@@ -25,10 +25,8 @@ export default function LogModal({ open, onClose }: LogModalProps) {
const [autoUpdate, setAutoUpdate] = useState(false); const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]); const [logs, setLogs] = useState<string[]>([]);
const openRef = useRef(open);
const refresh = useCallback(async () => { const runRefresh = useCallback(async () => {
setLoading(true);
try { try {
const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, { const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
level, level,
@@ -43,19 +41,28 @@ export default function LogModal({ open, onClose }: LogModalProps) {
} }
}, [rows, level, syslog]); }, [rows, level, syslog]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh); const refreshRef = useRef(refresh);
useEffect(() => { useEffect(() => {
refreshRef.current = refresh; 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(() => { useEffect(() => {
openRef.current = open; if (open) void runRefresh();
if (open) refresh(); }, [open, runRefresh]);
}, [open, refresh]);
useEffect(() => {
if (openRef.current) refresh();
}, [rows, level, syslog, refresh]);
useEffect(() => { useEffect(() => {
if (!open || !autoUpdate) return; if (!open || !autoUpdate) return;
+79 -64
View File
@@ -200,16 +200,74 @@ function formatFullTimestamp(unixSec: number): string {
return `${MM}-${DD} ${time}`; 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) { export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [activeKey, setActiveKey] = useState('cpu'); const [activeKey, setActiveKey] = useState('cpu');
const [bucket, setBucket] = useState(2); const [bucket, setBucket] = useState(2);
const [points, setPoints] = useState<number[]>([]); const [{ points, points2, points3, labels, timestamps }, setChart] =
const [points2, setPoints2] = useState<number[]>([]); useState<HistoryChart>(EMPTY_CHART);
const [points3, setPoints3] = useState<number[]>([]);
const [labels, setLabels] = useState<string[]>([]);
const [timestamps, setTimestamps] = useState<number[]>([]);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]); const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n); 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 () => { const fetchBucket = useCallback(async () => {
if (!activeMetric) return; if (!activeMetric) return;
try { const next = await loadBucket(activeMetric, bucket);
const url = `/panel/api/server/history/${activeMetric.key}/${bucket}`; setChart(next);
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([]);
}
}, [activeMetric, bucket]); }, [activeMetric, bucket]);
useEffect(() => { const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setActiveKey('cpu'); if (open) setActiveKey('cpu');
}, [open]); }
useEffect(() => { useEffect(() => {
if (open) fetchBucket(); if (!open || !activeMetric) return;
}, [open, activeKey, bucket, fetchBucket]); let cancelled = false;
void (async () => {
const next = await loadBucket(activeMetric, bucket);
if (!cancelled) setChart(next);
})();
return () => {
cancelled = true;
};
}, [open, activeMetric, bucket]);
useEffect(() => { useEffect(() => {
if (!open) return undefined; 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 [loading, setLoading] = useState(false);
const fetchVersions = useCallback(async () => { const fetchVersions = useCallback(async () => {
setLoading(true);
try { try {
const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion'); const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
if (msg?.success) setVersions(msg.obj || []); 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(() => { useEffect(() => {
if (open) fetchVersions(); if (open) void fetchVersions();
}, [open, fetchVersions]); }, [open, fetchVersions]);
function switchXrayVersion(version: string) { 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 [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<XrayLogEntry[]>([]); const [logs, setLogs] = useState<XrayLogEntry[]>([]);
const openRef = useRef(open);
const orderedLogs = useMemo(() => [...logs].reverse(), [logs]); const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
const refresh = useCallback(async () => { const runRefresh = useCallback(async () => {
setLoading(true);
try { try {
const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, { const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
filter, filter,
@@ -93,19 +91,30 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
} }
}, [rows, filter, showDirect, showBlocked, showProxy]); }, [rows, filter, showDirect, showBlocked, showProxy]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh); const refreshRef = useRef(refresh);
useEffect(() => { useEffect(() => {
refreshRef.current = refresh; 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(() => { useEffect(() => {
openRef.current = open; if (open) void runRefresh();
if (open) refresh(); }, [open, rows, showDirect, showBlocked, showProxy, runRefresh]);
}, [open, refresh]);
useEffect(() => {
if (openRef.current) refresh();
}, [rows, showDirect, showBlocked, showProxy, refresh]);
useEffect(() => { useEffect(() => {
if (!open || !autoUpdate) return; 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 type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Tabs, Tag } from 'antd'; import { Alert, Modal, Select, Tabs, Tag } from 'antd';
@@ -147,19 +147,71 @@ function formatFullTimestamp(unixSec: number): string {
return `${MM}-${DD} ${time}`; 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) { export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [activeKey, setActiveKey] = useState('xrAlloc'); const [activeKey, setActiveKey] = useState('xrAlloc');
const [bucket, setBucket] = useState(2); const [bucket, setBucket] = useState(2);
const [points, setPoints] = useState<number[]>([]); const [{ points, labels, timestamps }, setChart] = useState<MetricsChart>(EMPTY_CHART);
const [labels, setLabels] = useState<string[]>([]);
const [timestamps, setTimestamps] = useState<number[]>([]);
const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' }); const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]); const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
const [obsActiveTag, setObsActiveTag] = useState(''); const [obsActiveTag, setObsActiveTag] = useState('');
const obsTimerRef = useRef<number | null>(null); const [obsTick, setObsTick] = useState(0);
const openRef = useRef(open);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]); const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const isObservatory = activeKey === OBS_KEY; const isObservatory = activeKey === OBS_KEY;
@@ -184,151 +236,63 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
[tsLookup], [tsLookup],
); );
const applyHistory = useCallback( const [wasOpen, setWasOpen] = useState(false);
(msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => { if (open !== wasOpen) {
if (msg?.success && Array.isArray(msg.obj)) { setWasOpen(open);
const vals: number[] = []; if (open) setActiveKey('xrAlloc');
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]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (isObservatory) { let cancelled = false;
fetchObservatory(); void (async () => {
fetchObsBucket(); const next = await loadState();
stopObsPolling(); if (!cancelled && next) setState(next);
obsTimerRef.current = window.setInterval(async () => { })();
if (!openRef.current || !isObservatory) return;
await fetchObservatory();
fetchObsBucket();
}, 2000);
} else {
stopObsPolling();
fetchMetricBucket();
}
return () => { return () => {
stopObsPolling(); cancelled = true;
}; };
}, [ }, [open]);
open,
activeKey, // The observatory snapshot is a live view, so it re-polls; obsTick then pulls
isObservatory, // the chart along with it.
fetchObservatory, useEffect(() => {
fetchObsBucket, if (!open || !isObservatory) return;
fetchMetricBucket, let cancelled = false;
stopObsPolling, 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (isObservatory) { let cancelled = false;
fetchObsBucket(); void (async () => {
} else { const next = await loadHistory(historyUrl, bucket);
fetchMetricBucket(); if (!cancelled) setChart(next);
} })();
}, [open, bucket, isObservatory, fetchObsBucket, fetchMetricBucket]); return () => {
cancelled = true;
useEffect(() => { };
if (open && isObservatory) fetchObsBucket(); }, [open, historyUrl, bucket, obsTick]);
}, [open, obsActiveTag, isObservatory, fetchObsBucket]);
return ( return (
<Modal <Modal
@@ -128,8 +128,11 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
}; };
}, []); }, []);
useEffect(() => { // Each polled status is appended during render; an effect would show the
if (!hasData) return; // chart one sample behind the numbers beside it.
const [sampledStatus, setSampledStatus] = useState<Status | null>(null);
if (hasData && status !== sampledStatus) {
setSampledStatus(status);
setTrend((prev) => { setTrend((prev) => {
const point = sampleOf(status); const point = sampleOf(status);
const next = emptyWindow(); const next = emptyWindow();
@@ -139,7 +142,7 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
} }
return next; return next;
}); });
}, [status, hasData]); }
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]); const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
+8 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
@@ -103,8 +103,12 @@ export default function NodeFormModal({
]; ];
}, [outboundGroups, t]); }, [outboundGroups, t]);
useEffect(() => { // Reset during render, not in an effect, so the first frame is already clean.
if (!open) return; const [synced, setSynced] = useState<{ mode: string; node: NodeRecord | null } | null>(null);
if (!open) {
if (synced) setSynced(null);
} else if (!synced || synced.mode !== mode || synced.node !== (node ?? null)) {
setSynced({ mode, node: node ?? null });
const base = defaultValues(); const base = defaultValues();
const next: NodeFormValues = const next: NodeFormValues =
mode === 'edit' && node mode === 'edit' && node
@@ -123,7 +127,7 @@ export default function NodeFormModal({
methods.reset(next); methods.reset(next);
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag }))); setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
setTestResult(null); setTestResult(null);
}, [open, mode, node, methods]); }
const title = useMemo( const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
+9 -5
View File
@@ -73,7 +73,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]); const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
const [apiTokensLoading, setApiTokensLoading] = useState(false); const [apiTokensLoading, setApiTokensLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState(''); const [createName, setCreateName] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -130,8 +130,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
} }
} }
const loadApiTokens = useCallback(async () => { const fetchApiTokens = useCallback(async () => {
setApiTokensLoading(true);
try { try {
const msg = (await HttpUtil.get('/panel/api/setting/apiTokens')) as ApiMsg<ApiTokenRow[]>; const msg = (await HttpUtil.get('/panel/api/setting/apiTokens')) as ApiMsg<ApiTokenRow[]>;
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []); if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
@@ -140,9 +139,14 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
} }
}, []); }, []);
const loadApiTokens = useCallback(async () => {
setApiTokensLoading(true);
await fetchApiTokens();
}, [fetchApiTokens]);
useEffect(() => { useEffect(() => {
loadApiTokens(); void fetchApiTokens();
}, [loadApiTokens]); }, [fetchApiTokens]);
async function copyToken(token: string) { async function copyToken(token: string) {
if (!token) return; if (!token) return;
+3 -10
View File
@@ -86,16 +86,9 @@ export default function SettingsPage() {
savePayload, savePayload,
} = useAllSettings(); } = useAllSettings();
const [entryHost, setEntryHost] = useState(''); const [entryHost] = useState(() => window.location.hostname);
const [entryPort, setEntryPort] = useState(''); const [entryPort] = useState(() => window.location.port);
const [entryIsIP, setEntryIsIP] = useState(false); const [entryIsIP] = useState(() => isIp(window.location.hostname));
useEffect(() => {
const host = window.location.hostname;
setEntryHost(host);
setEntryPort(window.location.port);
setEntryIsIP(isIp(host));
}, []);
const [alertVisible, setAlertVisible] = useState(true); const [alertVisible, setAlertVisible] = useState(true);
const location = useLocation(); const location = useLocation();
@@ -30,7 +30,9 @@ export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMa
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initial] = useState(() => parseFinalMask(value)); const [initial] = useState(() => parseFinalMask(value));
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; useEffect(() => {
onChangeRef.current = onChange;
});
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined; const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
+20 -23
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Divider, Input, Modal, QRCode, message } from 'antd'; import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth'; import * as OTPAuth from 'otpauth';
@@ -32,28 +32,25 @@ export default function TwoFactorModal({
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [enteredCode, setEnteredCode] = useState(''); const [enteredCode, setEnteredCode] = useState('');
const [qrValue, setQrValue] = useState('');
const totpRef = useRef<OTPAuth.TOTP | null>(null);
useEffect(() => { const totp = useMemo(() => {
if (!open) return; if (!open || !token) return null;
return new OTPAuth.TOTP({
setEnteredCode(''); issuer: '3x-ui',
totpRef.current = null; label: 'Administrator',
setQrValue(''); algorithm: 'SHA1',
if (token) { digits: 6,
const totp = new OTPAuth.TOTP({ period: 30,
issuer: '3x-ui', secret: token,
label: 'Administrator', });
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: token,
});
totpRef.current = totp;
setQrValue(totp.toString());
}
}, [open, token]); }, [open, token]);
const qrValue = totp ? totp.toString() : '';
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setEnteredCode('');
}
function close(success: boolean, code = '') { function close(success: boolean, code = '') {
onConfirm(success, code); onConfirm(success, code);
@@ -73,8 +70,8 @@ export default function TwoFactorModal({
close(true, codeOk.data); close(true, codeOk.data);
return; return;
} }
if (!totpRef.current) return; if (!totp) return;
if (totpRef.current.generate() === codeOk.data) { if (totp.generate() === codeOk.data) {
close(true); close(true);
} else { } else {
messageApi.error(t('pages.settings.security.twoFactorModalError')); messageApi.error(t('pages.settings.security.twoFactorModalError'));
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd'; import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd';
@@ -72,12 +72,15 @@ export default function BalancerFormModal({
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null; const isEdit = balancer != null;
useEffect(() => { const openBalancer = open ? (balancer ?? null) : undefined;
const [syncedBalancer, setSyncedBalancer] = useState<typeof openBalancer>(undefined);
if (openBalancer !== syncedBalancer) {
setSyncedBalancer(openBalancer);
if (open) { if (open) {
methods.reset(initialState(balancer)); methods.reset(initialState(balancer));
setSubmitAttempted(false); setSubmitAttempted(false);
} }
}, [open, balancer, methods]); }
const strategy = useWatch({ control: methods.control, name: 'strategy' }); const strategy = useWatch({ control: methods.control, name: 'strategy' });
const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? []; const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
@@ -190,7 +190,14 @@ export default function BalancersTab({
}, [liveTags]); }, [liveTags]);
useEffect(() => { useEffect(() => {
refreshLive(); let cancelled = false;
void (async () => {
await refreshLive();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [refreshLive]); }, [refreshLive]);
async function setOverride(tag: string, target: string) { async function setOverride(tag: string, target: string) {
@@ -128,7 +128,15 @@ export default function NordModal({
}, [fetchCountries]); }, [fetchCountries]);
useEffect(() => { useEffect(() => {
if (open) fetchData(); if (!open) return;
let cancelled = false;
void (async () => {
await fetchData();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [open, fetchData]); }, [open, fetchData]);
async function login() { async function login() {
@@ -174,12 +174,26 @@ export default function WarpModal({
} }
}, [methods]); }, [methods]);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setWarpConfig(null);
setStagedOutbound(null);
setLicenseError('');
}
}
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setWarpConfig(null); let cancelled = false;
setStagedOutbound(null); void (async () => {
setLicenseError(''); await fetchData();
fetchData(); if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [open, fetchData]); }, [open, fetchData]);
async function register() { async function register() {
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd'; import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
import { import {
@@ -68,7 +68,6 @@ export default function RoutingTab({
[templateSettings?.routing?.rules], [templateSettings?.routing?.rules],
); );
const rulesRef = useRef(rules); const rulesRef = useRef(rules);
rulesRef.current = rules;
const rowsRef = useRef<RuleRow[]>([]); const rowsRef = useRef<RuleRow[]>([]);
const rows: RuleRow[] = useMemo( const rows: RuleRow[] = useMemo(
@@ -100,7 +99,11 @@ export default function RoutingTab({
}), }),
[rules], [rules],
); );
rowsRef.current = rows;
useEffect(() => {
rulesRef.current = rules;
rowsRef.current = rows;
});
const mutate = useCallback( const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => { (mutator: (next: XraySettingsValue) => void) => {