mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 03:47:15 +00:00
feat(frontend): TanStack Query + React Router migration & in-panel API docs (#4541)
* feat(frontend): introduce TanStack Query with status polling
Wires @tanstack/react-query into every entry and migrates useStatus to
useStatusQuery as the foundation for the multi-page MPA → SPA migration.
- QueryProvider wraps each entry inside ThemeProvider, with devtools gated
on import.meta.env.DEV
- Shared queryClient: 30s staleTime, refetchOnWindowFocus, 1 retry
- useStatusQuery preserves the { status, fetched, refresh } shape so
IndexPage swaps in without further changes
- refetchIntervalInBackground:false stops the 2s status poll when the
panel tab is hidden, cutting idle traffic against the server
* feat(frontend): collapse panel pages into a single React Router SPA
Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/
xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The
Go backend now serves the same index.html for every authenticated
panel route; React Router reads the URL and mounts the page from cache
on subsequent navigation — no more full reloads between tabs.
Frontend
- main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider,
QueryProvider, RouterProvider) replacing 7 near-duplicate entries
- routes.tsx: declarative router with lazy()-loaded pages, basename
derived from window.X_UI_BASE_PATH so panels at /secret/panel work
- layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient
bridge so connection survives navigation
- api/websocketBridge.ts: subscribes the singleton WebSocketClient to
queryClient and dispatches invalidate/outbounds events to cached
queries (page-level useWebSocket handlers stay until Phase 3 hooks
migrate)
- AppSidebar: navigates via useNavigate + useLocation instead of
window.location.href; drops basePath/requestUri props
- Pages: drop the unused basePath/requestUri locals exposed only for
the old sidebar
Build
- vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy
bypass collapses /panel/* to index.html and skips API prefixes
- vendor-tanstack + vendor-router chunks added to manualChunks
Backend
- xui.go: 7 per-page HTML handlers → one panelSPA handler serving
index.html for /, /inbounds, /clients, /nodes, /settings, /xray,
/api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers
are untouched
* feat(frontend): migrate useNodes to TanStack Query
Splits the hand-rolled useNodes hook into useNodesQuery (server data +
NodeRecord type + derived totals) and useNodeMutations (add/update/del/
setEnable/probe/test). Mutations invalidate ['nodes'] on success, so
the list refreshes without each call awaiting a manual refresh().
NodesPage drops useWebSocket({ nodes: applyNodesEvent }) — the
WebSocket → query bridge now forwards the 'nodes' push to
setQueryData(['nodes', 'list']) once at the SPA root.
InboundsPage and the inbound form/list components import NodeRecord
from its new home next to the query hook.
* feat(frontend): migrate useAllSetting to TanStack Query
Replaces the hand-rolled fetch + dirty-tracking hook with useAllSettings
backed by useQuery + useMutation. The draft (current edits) is kept in
local state and reset whenever query.data lands. saveAll posts the
draft via a mutation; on success, invalidating ['settings'] refetches
and the useEffect resets the draft so saveDisabled flips back to true.
staleTime: Infinity prevents refetchOnWindowFocus from clobbering
in-flight edits — settings only change in response to this user's own
save.
setSpinning stays as a pass-through to a local flag so the existing
restartPanel flow in SettingsPage keeps showing its spinner.
* feat(frontend): route useInbounds fetches through TanStack Query
Rewrites useInbounds so its four server fetches (slim list, default
settings, online clients, last-online map) live in useQuery with
staleTime: Infinity. The in-place WS merge logic for traffic and
client_stats is preserved — applyTrafficEvent / applyClientStatsEvent
still mutate the locally-mirrored dbInbounds so the panel doesn't
refetch every 1-2 seconds when stats stream in.
refresh() becomes a thin invalidateQueries on the three list keys,
which mutations in the page already call after add/edit/del.
The bridge now forwards the WebSocket 'inbounds' push to
setQueryData(['inbounds', 'slim']), and InboundsPage drops its
useEffect(fetchDefaultSettings → refresh) plus the invalidate /
inbounds wiring on useWebSocket — both are owned by the bridge now.
* feat(frontend): migrate useClients to TanStack Query
Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.
The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.
WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.
ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.
* feat(frontend): route useXraySetting fetches through TanStack Query
Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:
- config + traffic → useQuery on ['xray', 'config'] and
['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
query
- restartXray → useMutation (fires the restart, then reads the
result string)
- resetToDefault → useMutation (fetch default config, push it into
the editor via setTemplateSettings)
The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.
A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.
* fix(frontend): restore per-route document titles in the SPA
When the multi-entry MPA collapsed into a single index.html, every
route inherited the static <title>3X-UI</title> from the shared shell,
so every panel page showed "hostname - 3X-UI" instead of the original
"hostname - Overview / Clients / Inbounds / ...".
usePageTitle reads the current pathname and rewrites document.title
on every navigation, matching the titles the deleted *.html files
used to carry. Mounted in PanelLayout so it covers all panel routes
without each page having to opt in.
The startup applyDocumentTitle() call in main.tsx is gone — the hook
sets the full "hostname - PageTitle" string itself.
* feat(api-docs): expose OpenAPI spec + render Swagger UI in panel
Replaces the hand-rolled API docs UI with industry-standard tooling so
external integrations (Postman, Insomnia, openapi-generator) can
consume the panel API without parsing endpoints.js by hand.
Generator
- frontend/scripts/build-openapi.mjs: walks the existing endpoints.js
(still the single source of truth) and emits an OpenAPI 3.0.3 spec
at frontend/public/openapi.json. Handles Gin :param → {param} path
translation, body / query / path parameter splits, 200 + error
response examples, and Bearer + cookie security schemes
- npm run build now runs gen:api before vite build, so the spec is
always in sync with what's documented
Backend
- web/controller/dist.go exposes ServeOpenAPISpec which streams the
embedded dist/openapi.json with a short Cache-Control. Public
endpoint (no auth) so Postman can fetch it without first logging in
- web/web.go wires GET /panel/api/openapi.json before the auth-gated
/panel/api router
Panel
- ApiDocsPage now renders swagger-ui-react fed by the basePath-aware
openapi.json URL. Dark mode is overridden via CSS targeting the
Swagger UI internals
- CodeBlock / EndpointRow / EndpointSection are gone; the swagger-ui
vendor chunk (134 KB gzipped) only loads on this lazy route, not on
every panel page
- vite.config: vendor-swagger manualChunk keeps the new dep out of
the main vendor bundle
For Postman: import http://<panel>/panel/api/openapi.json. Everything
from /login + /panel/api/* shows up with auth, params, and examples.
* style(api-docs): dark/ultra theme for Swagger UI
Override every visual surface Swagger does not theme on its own:
opblocks, tables, model boxes, form inputs, code blocks, modals,
Servers dropdown, per-endpoint padlocks and expand chevrons. Replaces
Swagger's default light-arrow chevron on selects with a light-fill SVG
positioned at the corner so the dark background-color is visible.
Also disables deepLinking to silence the noisy v4 underscore warning;
not used in our panel.
This commit is contained in:
@@ -60,7 +60,7 @@ import { DBInbound } from '@/models/dbinbound.js';
|
||||
import FinalMaskForm from '@/components/FinalMaskForm';
|
||||
import DateTimePicker from '@/components/DateTimePicker';
|
||||
import JsonEditor from '@/components/JsonEditor';
|
||||
import type { NodeRecord } from '@/hooks/useNodes';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import './InboundFormModal.css';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import InfinityIcon from '@/components/InfinityIcon';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { NodeRecord } from '@/hooks/useNodes';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import './InboundList.css';
|
||||
|
||||
type ProtocolFlags = {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { coerceInboundJsonField } from '@/models/dbinbound.js';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useNodes } from '@/hooks/useNodes';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import AppSidebar from '@/components/AppSidebar';
|
||||
import CustomStatistic from '@/components/CustomStatistic';
|
||||
const TextModal = lazy(() => import('@/components/TextModal'));
|
||||
@@ -74,20 +74,17 @@ export default function InboundsPage() {
|
||||
remarkModel,
|
||||
refresh,
|
||||
hydrateInbound,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
} = useInbounds();
|
||||
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const { nodes: nodesList } = useNodes();
|
||||
const { nodes: nodesList } = useNodesQuery();
|
||||
const nodesById = useMemo(() => {
|
||||
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
|
||||
const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
|
||||
for (const n of nodesList || []) map.set(n.id, n);
|
||||
return map;
|
||||
}, [nodesList]);
|
||||
@@ -105,15 +102,8 @@ export default function InboundsPage() {
|
||||
useWebSocket({
|
||||
traffic: applyTrafficEvent,
|
||||
client_stats: applyClientStatsEvent,
|
||||
invalidate: applyInvalidate,
|
||||
inbounds: applyInboundsEvent,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefaultSettings().then(() => refresh());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formDbInbound, setFormDbInbound] = useState<any>(null);
|
||||
@@ -449,15 +439,12 @@ export default function InboundsPage() {
|
||||
}
|
||||
}, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]);
|
||||
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
|
||||
<AppSidebar basePath={basePath} requestUri={requestUri} />
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
export interface SubSettings {
|
||||
enable: boolean;
|
||||
@@ -25,6 +27,27 @@ interface ClientRollup {
|
||||
comments: Map<string, string>;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
interface DefaultsPayload {
|
||||
expireDiff?: number;
|
||||
trafficDiff?: number;
|
||||
tgBotEnable?: boolean;
|
||||
subEnable?: boolean;
|
||||
subTitle?: string;
|
||||
subURI?: string;
|
||||
subJsonURI?: string;
|
||||
subJsonEnable?: boolean;
|
||||
pageSize?: number;
|
||||
remarkModel?: string;
|
||||
datepicker?: string;
|
||||
ipLimitEnable?: boolean;
|
||||
}
|
||||
|
||||
const TRACKED_PROTOCOLS = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
@@ -33,40 +56,98 @@ const TRACKED_PROTOCOLS = [
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
|
||||
async function fetchSlimInbounds(): Promise<unknown[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg<unknown[]>;
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds');
|
||||
return Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
|
||||
async function fetchOnlineClients(): Promise<string[]> {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg<string[]>;
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines');
|
||||
return Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
|
||||
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg<Record<string, number>>;
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
|
||||
return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {};
|
||||
}
|
||||
|
||||
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg<DefaultsPayload>;
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
||||
return (msg.obj as DefaultsPayload) || {};
|
||||
}
|
||||
|
||||
export function useInbounds() {
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const refreshingRef = useRef(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const slimQuery = useQuery({
|
||||
queryKey: keys.inbounds.slim(),
|
||||
queryFn: fetchSlimInbounds,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const onlinesQuery = useQuery({
|
||||
queryKey: keys.clients.onlines(),
|
||||
queryFn: fetchOnlineClients,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const lastOnlineQuery = useQuery({
|
||||
queryKey: keys.clients.lastOnline(),
|
||||
queryFn: fetchLastOnlineMap,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const defaultsQuery = useQuery({
|
||||
queryKey: keys.settings.defaults(),
|
||||
queryFn: fetchDefaultSettings,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const defaults = defaultsQuery.data ?? {};
|
||||
const expireDiff = (defaults.expireDiff ?? 0) * 86400000;
|
||||
const trafficDiff = (defaults.trafficDiff ?? 0) * 1073741824;
|
||||
const tgBotEnable = !!defaults.tgBotEnable;
|
||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||
const pageSize = defaults.pageSize ?? 0;
|
||||
const remarkModel = defaults.remarkModel || '-ieo';
|
||||
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
|
||||
|
||||
const subSettings: SubSettings = useMemo(() => ({
|
||||
enable: !!defaults.subEnable,
|
||||
subTitle: defaults.subTitle || '',
|
||||
subURI: defaults.subURI || '',
|
||||
subJsonURI: defaults.subJsonURI || '',
|
||||
subJsonEnable: !!defaults.subJsonEnable,
|
||||
}), [defaults.subEnable, defaults.subTitle, defaults.subURI, defaults.subJsonURI, defaults.subJsonEnable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaults.datepicker) setDatepicker(datepicker);
|
||||
}, [datepicker, defaults.datepicker]);
|
||||
|
||||
const expireDiffRef = useRef(expireDiff);
|
||||
expireDiffRef.current = expireDiff;
|
||||
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 dbInboundsRef = useRef<DBInboundInstance[]>([]);
|
||||
dbInboundsRef.current = dbInbounds;
|
||||
|
||||
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
|
||||
const [statsVersion, setStatsVersion] = useState(0);
|
||||
|
||||
const [onlineClients, setOnlineClients] = useState<string[]>([]);
|
||||
const onlineClientsRef = useRef<string[]>([]);
|
||||
onlineClientsRef.current = onlineClients;
|
||||
|
||||
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
|
||||
const [statsVersion, setStatsVersion] = useState(0);
|
||||
|
||||
const [expireDiff, setExpireDiff] = useState(0);
|
||||
const expireDiffRef = useRef(0);
|
||||
expireDiffRef.current = expireDiff;
|
||||
const [trafficDiff, setTrafficDiff] = useState(0);
|
||||
const trafficDiffRef = useRef(0);
|
||||
trafficDiffRef.current = trafficDiff;
|
||||
|
||||
const [subSettings, setSubSettings] = useState<SubSettings>({
|
||||
enable: false,
|
||||
subTitle: '',
|
||||
subURI: '',
|
||||
subJsonURI: '',
|
||||
subJsonEnable: false,
|
||||
});
|
||||
const [remarkModel, setRemarkModel] = useState('-ieo');
|
||||
const [datepicker, setDatepickerState] = useState('gregorian');
|
||||
const [tgBotEnable, setTgBotEnable] = useState(false);
|
||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(0);
|
||||
|
||||
const rollupClients = useCallback(
|
||||
(dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
|
||||
@@ -130,27 +211,6 @@ export function useInbounds() {
|
||||
[],
|
||||
);
|
||||
|
||||
const setInbounds = useCallback(
|
||||
(rows: unknown[]) => {
|
||||
const next: DBInboundInstance[] = [];
|
||||
const counts: Record<number, ClientRollup> = {};
|
||||
for (const row of rows as { protocol: string; id: number }[]) {
|
||||
const dbInbound = new DBInbound(row) as DBInboundInstance;
|
||||
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
|
||||
next.push(dbInbound);
|
||||
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
|
||||
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
|
||||
}
|
||||
}
|
||||
dbInboundsRef.current = next;
|
||||
setDbInbounds(next);
|
||||
setClientCount(counts);
|
||||
setFetched(true);
|
||||
},
|
||||
[rollupClients],
|
||||
);
|
||||
|
||||
const rebuildClientCount = useCallback(() => {
|
||||
const counts: Record<number, ClientRollup> = {};
|
||||
for (const dbInbound of dbInboundsRef.current) {
|
||||
@@ -164,57 +224,46 @@ export function useInbounds() {
|
||||
setClientCount(counts);
|
||||
}, [rollupClients]);
|
||||
|
||||
const fetchOnlineUsers = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/onlines');
|
||||
if (msg?.success) {
|
||||
const list = (msg.obj || []) as string[];
|
||||
onlineClientsRef.current = list;
|
||||
setOnlineClients(list);
|
||||
// Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
|
||||
// again every time the query refetches (e.g. invalidate from WS bridge).
|
||||
useEffect(() => {
|
||||
if (!slimQuery.data) return;
|
||||
const next: DBInboundInstance[] = [];
|
||||
const counts: Record<number, ClientRollup> = {};
|
||||
for (const row of slimQuery.data as { protocol: string; id: number }[]) {
|
||||
const dbInbound = new DBInbound(row) as DBInboundInstance;
|
||||
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
|
||||
next.push(dbInbound);
|
||||
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
|
||||
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
dbInboundsRef.current = next;
|
||||
setDbInbounds(next);
|
||||
setClientCount(counts);
|
||||
}, [slimQuery.data, rollupClients]);
|
||||
|
||||
const fetchLastOnlineMap = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
|
||||
if (msg?.success && msg.obj) {
|
||||
setLastOnlineMap(msg.obj as Record<string, number>);
|
||||
useEffect(() => {
|
||||
if (onlinesQuery.data) {
|
||||
onlineClientsRef.current = onlinesQuery.data;
|
||||
setOnlineClients(onlinesQuery.data);
|
||||
}
|
||||
}, []);
|
||||
}, [onlinesQuery.data]);
|
||||
|
||||
const fetchDefaultSettings = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (!msg?.success) return;
|
||||
const s = (msg.obj || {}) as Record<string, unknown>;
|
||||
setExpireDiff((s.expireDiff as number ?? 0) * 86400000);
|
||||
setTrafficDiff((s.trafficDiff as number ?? 0) * 1073741824);
|
||||
setTgBotEnable(!!s.tgBotEnable);
|
||||
setSubSettings({
|
||||
enable: !!s.subEnable,
|
||||
subTitle: (s.subTitle as string) || '',
|
||||
subURI: (s.subURI as string) || '',
|
||||
subJsonURI: (s.subJsonURI as string) || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
});
|
||||
setPageSize((s.pageSize as number) ?? 0);
|
||||
setRemarkModel((s.remarkModel as string) || '-ieo');
|
||||
const dp = ((s.datepicker as string) || 'gregorian') as 'gregorian' | 'jalalian';
|
||||
setDatepickerState(dp);
|
||||
setDatepicker(dp);
|
||||
setIpLimitEnable(!!s.ipLimitEnable);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
|
||||
}, [lastOnlineQuery.data]);
|
||||
|
||||
const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshingRef.current) return;
|
||||
refreshingRef.current = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list/slim');
|
||||
if (!msg?.success) return;
|
||||
await fetchLastOnlineMap();
|
||||
await fetchOnlineUsers();
|
||||
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
} finally {
|
||||
window.setTimeout(() => { refreshingRef.current = false; }, 500);
|
||||
}
|
||||
}, [fetchLastOnlineMap, fetchOnlineUsers, setInbounds]);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.slim() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
||||
]);
|
||||
}, [queryClient]);
|
||||
|
||||
// hydrateInbound fetches the full inbound (including settings.clients with
|
||||
// uuid/password/flow/etc.) and swaps it into the cached list. Use this
|
||||
@@ -313,25 +362,6 @@ export function useInbounds() {
|
||||
[rebuildClientCount],
|
||||
);
|
||||
|
||||
const applyInvalidate = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { type?: string };
|
||||
if (p.type === 'inbounds') {
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const applyInboundsEvent = useCallback(
|
||||
(payload: unknown) => {
|
||||
if (!Array.isArray(payload)) return;
|
||||
setInbounds(payload);
|
||||
},
|
||||
[setInbounds],
|
||||
);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
@@ -361,10 +391,7 @@ export function useInbounds() {
|
||||
pageSize,
|
||||
refresh,
|
||||
hydrateInbound,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user