Files
3x-ui/frontend/src/pages/inbounds/InboundsPage.tsx
T
Amirmohammad Sadat Shokouhi 5a63d5d468 fix(mtproto): use hosts for public share links (#6369)
* fix(mtproto): use hosts for public share links

Generate MTProto subscription, client, copy, QR, and export links from managed Hosts so reverse-proxied public ports are advertised correctly. Migrate the redundant legacy custom share address into a Host and keep old imports compatible.

Closes #5126.

* fix(mtproto): keep host share links lossless and consistent

Address review on the MTProto hosts share-link change.

The migration no longer drops a legacy custom share address: an unrelated
(or disabled) Host stopped suppressing it, so only a Host already advertising
the same address does. An imported address now clears the same validation the
strict normalizer applies to every other protocol before it becomes a Host.

Panel and subscription agree on the endpoint a Host advertises: a portless host
string inherits the inbound port rather than the group's, and a port-only host
inherits the inbound address instead of emitting server=%3A8443.

LinksForClient prefers host endpoints for every protocol, the way getSubs and
inboundLinks already do, so the client-links API no longer ignores managed
hosts.

* fix(mtproto): migrate legacy share address past unusable hosts

The seeder skipped the conversion whenever any Host already carried the
address, including one that is disabled or excludes the raw sub type.
hostEndpoints drops those, so nothing advertised the address afterwards and
the marker committed with no way back. The duplicate check now mirrors that
same predicate.

UpdateInbound cleared a legacy MTProto shareAddr without the Host conversion
AddInbound runs, so re-applying an inbound definition through the API dropped
the public address silently. Both paths share one capture helper now.

Refresh the generated clients API reference for the summary reworded in the
previous commit.

* fix(inbounds): wait for the hosts list before building mtproto links

The page destructured only `hosts` from useHostsQuery, and that list reads
empty both while /panel/api/hosts/list is in flight and after it fails.
withMtprotoHostEndpoints then returns the inbound untouched, so Copy, QR and
Export advertise the internal listen port — the endpoint this branch exists to
replace. It is worse than not fixing it: the seeder has already moved a legacy
custom share address into a Host, so the fallback is the panel's own hostname
instead of the operator's address, and the Go generators reading the same rows
from the DB stay correct, so the two disagree for one inbound.

Fold the query into the page's existing readiness gate, the same way
useInbounds and HostsPage already consume that hook, so an empty list means
"no hosts" rather than "not loaded yet". The error branch fires only when
nothing is cached, so a refetch failing on window focus does not blank a page
whose host rows are still perfectly usable.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-08 15:36:57 +02:00

931 lines
30 KiB
TypeScript

import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
ConfigProvider,
Layout,
Modal,
Result,
Row,
Spin,
Statistic,
message,
} from 'antd';
import { setMessageInstance } from '@/utils/messageBus';
import {
ArrowUpOutlined,
ArrowDownOutlined,
PieChartOutlined,
BarsOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
import { buildClonePayload } from '@/lib/xray/inbound-clone';
import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
import {
genAmneziaWGLinks,
genInboundLinks,
genWireguardLinks,
preferPublicHost,
} from '@/lib/xray/inbound-link';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
import { Protocols } from '@/schemas/primitives';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useHostsQuery } from '@/api/queries/useHostsQuery';
import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
import AppSidebar from '@/layouts/AppSidebar';
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
import type { TextModalTab } from '@/components/feedback/TextModal';
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
import { useInbounds } from './useInbounds';
import { InboundList } from './list';
import { LazyMount } from '@/components/utility';
const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
const CloneInboundModal = lazy(() => import('./CloneInboundModal'));
const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
type RowAction =
| 'edit'
| 'showInfo'
| 'qrcode'
| 'export'
| 'subs'
| 'clipboard'
| 'delete'
| 'resetTraffic'
| 'delAllClients'
| 'attachClients'
| 'attachExisting'
| 'detachClients'
| 'addToGroup'
| 'clone';
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
interface ClientMatchTarget {
id?: string;
email?: string;
password?: string;
}
export default function InboundsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const {
fetched,
fetchError,
dbInbounds,
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
totals,
expireDiff,
trafficDiff,
pageSize,
subSettings,
tgBotEnable,
ipLimitEnable,
refresh,
hydrateInbound,
applyTrafficEvent,
applyClientStatsEvent,
} = useInbounds();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
// MTProto share links are generated from this list, so an empty one must mean
// "no hosts" and not "not loaded yet" — the gate below waits for it.
const {
hosts,
fetched: hostsFetched,
fetchError: hostsFetchError,
refetch: refetchHosts,
} = useHostsQuery();
// A background refetch that fails while rows are still cached is not fatal.
const hostsError = hosts.length > 0 ? '' : hostsFetchError;
const nodesById = useMemo(() => {
const map = new Map<number, ReturnType<typeof useNodesQuery>['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) => ib?.nodeId != null),
[dbInbounds],
);
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
// Ports already bound per clone target (0 = local panel, matching the
// clients page node-filter sentinel), for the clone dialog's client-side
// conflict pre-check.
const clonePortsInUse = useMemo(() => {
const map = new Map<number, Set<number>>();
for (const ib of dbInbounds || []) {
const key = ib.nodeId ?? 0;
const ports = map.get(key) ?? new Set<number>();
ports.add(ib.port);
map.set(key, ports);
}
return map;
}, [dbInbounds]);
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
});
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formDbInbound, setFormDbInbound] = useState<DBInbound | null>(null);
const [infoOpen, setInfoOpen] = useState(false);
const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
const [infoClientIndex, setInfoClientIndex] = useState(0);
const [qrOpen, setQrOpen] = useState(false);
const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
const [attachOpen, setAttachOpen] = useState(false);
const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
const [attachExistingOpen, setAttachExistingOpen] = useState(false);
const [attachExistingTarget, setAttachExistingTarget] = useState<DBInbound | null>(null);
const [detachOpen, setDetachOpen] = useState(false);
const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
const [groupOpen, setGroupOpen] = useState(false);
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
const [cloneOpen, setCloneOpen] = useState(false);
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
const [textOpen, setTextOpen] = useState(false);
const [textTitle, setTextTitle] = useState('');
const [textContent, setTextContent] = useState('');
const [textFileName, setTextFileName] = useState('');
const [textJson, setTextJson] = useState(false);
const [textTabs, setTextTabs] = useState<TextModalTab[] | undefined>(undefined);
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 [promptJson, setPromptJson] = useState(false);
const [promptLoading, setPromptLoading] = useState(false);
const [promptHandler, setPromptHandler] = useState<
((value: string) => Promise<boolean | void> | boolean | void) | null
>(null);
const hostOverrideFor = useCallback(
(dbInbound: DBInbound | null) => {
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;
json?: boolean;
tabs?: TextModalTab[];
}) => {
setTextTitle(opts.title);
setTextContent(opts.content);
setTextFileName(opts.fileName || '');
setTextJson(opts.json || false);
setTextTabs(opts.tabs);
setTextOpen(true);
},
[],
);
const openPrompt = useCallback(
(opts: {
title: string;
okText?: string;
type?: 'textarea' | 'input';
value?: string;
json?: boolean;
confirm: (value: string) => Promise<boolean | void> | boolean | void;
}) => {
setPromptTitle(opts.title);
setPromptOkText(opts.okText || t('confirm'));
setPromptType(opts.type || 'textarea');
setPromptInitial(opts.value || '');
setPromptJson(opts.json || false);
setPromptHandler(() => opts.confirm);
setPromptOpen(true);
},
[t],
);
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: DBInbound, master: DBInbound): DBInbound => {
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
projected.listen = master.listen;
projected.port = master.port;
const masterStream = coerceInboundJsonField(master.streamSettings) as Record<string, unknown>;
const childStream = {
...(coerceInboundJsonField(child.streamSettings) as Record<string, unknown>),
};
childStream.security = masterStream.security;
childStream.tlsSettings = masterStream.tlsSettings;
childStream.realitySettings = masterStream.realitySettings;
childStream.externalProxy = masterStream.externalProxy;
projected.streamSettings = JSON.stringify(childStream);
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
return new Ctor(projected);
},
[],
);
const checkFallback = useCallback(
(dbInbound: DBInbound): DBInbound => {
const parent = dbInbound?.fallbackParent;
if (parent?.masterId) {
const master = dbInbounds.find((ib) => ib.id === parent.masterId);
if (master) return projectChildThroughMaster(dbInbound, master);
}
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds) {
if (candidate.id === dbInbound.id) continue;
if (!['trojan', 'vless'].includes(candidate.protocol)) continue;
const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string };
if (candStream.network !== 'tcp') continue;
const candSettings = coerceInboundJsonField(candidate.settings) as {
fallbacks?: { dest?: string }[];
};
const fallbacks = candSettings.fallbacks || [];
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
return projectChildThroughMaster(dbInbound, candidate);
}
return dbInbound;
},
[dbInbounds, projectChildThroughMaster],
);
const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
if (!client) return 0;
const settings = coerceInboundJsonField(dbInbound.settings) as {
clients?: ClientMatchTarget[];
};
const clients = settings.clients || [];
const idx = clients.findIndex((c) => {
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: DBInbound) => {
const projected = checkFallback(dbInbound);
const hostOverride = hostOverrideFor(dbInbound);
const fallbackHostname = preferPublicHost(window.location.hostname, subSettings.publicHost);
const genInput = {
inbound: withMtprotoHostEndpoints(
inboundFromDb(projected),
dbInbound.id,
hosts,
hostOverride,
fallbackHostname,
),
remark: projected.remark,
hostOverride,
fallbackHostname,
};
const content = genInboundLinks(genInput);
const tabs: TextModalTab[] | undefined = projected.isWireguard
? [
{ key: 'config', label: t('pages.clients.config'), content },
{
key: 'links',
label: t('pages.clients.tabLinks'),
content: genWireguardLinks(genInput),
},
]
: projected.protocol === Protocols.AMNEZIAWG
? [
{ key: 'config', label: t('pages.clients.config'), content },
{
key: 'links',
label: t('pages.clients.tabLinks'),
content: genAmneziaWGLinks(genInput),
},
]
: undefined;
openText({
title: t('pages.inbounds.exportLinksTitle'),
content,
fileName: projected.remark || 'inbound',
tabs,
});
},
[checkFallback, hostOverrideFor, hosts, subSettings.publicHost, openText, t],
);
const exportInboundClipboard = useCallback(
(dbInbound: DBInbound) => {
openText({
title: t('pages.inbounds.inboundJsonTitle'),
content: JSON.stringify(dbInbound, null, 2),
json: true,
});
},
[openText, t],
);
const exportInboundSubs = useCallback(
(dbInbound: DBInbound) => {
const settings = coerceInboundJsonField(dbInbound.settings) as {
clients?: { subId?: string }[];
};
const clients = settings.clients || [];
const subLinks: string[] = [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
subLinks.push(subSettings.subURI + c.subId);
}
}
openText({
title: t('pages.inbounds.exportSubsTitle'),
content: [...new Set(subLinks)].join('\n'),
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
});
},
[subSettings, openText, t],
);
const exportAllLinks = useCallback(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/allLinks');
const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : [];
openText({
title: t('pages.inbounds.exportAllLinksTitle'),
content: links.join('\r\n'),
fileName: t('pages.inbounds.exportAllLinksFileName'),
});
}, [openText, t]);
const exportAllSubs = useCallback(async () => {
const hydrated = await Promise.all(
dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
);
const out: string[] = [];
for (const ib of hydrated) {
const settings = coerceInboundJsonField(ib.settings) as { clients?: { subId?: string }[] };
const clients = settings.clients || [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
out.push(subSettings.subURI + c.subId);
}
}
}
openText({
title: t('pages.inbounds.exportAllSubsTitle'),
content: [...new Set(out)].join('\r\n'),
fileName: t('pages.inbounds.exportAllSubsFileName'),
});
}, [dbInbounds, hydrateInbound, subSettings, openText, t]);
const importInbound = useCallback(() => {
openPrompt({
title: t('pages.inbounds.importInbound'),
okText: t('pages.inbounds.import'),
type: 'textarea',
value: '',
json: true,
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, t]);
const onAddInbound = useCallback(() => {
setFormMode('add');
setFormDbInbound(null);
setFormOpen(true);
}, []);
const openEdit = useCallback((dbInbound: DBInbound) => {
setFormMode('edit');
setFormDbInbound(dbInbound);
setFormOpen(true);
}, []);
const confirmDelete = useCallback(
(dbInbound: DBInbound) => {
modal.confirm({
title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
if (msg?.success) await refresh();
},
});
},
[modal, refresh, t],
);
const confirmBulkDelete = useCallback(
(ids: number[]) =>
new Promise<boolean>((resolve) => {
if (ids.length === 0) {
resolve(false);
return;
}
modal.confirm({
title: t('pages.inbounds.bulkDeleteConfirmTitle', { count: ids.length }),
content: t('pages.inbounds.bulkDeleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(
'/panel/api/inbounds/bulkDel',
{ ids },
{ headers: { 'Content-Type': 'application/json' } },
);
const obj = (msg?.obj ?? {}) as {
deleted?: number;
skipped?: { id: number; reason: string }[];
};
const ok = obj.deleted ?? 0;
const skipped = obj.skipped ?? [];
if (msg?.success && skipped.length === 0) {
messageApi.success(t('pages.inbounds.toasts.bulkDeleted', { count: ok }));
} else {
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
const base = t('pages.inbounds.toasts.bulkDeletedMixed', {
ok,
failed: skipped.length,
});
messageApi.warning(firstError ? `${base} — ${firstError}` : base);
}
await refresh();
resolve(true);
},
onCancel: () => resolve(false),
});
}),
[modal, refresh, t, messageApi],
);
const confirmResetTraffic = useCallback(
(dbInbound: DBInbound) => {
modal.confirm({
title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.resetConfirmContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
if (msg?.success) await refresh();
},
});
},
[modal, refresh, t],
);
const confirmDelAllClients = useCallback(
(dbInbound: DBInbound) => {
const count = clientCount[dbInbound.id]?.clients || 0;
modal.confirm({
title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }),
content: t('pages.inbounds.delAllClientsConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`);
if (msg?.success) await refresh();
},
});
},
[modal, refresh, t, clientCount],
);
const confirmClone = useCallback(
(dbInbound: DBInbound) => {
// Node-eligible protocol with at least one deployable node → open the
// target picker; anything else keeps the original one-click local clone.
if (
NODE_ELIGIBLE_PROTOCOLS[dbInbound.protocol] &&
(nodesList || []).some((n) => n.enable && n.status === 'online')
) {
setCloneSource(dbInbound);
setCloneOpen(true);
return;
}
modal.confirm({
title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.cloneConfirmContent'),
okText: t('pages.inbounds.clone'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(
'/panel/api/inbounds/add',
buildClonePayload(dbInbound, RandomUtil.randomInteger(10000, 60000), null),
);
if (msg?.success) await refresh();
},
});
},
[modal, nodesList, refresh, t],
);
const onGeneralAction = useCallback(
(key: GeneralAction) => {
switch (key) {
case 'import':
importInbound();
break;
case 'export':
exportAllLinks();
break;
case 'subs':
exportAllSubs();
break;
case 'resetInbounds':
modal.confirm({
title: t('pages.inbounds.resetAllTrafficTitle'),
okText: t('reset'),
cancelText: t('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, t],
);
const onRowAction = useCallback(
async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
// 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',
'attachClients',
'addToGroup',
];
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 'delAllClients':
confirmDelAllClients(target);
break;
case 'attachClients':
setAttachSource(target);
setAttachOpen(true);
break;
case 'attachExisting':
setAttachExistingTarget(target);
setAttachExistingOpen(true);
break;
case 'detachClients':
setDetachSource(target);
setDetachOpen(true);
break;
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
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,
confirmDelAllClients,
confirmClone,
messageApi,
],
);
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
spinning={!fetched || !hostsFetched}
delay={200}
description={t('loading')}
size="large"
>
{!fetched || !hostsFetched ? (
<div className="loading-spacer" />
) : fetchError || hostsError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError || hostsError}
extra={
<Button
type="primary"
onClick={() => {
void refresh();
void refetchHosts();
}}
>
{t('refresh')}
</Button>
}
/>
) : (
<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}>
<Statistic
title={t('pages.inbounds.totalDownUp')}
value={0}
formatter={() => (
<span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totals.up)}
{' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totals.down)}
</span>
)}
/>
</Col>
<Col xs={12} sm={12} md={8}>
<Statistic
title={t('pages.inbounds.totalUsage')}
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
prefix={<PieChartOutlined />}
/>
</Col>
<Col xs={24} sm={24} md={8}>
<Statistic
title={t('pages.inbounds.inboundCount')}
value={String(dbInbounds.length)}
prefix={<BarsOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<InboundList
dbInbounds={dbInbounds}
clientCount={clientCount}
onlineClients={onlineClients}
lastOnlineMap={lastOnlineMap}
inboundSpeed={inboundSpeed}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
pageSize={pageSize}
isMobile={isMobile}
subEnable={subSettings.enable}
nodesById={nodesById}
hasActiveNode={showNodeInfo}
onAddInbound={onAddInbound}
onGeneralAction={onGeneralAction}
onRowAction={({ key, dbInbound }) =>
onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })
}
onBulkDelete={confirmBulkDelete}
/>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<LazyMount when={formOpen}>
<InboundFormModal
open={formOpen}
onClose={() => setFormOpen(false)}
onSaved={refresh}
mode={formMode}
dbInbound={formDbInbound}
dbInbounds={dbInbounds}
availableNodes={nodesList}
availableNodesFetched={nodesFetched}
/>
</LazyMount>
<LazyMount when={infoOpen}>
<InboundInfoModal
open={infoOpen}
onClose={() => setInfoOpen(false)}
dbInbound={infoDbInbound}
clientIndex={infoClientIndex}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
ipLimitEnable={ipLimitEnable}
tgBotEnable={tgBotEnable}
subSettings={subSettings}
hosts={hosts}
lastOnlineMap={lastOnlineMap}
nodeAddress={infoNodeAddress}
/>
</LazyMount>
<LazyMount when={qrOpen}>
<QrCodeModal
open={qrOpen}
onClose={() => setQrOpen(false)}
dbInbound={qrDbInbound}
client={null}
nodeAddress={qrNodeAddress}
subSettings={subSettings}
hosts={hosts}
/>
</LazyMount>
<LazyMount when={attachOpen}>
<AttachClientsModal
open={attachOpen}
onClose={() => setAttachOpen(false)}
onAttached={refresh}
source={attachSource}
dbInbounds={dbInbounds}
/>
</LazyMount>
<LazyMount when={attachExistingOpen}>
<AttachExistingClientsModal
open={attachExistingOpen}
onClose={() => setAttachExistingOpen(false)}
onAttached={refresh}
target={attachExistingTarget}
/>
</LazyMount>
<LazyMount when={detachOpen}>
<DetachClientsModal
open={detachOpen}
onClose={() => setDetachOpen(false)}
onDetached={refresh}
source={detachSource}
/>
</LazyMount>
<LazyMount when={groupOpen}>
<AddClientsToGroupModal
open={groupOpen}
onClose={() => setGroupOpen(false)}
onAdded={refresh}
source={groupSource}
/>
</LazyMount>
<LazyMount when={cloneOpen}>
<CloneInboundModal
open={cloneOpen}
onClose={() => setCloneOpen(false)}
onCloned={refresh}
dbInbound={cloneSource}
nodes={nodesList || []}
portsInUse={clonePortsInUse}
/>
</LazyMount>
<LazyMount when={textOpen}>
<TextModal
open={textOpen}
onClose={() => setTextOpen(false)}
title={textTitle}
content={textContent}
fileName={textFileName}
json={textJson}
tabs={textTabs}
/>
</LazyMount>
<LazyMount when={promptOpen}>
<PromptModal
open={promptOpen}
onClose={() => setPromptOpen(false)}
title={promptTitle}
okText={promptOkText}
type={promptType}
initialValue={promptInitial}
loading={promptLoading}
json={promptJson}
onConfirm={onPromptConfirm}
/>
</LazyMount>
</Layout>
</ConfigProvider>
);
}