| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576 |
- import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import {
- Card,
- Col,
- ConfigProvider,
- Layout,
- Modal,
- Row,
- Spin,
- Statistic,
- message,
- } from 'antd';
- import { setMessageInstance } from '@/utils/messageBus';
- import {
- SwapOutlined,
- PieChartOutlined,
- BarsOutlined,
- } from '@ant-design/icons';
- import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
- import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
- 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 AppSidebar from '@/components/AppSidebar';
- const TextModal = lazy(() => import('@/components/TextModal'));
- const PromptModal = lazy(() => import('@/components/PromptModal'));
- import { useInbounds } from './useInbounds';
- import InboundList from './InboundList';
- import LazyMount from '@/components/LazyMount';
- const InboundFormModal = lazy(() => import('./InboundFormModal'));
- const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
- const QrCodeModal = lazy(() => import('./QrCodeModal'));
- type RowAction =
- | 'edit'
- | 'showInfo'
- | 'qrcode'
- | 'export'
- | 'subs'
- | 'clipboard'
- | 'delete'
- | 'resetTraffic'
- | '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,
- dbInbounds,
- clientCount,
- onlineClients,
- lastOnlineMap,
- totals,
- expireDiff,
- trafficDiff,
- pageSize,
- subSettings,
- tgBotEnable,
- ipLimitEnable,
- remarkModel,
- refresh,
- hydrateInbound,
- applyTrafficEvent,
- applyClientStatsEvent,
- } = useInbounds();
- const [modal, modalContextHolder] = Modal.useModal();
- const [messageApi, messageContextHolder] = message.useMessage();
- useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
- const { nodes: nodesList } = useNodesQuery();
- 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;
- 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 [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: 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 }) => {
- 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: DBInbound, master: DBInbound): DBInbound => {
- const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
- 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();
- 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;
- 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: DBInbound, client: ClientMatchTarget | null) => {
- if (!client) return 0;
- const inbound = dbInbound.toInbound();
- const clients = (inbound?.clients || []) as ClientMatchTarget[];
- 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);
- openText({
- title: t('pages.inbounds.exportLinksTitle'),
- content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)),
- fileName: projected.remark || 'inbound',
- });
- }, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
- const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
- openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
- }, [openText, t]);
- const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
- const inbound = dbInbound.toInbound();
- const clients = (inbound?.clients || []) as { subId?: string }[];
- 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 hydrated = await Promise.all(
- dbInbounds.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: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: 'All-Inbounds' });
- }, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, 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 inbound = ib.toInbound();
- const clients = (inbound?.clients || []) as { subId?: string }[];
- 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: 'All-Inbounds-Subs' });
- }, [dbInbounds, hydrateInbound, subSettings, openText, t]);
- 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: 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 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 confirmClone = useCallback((dbInbound: DBInbound) => {
- 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 baseInbound = dbInbound.toInbound();
- let clonedSettings: string;
- try {
- const raw = coerceInboundJsonField(dbInbound.settings);
- raw.clients = [];
- clonedSettings = JSON.stringify(raw);
- } catch {
- const fallback = createDefaultInboundSettings(baseInbound.protocol);
- clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
- }
- 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, 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: '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: 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'];
- 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]);
- 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} 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}>
- <Statistic
- title={t('pages.inbounds.totalDownUp')}
- value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
- prefix={<SwapOutlined />}
- />
- </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}
- 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 })}
- />
- </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}
- />
- </LazyMount>
- <LazyMount when={infoOpen}>
- <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}
- />
- </LazyMount>
- <LazyMount when={qrOpen}>
- <QrCodeModal
- open={qrOpen}
- onClose={() => setQrOpen(false)}
- dbInbound={qrDbInbound}
- client={null}
- remarkModel={remarkModel}
- nodeAddress={qrNodeAddress}
- subSettings={subSettings}
- />
- </LazyMount>
- <LazyMount when={textOpen}>
- <TextModal
- open={textOpen}
- onClose={() => setTextOpen(false)}
- title={textTitle}
- content={textContent}
- fileName={textFileName}
- />
- </LazyMount>
- <LazyMount when={promptOpen}>
- <PromptModal
- open={promptOpen}
- onClose={() => setPromptOpen(false)}
- title={promptTitle}
- okText={promptOkText}
- type={promptType}
- initialValue={promptInitial}
- loading={promptLoading}
- onConfirm={onPromptConfirm}
- />
- </LazyMount>
- </Layout>
- </ConfigProvider>
- );
- }
|