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:
Sanaei
2026-05-24 21:34:52 +02:00
committed by GitHub
parent 867a145979
commit cfe1b25ca0
60 changed files with 8724 additions and 2122 deletions
@@ -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;
+1 -1
View File
@@ -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 = {
+4 -17
View File
@@ -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">
+139 -112
View File
@@ -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,
};
}