mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
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:
Generated
+538
-600
File diff suppressed because it is too large
Load Diff
+15
-14
@@ -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, initialValue, type]);
|
}, [open, 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) {
|
||||||
|
setWasOpen(open);
|
||||||
|
if (open) {
|
||||||
|
setKnown(new Set());
|
||||||
|
setSeededFiles(new Set());
|
||||||
setCategoryQuery('');
|
setCategoryQuery('');
|
||||||
setEntryQuery('');
|
setEntryQuery('');
|
||||||
setEntryFilter('');
|
setEntryFilter('');
|
||||||
setActiveCode(undefined);
|
setActiveCode(undefined);
|
||||||
setEntryPage(1);
|
setEntryPage(1);
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
}, [open]);
|
}
|
||||||
|
} else if (open && !file && files.length > 0) {
|
||||||
useEffect(() => {
|
|
||||||
if (!open || file || files.length === 0) return;
|
|
||||||
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
useEffect(() => {
|
||||||
|
cfgRef.current = cfg;
|
||||||
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
useEffect(() => {
|
||||||
queryRef.current = query;
|
queryRef.current = query;
|
||||||
|
});
|
||||||
|
|
||||||
const applyTrafficEvent = useCallback(
|
const applyTrafficEvent = useCallback(
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
|
||||||
|
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
xraySettingRef.current = xraySetting;
|
xraySettingRef.current = xraySetting;
|
||||||
outboundTestUrlRef.current = outboundTestUrl;
|
outboundTestUrlRef.current = outboundTestUrl;
|
||||||
savedXraySettingRef.current = savedXraySetting;
|
savedXraySettingRef.current = savedXraySetting;
|
||||||
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
||||||
templateSettingsRef.current = templateSettings;
|
templateSettingsRef.current = templateSettings;
|
||||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// Adopt a fetched config during render, so the editor never paints one frame
|
||||||
if (!configQuery.data) return;
|
// of the previous config after a refetch. Local edits win over the refetch.
|
||||||
const obj = configQuery.data;
|
if (config && config !== syncedConfig) {
|
||||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
setSyncedConfig(config);
|
||||||
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
|
|
||||||
setInboundTags(obj.inboundTags || []);
|
|
||||||
setClientReverseTags(obj.clientReverseTags || []);
|
|
||||||
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
|
||||||
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
|
||||||
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);
|
||||||
|
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
|
||||||
setXraySettingState(pretty);
|
setXraySettingState(pretty);
|
||||||
setTemplateSettingsState(obj.xraySetting);
|
setTemplateSettingsState(config.xraySetting);
|
||||||
setSavedXraySetting(pretty);
|
setSavedXraySetting(pretty);
|
||||||
syncingRef.current = false;
|
|
||||||
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 : '';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange;
|
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);
|
||||||
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange;
|
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);
|
||||||
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange;
|
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);
|
||||||
|
if (open) {
|
||||||
methods.reset(EMPTY);
|
methods.reset(EMPTY);
|
||||||
setDelayedStart(false);
|
setDelayedStart(false);
|
||||||
}, [open, methods]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const flowCapableIds = useMemo(() => {
|
const flowCapableIds = useMemo(() => {
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
useEffect(() => {
|
// The reset runs during render so the effect only carries the request.
|
||||||
if (!open || !client?.subId) {
|
const openSubId = open ? (client?.subId ?? '') : '';
|
||||||
|
const [syncedSubId, setSyncedSubId] = useState(openSubId);
|
||||||
|
if (openSubId !== syncedSubId) {
|
||||||
|
setSyncedSubId(openSubId);
|
||||||
setLinks([]);
|
setLinks([]);
|
||||||
return;
|
setLoading(!!openSubId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !client?.subId) 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(firstKey ? [firstKey] : []);
|
||||||
}
|
}
|
||||||
setActiveKey(items.length > 0 ? [items[0].key] : []);
|
|
||||||
}, [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.
|
||||||
|
const [wasOpen, setWasOpen] = useState(false);
|
||||||
|
if (open !== wasOpen) {
|
||||||
|
setWasOpen(open);
|
||||||
|
if (open) {
|
||||||
setSelectedEmails([]);
|
setSelectedEmails([]);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
}, [open]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
const [wasOpen, setWasOpen] = useState(false);
|
||||||
|
if (open !== wasOpen) {
|
||||||
|
setWasOpen(open);
|
||||||
|
if (open) {
|
||||||
setSelectedEmails([]);
|
setSelectedEmails([]);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
}, [open]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 { 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);
|
||||||
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange;
|
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;
|
||||||
|
const [syncedSource, setSyncedSource] = useState(openSource);
|
||||||
|
if (openSource !== syncedSource) {
|
||||||
|
setSyncedSource(openSource);
|
||||||
|
if (openSource) {
|
||||||
|
const rows = readClientRows(openSource.settings);
|
||||||
setClientRows(rows);
|
setClientRows(rows);
|
||||||
setSelectedEmails(rows.map((r) => r.email));
|
setSelectedEmails(rows.map((r) => r.email));
|
||||||
setTargetIds([]);
|
setTargetIds([]);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
}, [open, source]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
useEffect(() => {
|
// Reset during render, not in an effect, so the first frame is already clean.
|
||||||
if (!open || !target) return;
|
const openTarget = open ? target : null;
|
||||||
let cancelled = false;
|
const [syncedTarget, setSyncedTarget] = useState(openTarget);
|
||||||
|
if (openTarget !== syncedTarget) {
|
||||||
|
setSyncedTarget(openTarget);
|
||||||
|
if (openTarget) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setGroupFilter(undefined);
|
setGroupFilter(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !target) return;
|
||||||
|
let cancelled = false;
|
||||||
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) {
|
||||||
|
setSyncedSource(openSource);
|
||||||
|
if (openSource) {
|
||||||
|
setClientRows(readClientRows(openSource.settings));
|
||||||
setSelectedEmails([]);
|
setSelectedEmails([]);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
}, [open, source]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
useEffect(() => {
|
||||||
scanRef.current = scanRealityCandidates;
|
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({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const runScan = useCallback(
|
||||||
if (!open) return;
|
(targets?: string) => {
|
||||||
|
setLoading(true);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
runScan();
|
void applyScan(targets);
|
||||||
}, [open, runScan]);
|
},
|
||||||
|
[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(() => {
|
||||||
|
if (open) void applyScan();
|
||||||
|
}, [open, applyScan]);
|
||||||
|
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
dbInboundsRef.current = dbInbounds;
|
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,8 +408,7 @@ 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}`);
|
||||||
@@ -444,14 +421,10 @@ export function useInbounds() {
|
|||||||
dbInboundsRef.current = next;
|
dbInboundsRef.current = next;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
rebuildClientCount();
|
|
||||||
return dbInbound;
|
return dbInbound;
|
||||||
},
|
}, []);
|
||||||
[rebuildClientCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyTrafficEvent = useCallback(
|
const applyTrafficEvent = 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 {
|
||||||
traffics?: TrafficDelta[];
|
traffics?: TrafficDelta[];
|
||||||
@@ -462,14 +435,13 @@ export function useInbounds() {
|
|||||||
lastOnlineMap?: Record<string, number>;
|
lastOnlineMap?: Record<string, number>;
|
||||||
};
|
};
|
||||||
if (Array.isArray(p.onlineClients)) {
|
if (Array.isArray(p.onlineClients)) {
|
||||||
onlineClientsRef.current = p.onlineClients;
|
|
||||||
setOnlineClients(p.onlineClients);
|
setOnlineClients(p.onlineClients);
|
||||||
}
|
}
|
||||||
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
||||||
onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
|
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
|
||||||
}
|
}
|
||||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||||
activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
|
setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
|
||||||
}
|
}
|
||||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||||
@@ -508,13 +480,9 @@ export function useInbounds() {
|
|||||||
};
|
};
|
||||||
if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
|
if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
|
||||||
if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
|
if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
|
||||||
rebuildClientCount();
|
}, []);
|
||||||
},
|
|
||||||
[rebuildClientCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyClientStatsEvent = 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 }[];
|
inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
|
||||||
@@ -527,34 +495,16 @@ export function useInbounds() {
|
|||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
let touched = false;
|
|
||||||
|
|
||||||
if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
|
|
||||||
const byId = new Map<
|
const byId = new Map<
|
||||||
number,
|
number,
|
||||||
{ id: number; up?: number; down?: number; total?: number; enable?: boolean }
|
{ id: number; up?: number; down?: number; total?: number; enable?: boolean }
|
||||||
>();
|
>();
|
||||||
|
if (Array.isArray(p.inbounds)) {
|
||||||
for (const row of p.inbounds) {
|
for (const row of p.inbounds) {
|
||||||
if (row && row.id != null) byId.set(row.id, row);
|
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,
|
||||||
{
|
{
|
||||||
@@ -566,49 +516,53 @@ export function useInbounds() {
|
|||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
if (Array.isArray(p.clients)) {
|
||||||
for (const row of p.clients) {
|
for (const row of p.clients) {
|
||||||
if (row && row.email) byEmail.set(row.email, row);
|
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 (byId.size === 0 && byEmail.size === 0) return;
|
||||||
|
|
||||||
if (touched) {
|
// Rows carrying an update are rebuilt rather than patched in place: the
|
||||||
setStatsVersion((v) => v + 1);
|
// derived clientCount only recomputes when a row's identity changes.
|
||||||
setDbInbounds((prev) => {
|
let touched = false;
|
||||||
const next = [...prev];
|
const next = dbInboundsRef.current.map((ib) => {
|
||||||
dbInboundsRef.current = next;
|
const upd = byId.get(ib.id);
|
||||||
return next;
|
const stats = Array.isArray(ib.clientStats) ? ib.clientStats : null;
|
||||||
});
|
let statsTouched = false;
|
||||||
rebuildClientCount();
|
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;
|
||||||
[rebuildClientCount],
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange;
|
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, 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,17 +32,10 @@ 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('');
|
|
||||||
totpRef.current = null;
|
|
||||||
setQrValue('');
|
|
||||||
if (token) {
|
|
||||||
const totp = new OTPAuth.TOTP({
|
|
||||||
issuer: '3x-ui',
|
issuer: '3x-ui',
|
||||||
label: 'Administrator',
|
label: 'Administrator',
|
||||||
algorithm: 'SHA1',
|
algorithm: 'SHA1',
|
||||||
@@ -50,10 +43,14 @@ export default function TwoFactorModal({
|
|||||||
period: 30,
|
period: 30,
|
||||||
secret: token,
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [wasOpen, setWasOpen] = useState(false);
|
||||||
if (!open) return;
|
if (open !== wasOpen) {
|
||||||
|
setWasOpen(open);
|
||||||
|
if (open) {
|
||||||
setWarpConfig(null);
|
setWarpConfig(null);
|
||||||
setStagedOutbound(null);
|
setStagedOutbound(null);
|
||||||
setLicenseError('');
|
setLicenseError('');
|
||||||
fetchData();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
await 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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rulesRef.current = rules;
|
||||||
rowsRef.current = rows;
|
rowsRef.current = rows;
|
||||||
|
});
|
||||||
|
|
||||||
const mutate = useCallback(
|
const mutate = useCallback(
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
(mutator: (next: XraySettingsValue) => void) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user