mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
* perf(inbounds): slim list payload + lazy hydrate for row actions
Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.
Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.
* perf(clients): server-side pagination + slim row payload
Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates
on the server, returns a slim row shape (drops uuid/password/auth/flow/
security/reverse/tgId per client), and includes a stable summary
(total, active, online[], depleted[], expiring[], deactive[]) computed
across the full DB row set so the dashboard cards don't change as the
user paginates or filters. Page size capped at 200.
useClients now exposes { clients (current page), total, filtered, query,
setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page
state into setQuery via a single effect, debounces search by 300ms, and
hydrates the full client record via /get/:email before opening edit/info/
qr modals. Local filter/sort logic and the all-clients summary memo are
gone.
On a 2000-client panel this turns the initial response from ~MB to ~25 row
slice (~10s of KB) and removes the all-client parse cost from every
refresh.
* perf(settings): use /inbounds/options for LDAP tag picker
The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.
* perf(clients): de-duplicate options + paged list fetches
Two issues caused each clients-page load to fire its requests twice:
1. setQuery in the hook took whatever object the consumer passed and
stored it as-is. The consumer (ClientsPage) constructs a new object
literal in an effect, so even when nothing actually changed the ref
was new — the hook's useEffect saw a new query and re-fetched.
Wrapped setQuery with a shallow value compare so identical params
are a no-op.
2. The picker /inbounds/options fetch was bundled into refresh() with a
length==0 guard, but the two back-to-back refreshes both saw an
empty inbounds array (the first hadn't resolved yet) so both fired
the request. Moved the options fetch into its own one-shot effect.
* perf(inbounds): share nodes list with form modal instead of refetching
InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.
Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.
* docs(api): register /clients/list/paged endpoint
TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
573 lines
20 KiB
TypeScript
573 lines
20 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Card,
|
|
Col,
|
|
ConfigProvider,
|
|
Layout,
|
|
Modal,
|
|
Row,
|
|
Spin,
|
|
message,
|
|
} from 'antd';
|
|
|
|
import { setMessageInstance } from '@/utils/messageBus';
|
|
import {
|
|
SwapOutlined,
|
|
PieChartOutlined,
|
|
BarsOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
|
import { Inbound } from '@/models/inbound.js';
|
|
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 AppSidebar from '@/components/AppSidebar';
|
|
import CustomStatistic from '@/components/CustomStatistic';
|
|
import TextModal from '@/components/TextModal';
|
|
import PromptModal from '@/components/PromptModal';
|
|
|
|
import { useInbounds } from './useInbounds';
|
|
import InboundList from './InboundList';
|
|
import InboundFormModal from './InboundFormModal';
|
|
import InboundInfoModal from './InboundInfoModal';
|
|
import QrCodeModal from './QrCodeModal';
|
|
import '@/styles/page-cards.css';
|
|
import './InboundsPage.css';
|
|
|
|
type RowAction =
|
|
| 'edit'
|
|
| 'showInfo'
|
|
| 'qrcode'
|
|
| 'export'
|
|
| 'subs'
|
|
| 'clipboard'
|
|
| 'delete'
|
|
| 'resetTraffic'
|
|
| 'clone';
|
|
|
|
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
|
|
|
export default function InboundsPage() {
|
|
const { t } = useTranslation();
|
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
|
const { isMobile } = useMediaQuery();
|
|
|
|
const {
|
|
fetched,
|
|
dbInbounds,
|
|
clientCount,
|
|
onlineClients,
|
|
lastOnlineMap,
|
|
totals,
|
|
expireDiff,
|
|
trafficDiff,
|
|
pageSize,
|
|
subSettings,
|
|
tgBotEnable,
|
|
ipLimitEnable,
|
|
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 nodesById = useMemo(() => {
|
|
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
|
|
for (const n of nodesList || []) map.set(n.id, n);
|
|
return map;
|
|
}, [nodesList]);
|
|
|
|
const hasActiveNode = useMemo(
|
|
() => (nodesList || []).some((n) => n.enable && n.status === 'online'),
|
|
[nodesList],
|
|
);
|
|
const hasNodeAttachedInbound = useMemo(
|
|
() => (dbInbounds || []).some((ib: any) => ib?.nodeId != null),
|
|
[dbInbounds],
|
|
);
|
|
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
|
|
|
|
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);
|
|
|
|
const [infoOpen, setInfoOpen] = useState(false);
|
|
const [infoDbInbound, setInfoDbInbound] = useState<any>(null);
|
|
const [infoClientIndex, setInfoClientIndex] = useState(0);
|
|
|
|
const [qrOpen, setQrOpen] = useState(false);
|
|
const [qrDbInbound, setQrDbInbound] = useState<any>(null);
|
|
|
|
const [textOpen, setTextOpen] = useState(false);
|
|
const [textTitle, setTextTitle] = useState('');
|
|
const [textContent, setTextContent] = useState('');
|
|
const [textFileName, setTextFileName] = useState('');
|
|
|
|
const [promptOpen, setPromptOpen] = useState(false);
|
|
const [promptTitle, setPromptTitle] = useState('');
|
|
const [promptOkText, setPromptOkText] = useState('OK');
|
|
const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
|
|
const [promptInitial, setPromptInitial] = useState('');
|
|
const [promptLoading, setPromptLoading] = useState(false);
|
|
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
|
|
|
|
const hostOverrideFor = useCallback((dbInbound: any) => {
|
|
if (!dbInbound || dbInbound.nodeId == null) return '';
|
|
return nodesById.get(dbInbound.nodeId)?.address || '';
|
|
}, [nodesById]);
|
|
|
|
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
|
|
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
|
|
|
|
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
|
|
setTextTitle(opts.title);
|
|
setTextContent(opts.content);
|
|
setTextFileName(opts.fileName || '');
|
|
setTextOpen(true);
|
|
}, []);
|
|
|
|
const openPrompt = useCallback((opts: {
|
|
title: string;
|
|
okText?: string;
|
|
type?: 'textarea' | 'input';
|
|
value?: string;
|
|
confirm: (value: string) => Promise<boolean | void> | boolean | void;
|
|
}) => {
|
|
setPromptTitle(opts.title);
|
|
setPromptOkText(opts.okText || 'OK');
|
|
setPromptType(opts.type || 'textarea');
|
|
setPromptInitial(opts.value || '');
|
|
setPromptHandler(() => opts.confirm);
|
|
setPromptOpen(true);
|
|
}, []);
|
|
|
|
const onPromptConfirm = useCallback(async (value: string) => {
|
|
if (!promptHandler) {
|
|
setPromptOpen(false);
|
|
return;
|
|
}
|
|
setPromptLoading(true);
|
|
try {
|
|
const ok = await promptHandler(value);
|
|
if (ok !== false) setPromptOpen(false);
|
|
} finally {
|
|
setPromptLoading(false);
|
|
}
|
|
}, [promptHandler]);
|
|
|
|
const projectChildThroughMaster = useCallback((child: any, master: any) => {
|
|
const projected = JSON.parse(JSON.stringify(child));
|
|
projected.listen = master.listen;
|
|
projected.port = master.port;
|
|
const masterStream = master.toInbound().stream;
|
|
const childInbound = child.toInbound();
|
|
childInbound.stream.security = masterStream.security;
|
|
childInbound.stream.tls = masterStream.tls;
|
|
childInbound.stream.reality = masterStream.reality;
|
|
childInbound.stream.externalProxy = masterStream.externalProxy;
|
|
projected.streamSettings = childInbound.stream.toString();
|
|
return new child.constructor(projected);
|
|
}, []);
|
|
|
|
const checkFallback = useCallback((dbInbound: any) => {
|
|
const parent = dbInbound?.fallbackParent;
|
|
if (parent?.masterId) {
|
|
const master = (dbInbounds as any[]).find((ib: any) => ib.id === parent.masterId);
|
|
if (master) return projectChildThroughMaster(dbInbound, master);
|
|
}
|
|
if (!(dbInbound?.listen as string | undefined)?.startsWith?.('@')) return dbInbound;
|
|
for (const candidate of dbInbounds as any[]) {
|
|
if (candidate.id === dbInbound.id) continue;
|
|
const parsed = candidate.toInbound();
|
|
if (!parsed.isTcp) continue;
|
|
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
|
const fallbacks = parsed.settings.fallbacks || [];
|
|
if (!fallbacks.find((f: { dest?: string }) => f.dest === dbInbound.listen)) continue;
|
|
return projectChildThroughMaster(dbInbound, candidate);
|
|
}
|
|
return dbInbound;
|
|
}, [dbInbounds, projectChildThroughMaster]);
|
|
|
|
const findClientIndex = useCallback((dbInbound: any, client: any) => {
|
|
if (!client) return 0;
|
|
const inbound = dbInbound.toInbound();
|
|
const clients = inbound?.clients || [];
|
|
const idx = clients.findIndex((c: any) => {
|
|
if (!c) return false;
|
|
switch (dbInbound.protocol) {
|
|
case 'trojan':
|
|
case 'shadowsocks':
|
|
return c.password === client.password && c.email === client.email;
|
|
default:
|
|
return c.id === client.id && c.email === client.email;
|
|
}
|
|
});
|
|
return idx >= 0 ? idx : 0;
|
|
}, []);
|
|
|
|
const exportInboundLinks = useCallback((dbInbound: any) => {
|
|
const projected = checkFallback(dbInbound);
|
|
openText({
|
|
title: 'Export inbound links',
|
|
content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)),
|
|
fileName: projected.remark || 'inbound',
|
|
});
|
|
}, [checkFallback, remarkModel, hostOverrideFor, openText]);
|
|
|
|
const exportInboundClipboard = useCallback((dbInbound: any) => {
|
|
openText({ title: 'Inbound JSON', content: JSON.stringify(dbInbound, null, 2) });
|
|
}, [openText]);
|
|
|
|
const exportInboundSubs = useCallback((dbInbound: any) => {
|
|
const inbound = dbInbound.toInbound();
|
|
const clients = inbound?.clients || [];
|
|
const subLinks: string[] = [];
|
|
for (const c of clients) {
|
|
if (c.subId && subSettings.subURI) {
|
|
subLinks.push(subSettings.subURI + c.subId);
|
|
}
|
|
}
|
|
openText({
|
|
title: 'Export subscription links',
|
|
content: [...new Set(subLinks)].join('\n'),
|
|
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
|
|
});
|
|
}, [subSettings, openText]);
|
|
|
|
const exportAllLinks = useCallback(async () => {
|
|
const hydrated = await Promise.all(
|
|
(dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
|
);
|
|
const out: string[] = [];
|
|
for (const ib of hydrated) {
|
|
const projected = checkFallback(ib);
|
|
out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib)));
|
|
}
|
|
openText({ title: 'Export all inbound links', content: out.join('\r\n'), fileName: 'All-Inbounds' });
|
|
}, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, openText]);
|
|
|
|
const exportAllSubs = useCallback(async () => {
|
|
const hydrated = await Promise.all(
|
|
(dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
|
);
|
|
const out: string[] = [];
|
|
for (const ib of hydrated) {
|
|
const inbound = ib.toInbound();
|
|
const clients = inbound?.clients || [];
|
|
for (const c of clients) {
|
|
if (c.subId && subSettings.subURI) {
|
|
out.push(subSettings.subURI + c.subId);
|
|
}
|
|
}
|
|
}
|
|
openText({ title: 'Export all subscription links', content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' });
|
|
}, [dbInbounds, hydrateInbound, subSettings, openText]);
|
|
|
|
const importInbound = useCallback(() => {
|
|
openPrompt({
|
|
title: 'Import inbound',
|
|
okText: 'Import',
|
|
type: 'textarea',
|
|
value: '',
|
|
confirm: async (value) => {
|
|
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
|
if (msg?.success) {
|
|
await refresh();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
});
|
|
}, [openPrompt, refresh]);
|
|
|
|
const onAddInbound = useCallback(() => {
|
|
setFormMode('add');
|
|
setFormDbInbound(null);
|
|
setFormOpen(true);
|
|
}, []);
|
|
|
|
const openEdit = useCallback((dbInbound: any) => {
|
|
setFormMode('edit');
|
|
setFormDbInbound(dbInbound);
|
|
setFormOpen(true);
|
|
}, []);
|
|
|
|
const confirmDelete = useCallback((dbInbound: any) => {
|
|
modal.confirm({
|
|
title: `Delete inbound "${dbInbound.remark}"?`,
|
|
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
|
okText: 'Delete',
|
|
okType: 'danger',
|
|
cancelText: 'Cancel',
|
|
onOk: async () => {
|
|
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
|
|
if (msg?.success) await refresh();
|
|
},
|
|
});
|
|
}, [modal, refresh]);
|
|
|
|
const confirmResetTraffic = useCallback((dbInbound: any) => {
|
|
modal.confirm({
|
|
title: `Reset traffic for "${dbInbound.remark}"?`,
|
|
content: 'Resets up/down counters to 0 for this inbound.',
|
|
okText: 'Reset',
|
|
cancelText: 'Cancel',
|
|
onOk: async () => {
|
|
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
|
|
if (msg?.success) await refresh();
|
|
},
|
|
});
|
|
}, [modal, refresh]);
|
|
|
|
const confirmClone = useCallback((dbInbound: any) => {
|
|
modal.confirm({
|
|
title: `Clone inbound "${dbInbound.remark}"?`,
|
|
content: 'Creates a copy with a new port and an empty client list.',
|
|
okText: 'Clone',
|
|
cancelText: 'Cancel',
|
|
onOk: async () => {
|
|
const baseInbound = dbInbound.toInbound();
|
|
let clonedSettings: string;
|
|
try {
|
|
const raw = coerceInboundJsonField(dbInbound.settings);
|
|
raw.clients = [];
|
|
clonedSettings = JSON.stringify(raw);
|
|
} catch {
|
|
clonedSettings = (Inbound as any).Settings.getSettings(baseInbound.protocol).toString();
|
|
}
|
|
const data = {
|
|
up: 0,
|
|
down: 0,
|
|
total: 0,
|
|
remark: `${dbInbound.remark} (clone)`,
|
|
enable: false,
|
|
expiryTime: 0,
|
|
listen: '',
|
|
port: RandomUtil.randomInteger(10000, 60000),
|
|
protocol: baseInbound.protocol,
|
|
settings: clonedSettings,
|
|
streamSettings: baseInbound.stream.toString(),
|
|
sniffing: baseInbound.sniffing.toString(),
|
|
};
|
|
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
|
if (msg?.success) await refresh();
|
|
},
|
|
});
|
|
}, [modal, refresh]);
|
|
|
|
const onGeneralAction = useCallback((key: GeneralAction) => {
|
|
switch (key) {
|
|
case 'import': importInbound(); break;
|
|
case 'export': exportAllLinks(); break;
|
|
case 'subs': exportAllSubs(); break;
|
|
case 'resetInbounds':
|
|
modal.confirm({
|
|
title: 'Reset all inbound traffic?',
|
|
okText: 'Reset',
|
|
cancelText: 'Cancel',
|
|
onOk: async () => {
|
|
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
|
|
if (msg?.success) await refresh();
|
|
},
|
|
});
|
|
break;
|
|
default:
|
|
messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
|
|
}
|
|
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
|
|
|
|
const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
|
|
// Actions that touch per-client secrets (uuid, password, flow, ...) need
|
|
// the full payload that the slim list view does not ship. Hydrate first
|
|
// and then operate on the rehydrated record.
|
|
const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone'];
|
|
let target = dbInbound;
|
|
if (hydratingKeys.includes(key)) {
|
|
const hydrated = await hydrateInbound(dbInbound.id);
|
|
if (hydrated) target = hydrated;
|
|
}
|
|
switch (key) {
|
|
case 'edit':
|
|
openEdit(target);
|
|
break;
|
|
case 'showInfo':
|
|
setInfoDbInbound(checkFallback(target));
|
|
setInfoClientIndex(findClientIndex(target, null));
|
|
setInfoOpen(true);
|
|
break;
|
|
case 'qrcode':
|
|
setQrDbInbound(checkFallback(target));
|
|
setQrOpen(true);
|
|
break;
|
|
case 'export':
|
|
exportInboundLinks(target);
|
|
break;
|
|
case 'subs':
|
|
exportInboundSubs(target);
|
|
break;
|
|
case 'clipboard':
|
|
exportInboundClipboard(target);
|
|
break;
|
|
case 'delete':
|
|
confirmDelete(target);
|
|
break;
|
|
case 'resetTraffic':
|
|
confirmResetTraffic(target);
|
|
break;
|
|
case 'clone':
|
|
confirmClone(target);
|
|
break;
|
|
default:
|
|
messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
|
|
}
|
|
}, [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} />
|
|
|
|
<Layout className="content-shell">
|
|
<Layout.Content id="content-layout" className="content-area">
|
|
<Spin spinning={!fetched} delay={200} description="Loading…" size="large">
|
|
{!fetched ? (
|
|
<div className="loading-spacer" />
|
|
) : (
|
|
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
|
<Col span={24}>
|
|
<Card size="small" hoverable className="summary-card">
|
|
<Row gutter={[16, 12]}>
|
|
<Col xs={12} sm={12} md={8}>
|
|
<CustomStatistic
|
|
title={t('pages.inbounds.totalDownUp')}
|
|
value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
|
|
prefix={<SwapOutlined />}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={12} md={8}>
|
|
<CustomStatistic
|
|
title={t('pages.inbounds.totalUsage')}
|
|
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
|
|
prefix={<PieChartOutlined />}
|
|
/>
|
|
</Col>
|
|
<Col xs={24} sm={24} md={8}>
|
|
<CustomStatistic
|
|
title={t('pages.inbounds.inboundCount')}
|
|
value={String(dbInbounds.length)}
|
|
prefix={<BarsOutlined />}
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col span={24}>
|
|
<InboundList
|
|
dbInbounds={dbInbounds as any}
|
|
clientCount={clientCount}
|
|
onlineClients={onlineClients}
|
|
lastOnlineMap={lastOnlineMap}
|
|
expireDiff={expireDiff}
|
|
trafficDiff={trafficDiff}
|
|
pageSize={pageSize}
|
|
isMobile={isMobile}
|
|
subEnable={subSettings.enable}
|
|
nodesById={nodesById}
|
|
hasActiveNode={showNodeInfo}
|
|
onAddInbound={onAddInbound}
|
|
onGeneralAction={onGeneralAction}
|
|
onRowAction={onRowAction}
|
|
/>
|
|
</Col>
|
|
</Row>
|
|
)}
|
|
</Spin>
|
|
</Layout.Content>
|
|
</Layout>
|
|
|
|
<InboundFormModal
|
|
open={formOpen}
|
|
onClose={() => setFormOpen(false)}
|
|
onSaved={refresh}
|
|
mode={formMode}
|
|
dbInbound={formDbInbound}
|
|
dbInbounds={dbInbounds as any[]}
|
|
availableNodes={nodesList}
|
|
/>
|
|
<InboundInfoModal
|
|
open={infoOpen}
|
|
onClose={() => setInfoOpen(false)}
|
|
dbInbound={infoDbInbound}
|
|
clientIndex={infoClientIndex}
|
|
remarkModel={remarkModel}
|
|
expireDiff={expireDiff}
|
|
trafficDiff={trafficDiff}
|
|
ipLimitEnable={ipLimitEnable}
|
|
tgBotEnable={tgBotEnable}
|
|
subSettings={subSettings}
|
|
lastOnlineMap={lastOnlineMap}
|
|
nodeAddress={infoNodeAddress}
|
|
/>
|
|
<QrCodeModal
|
|
open={qrOpen}
|
|
onClose={() => setQrOpen(false)}
|
|
dbInbound={qrDbInbound}
|
|
client={null}
|
|
remarkModel={remarkModel}
|
|
nodeAddress={qrNodeAddress}
|
|
subSettings={subSettings}
|
|
/>
|
|
|
|
<TextModal
|
|
open={textOpen}
|
|
onClose={() => setTextOpen(false)}
|
|
title={textTitle}
|
|
content={textContent}
|
|
fileName={textFileName}
|
|
/>
|
|
<PromptModal
|
|
open={promptOpen}
|
|
onClose={() => setPromptOpen(false)}
|
|
title={promptTitle}
|
|
okText={promptOkText}
|
|
type={promptType}
|
|
initialValue={promptInitial}
|
|
loading={promptLoading}
|
|
onConfirm={onPromptConfirm}
|
|
/>
|
|
</Layout>
|
|
</ConfigProvider>
|
|
);
|
|
}
|