refactor(frontend): break InboundList into helpers/types/RowActions/columns hook/stats modal

InboundList.tsx 781 -> 203 lines. Extracted pure helpers (network labels, sort fns, isInboundMultiUser), shared types, the row-actions menu/cell, the table columns hook, and the mobile stats modal into the list/ folder. Code moved verbatim; no behavior change. typecheck/lint/test/build green, 337 tests pass.
This commit is contained in:
MHSanaei 2026-05-30 15:28:14 +02:00
parent 1645664f03
commit a32fe94872
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 738 additions and 609 deletions

View file

@ -1,296 +1,34 @@
import { useCallback, useMemo, useState, type ReactElement } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Dropdown,
Modal,
Popover,
Space,
Switch,
Table,
Tag,
Tooltip,
type TableColumnType,
type MenuProps,
} from 'antd';
import {
PlusOutlined,
MenuOutlined,
MoreOutlined,
EditOutlined,
QrcodeOutlined,
CopyOutlined,
ExportOutlined,
ImportOutlined,
ReloadOutlined,
RetweetOutlined,
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
TagsOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { InfinityIcon } from '@/components/ui';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { coerceInboundJsonField } from '@/models/dbinbound';
import { HttpUtil } from '@/utils';
import { SORT_FNS } from './helpers';
import { buildRowActionsMenu } from './RowActions';
import { useInboundColumns } from './useInboundColumns';
import InboundStatsModal from './InboundStatsModal';
import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction, SortKey, SortOrder } from './types';
import './InboundList.css';
interface StreamHints {
network: string;
isTls: boolean;
isReality: boolean;
}
function readStreamHints(streamSettings: unknown): StreamHints {
const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
return {
network: stream.network ?? '',
isTls: stream.security === 'tls',
isReality: stream.security === 'reality',
};
}
// Display label for a network value. All known transports render in
// upper-case for visual consistency with the TCP/UDP/TLS/Reality tags
// already shown alongside; compound names (`httpupgrade`, `splithttp`,
// `xhttp`) get a tiny touch of casing so they don't read as one word.
function networkLabel(network: string): string {
const n = (network || '').toLowerCase();
if (!n) return 'TCP';
switch (n) {
case 'httpupgrade': return 'HTTPUpgrade';
case 'splithttp': return 'SplitHTTP';
case 'xhttp': return 'XHTTP';
}
return n.toUpperCase();
}
// Returns the underlying L4 protocol for transports whose name isn't
// already TCP/UDP. `kcp` and `quic` both ride on UDP; everything else
// (`ws`, `grpc`, `http`, `httpupgrade`, `xhttp`) is TCP-based and gets
// no extra tag (the transport name implies TCP).
function networkL4(network: string): 'UDP' | '' {
const n = (network || '').toLowerCase();
if (n === 'kcp' || n === 'quic') return 'UDP';
return '';
}
// Shadowsocks settings.network ("tcp" / "udp" / "tcp,udp") and Tunnel
// settings.allowedNetwork (same shape, different field name) both carry
// the L4 transport list independent of streamSettings. Returns a
// comma-separated label.
function commaNetworkLabel(raw: string): string {
const parts = (raw || 'tcp').toLowerCase().split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return 'TCP';
return parts.map(networkLabel).join(',');
}
function shadowsocksNetworkLabel(settings: unknown): string {
return commaNetworkLabel(readSettings(settings).network || '');
}
function tunnelNetworkLabel(settings: unknown): string {
return commaNetworkLabel(readSettings(settings).allowedNetwork || '');
}
// Mixed (socks+http combo) is always TCP at L4; settings.udp=true adds
// UDP-associate support on the same port (SOCKS5 UDP).
function mixedNetworkLabel(settings: unknown): string {
const st = coerceInboundJsonField(settings) as { udp?: boolean };
return st.udp ? 'TCP,UDP' : 'TCP';
}
function readSettings(settings: unknown): { method?: string; network?: string; allowedNetwork?: string } {
return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
}
export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
switch (record.protocol) {
case 'vmess':
case 'vless':
case 'trojan':
case 'hysteria':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
default:
return false;
}
}
type ProtocolFlags = {
isVMess?: boolean;
isVLess?: boolean;
isTrojan?: boolean;
isSS?: boolean;
isHysteria?: boolean;
isMixed?: boolean;
isHTTP?: boolean;
isWireguard?: boolean;
isTunnel?: boolean;
};
interface DBInboundRecord extends ProtocolFlags {
id: number;
enable: boolean;
remark: string;
port: number;
protocol: string;
up: number;
down: number;
total: number;
expiryTime: number;
_expiryTime: { valueOf(): number } | null;
nodeId?: number | null;
settings: unknown;
streamSettings: unknown;
}
export interface ClientCountEntry {
clients: number;
active: string[];
deactive: string[];
depleted: string[];
expiring: string[];
online: string[];
}
export type RowAction =
| 'edit'
| 'showInfo'
| 'qrcode'
| 'export'
| 'subs'
| 'clipboard'
| 'delete'
| 'resetTraffic'
| 'delAllClients'
| 'clone';
export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
interface InboundListProps {
dbInbounds: DBInboundRecord[];
clientCount: Record<number, ClientCountEntry>;
onlineClients: string[];
lastOnlineMap: Record<string, number>;
expireDiff: number;
trafficDiff: number;
pageSize: number;
isMobile: boolean;
subEnable: boolean;
nodesById: Map<number, NodeRecord>;
hasActiveNode: boolean;
onAddInbound: () => void;
onGeneralAction: (key: GeneralAction) => void;
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
}
type SortKey =
| 'id'
| 'enable'
| 'remark'
| 'port'
| 'protocol'
| 'traffic'
| 'expiryTime'
| 'node'
| 'clients';
type SortOrder = 'ascend' | 'descend' | null;
const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
id: (a, b) => a.id - b.id,
enable: (a, b) => Number(a.enable) - Number(b.enable),
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
port: (a, b) => a.port - b.port,
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
node: (a, b, ctx) => {
const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '￿' : `node #${a.nodeId}`);
const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '￿' : `node #${b.nodeId}`);
return nameA.localeCompare(nameB);
},
clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
};
function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
if (dbInbound.isWireguard) return true;
if (dbInbound.isSS) {
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
}
return false;
}
interface RowActionsMenuProps {
record: DBInboundRecord;
subEnable: boolean;
hasClients: boolean;
onClick: (key: RowAction) => void;
isMobile?: boolean;
}
function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] {
const items: MenuProps['items'] = [];
if (isMobile) {
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
}
if (showQrCodeMenu(record)) {
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
}
if (isInboundMultiUser(record)) {
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
if (subEnable) {
items.push({
key: 'subs',
icon: <ExportOutlined />,
label: `${t('pages.inbounds.export')}${t('pages.settings.subSettings')}`,
});
}
} else {
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
}
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
if (isInboundMultiUser(record) && hasClients) {
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
items.push({ type: 'divider' });
items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
} else {
items.push({ type: 'divider' });
}
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
return items;
}
function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) {
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
items: buildRowActionsMenu({ record, subEnable, t, hasClients }),
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
);
}
export default function InboundList({
dbInbounds,
clientCount,
@ -307,7 +45,6 @@ export default function InboundList({
onRowAction,
}: InboundListProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
@ -338,245 +75,19 @@ export default function InboundList({
[dbInbounds],
);
const sorterFor = useCallback((key: SortKey) => ({
sorter: true as const,
showSorterTooltip: false,
sortOrder: sortKey === key ? sortOrder : null,
sortDirections: ['ascend' as const, 'descend' as const],
}), [sortKey, sortOrder]);
const columns: TableColumnType<DBInboundRecord>[] = useMemo(() => {
const cols: TableColumnType<DBInboundRecord>[] = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'right',
width: 30,
...sorterFor('id'),
},
{
title: t('pages.inbounds.operate'),
key: 'action',
align: 'center',
width: 60,
render: (_, record) => (
<RowActionsCell
record={record}
subEnable={subEnable}
hasClients={(clientCount[record.id]?.clients || 0) > 0}
onClick={(key) => onRowAction({ key, dbInbound: record })}
/>
),
},
{
title: t('pages.inbounds.enable'),
key: 'enable',
align: 'center',
width: 35,
...sorterFor('enable'),
render: (_, record) => (
<Switch
checked={record.enable}
onChange={(next) => onSwitchEnable(record, next)}
/>
),
},
];
if (hasAnyRemark) {
cols.push({
title: t('pages.inbounds.remark'),
dataIndex: 'remark',
key: 'remark',
align: 'center',
width: 60,
...sorterFor('remark'),
});
}
if (hasActiveNode) {
cols.push({
title: t('pages.inbounds.node'),
key: 'node',
align: 'center',
width: 60,
...sorterFor('node'),
render: (_, record) => {
if (record.nodeId == null) {
return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
}
const node = nodesById.get(record.nodeId);
if (!node) {
return <Tag color="orange">node #{record.nodeId}</Tag>;
}
return (
<Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
);
},
});
}
cols.push(
{
title: t('pages.inbounds.port'),
dataIndex: 'port',
key: 'port',
align: 'center',
width: 40,
...sorterFor('port'),
},
{
title: t('pages.inbounds.protocol'),
key: 'protocol',
align: 'left',
width: 130,
...sorterFor('protocol'),
render: (_, record) => {
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
if (record.isWireguard || record.isHysteria) {
tags.push(<Tag key="n" color="green">UDP</Tag>);
} else if (record.isSS) {
const stream = readStreamHints(record.streamSettings);
tags.push(<Tag key="n" color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>);
if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
} else if (record.isTunnel) {
tags.push(<Tag key="n" color="green">{tunnelNetworkLabel(record.settings)}</Tag>);
} else if (record.isMixed) {
tags.push(<Tag key="n" color="green">{mixedNetworkLabel(record.settings)}</Tag>);
} else if (record.isVMess || record.isVLess || record.isTrojan) {
const stream = readStreamHints(record.streamSettings);
tags.push(<Tag key="n" color="green">{networkLabel(stream.network)}</Tag>);
const l4 = networkL4(stream.network);
if (l4) tags.push(<Tag key="l4" color="green">{l4}</Tag>);
if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
}
return <div className="protocol-tags">{tags}</div>;
},
},
{
title: t('clients'),
key: 'clients',
align: 'left',
width: 50,
...sorterFor('clients'),
render: (_, record) => {
const cc = clientCount[record.id];
if (!cc) return null;
return (
<>
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{cc.clients}
</Tag>
{cc.deactive.length > 0 && (
<Popover
title={t('disabled')}
content={(
<div className="client-email-list">
{cc.deactive.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
</Popover>
)}
{cc.depleted.length > 0 && (
<Popover
title={t('depleted')}
content={(
<div className="client-email-list">
{cc.depleted.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
</Popover>
)}
{cc.expiring.length > 0 && (
<Popover
title={t('depletingSoon')}
content={(
<div className="client-email-list">
{cc.expiring.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="orange" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.expiring.length}</Tag>
</Popover>
)}
{cc.online.length > 0 && (
<Popover
title={t('online')}
content={(
<div className="client-email-list">
{cc.online.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
</Popover>
)}
</>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'center',
width: 90,
...sorterFor('traffic'),
render: (_, record) => (
<Popover
content={(
<table cellPadding={2}>
<tbody>
<tr>
<td> {SizeFormatter.sizeFormat(record.up)}</td>
<td> {SizeFormatter.sizeFormat(record.down)}</td>
</tr>
{record.total > 0 && record.up + record.down < record.total && (
<tr>
<td>{t('remained')}</td>
<td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
</tr>
)}
</tbody>
</table>
)}
>
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
{SizeFormatter.sizeFormat(record.up + record.down)} /
{' '}
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</Popover>
),
},
{
title: t('pages.inbounds.expireDate'),
key: 'expiryTime',
align: 'center',
width: 40,
...sorterFor('expiryTime'),
render: (_, record) => {
if (record.expiryTime > 0) {
return (
<Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
{IntlUtil.formatRelativeTime(record.expiryTime)}
</Tag>
</Popover>
);
}
return <Tag color="purple"><InfinityIcon /></Tag>;
},
},
);
return cols;
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
const columns = useInboundColumns({
hasAnyRemark,
hasActiveNode,
nodesById,
clientCount,
subEnable,
expireDiff,
trafficDiff,
sortKey,
sortOrder,
onRowAction,
onSwitchEnable,
});
const paginationFor = (rows: DBInboundRecord[]) => {
const size = pageSize > 0 ? pageSize : rows.length || 1;
@ -677,105 +188,16 @@ export default function InboundList({
)}
</Space>
<Modal
<InboundStatsModal
open={isMobile && !!statsRecord}
footer={null}
width={360}
centered
title={statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''}
onCancel={() => setStatsRecord(null)}
destroyOnHidden
>
{statsRecord && (
<div className="card-stats">
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
<Tag color="purple">{statsRecord.protocol}</Tag>
{(statsRecord.isWireguard || statsRecord.isHysteria) && (
<Tag color="green">UDP</Tag>
)}
{statsRecord.isSS && (() => {
const stream = readStreamHints(statsRecord.streamSettings);
return (
<>
<Tag color="green">{shadowsocksNetworkLabel(statsRecord.settings)}</Tag>
{stream.isTls && <Tag color="blue">TLS</Tag>}
</>
);
})()}
{statsRecord.isTunnel && (
<Tag color="green">{tunnelNetworkLabel(statsRecord.settings)}</Tag>
)}
{statsRecord.isMixed && (
<Tag color="green">{mixedNetworkLabel(statsRecord.settings)}</Tag>
)}
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan) && (() => {
const stream = readStreamHints(statsRecord.streamSettings);
const l4 = networkL4(stream.network);
return (
<>
<Tag color="green">{networkLabel(stream.network)}</Tag>
{l4 && <Tag color="green">{l4}</Tag>}
{stream.isTls && <Tag color="blue">TLS</Tag>}
{stream.isReality && <Tag color="blue">Reality</Tag>}
</>
);
})()}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.port')}</span>
<Tag>{statsRecord.port}</Tag>
</div>
{hasActiveNode && (
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.node')}</span>
{statsRecord.nodeId == null ? (
<Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
) : nodesById.get(statsRecord.nodeId) ? (
<Tag color={nodesById.get(statsRecord.nodeId)!.status === 'online' ? 'blue' : 'red'}>
{nodesById.get(statsRecord.nodeId)!.name}
</Tag>
) : (
<Tag color="orange">#{statsRecord.nodeId}</Tag>
)}
</div>
)}
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.traffic')}</span>
<Tag color={ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)}>
{SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} /
{' '}
{statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : <InfinityIcon />}
</Tag>
</div>
{clientCount[statsRecord.id] && (
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
<Tag color="green" className="client-count-tag">{clientCount[statsRecord.id].clients}</Tag>
{clientCount[statsRecord.id].online.length > 0 && (
<Tag color="blue">{clientCount[statsRecord.id].online.length} {t('online')}</Tag>
)}
{clientCount[statsRecord.id].depleted.length > 0 && (
<Tag color="red">{clientCount[statsRecord.id].depleted.length} {t('depleted')}</Tag>
)}
{clientCount[statsRecord.id].expiring.length > 0 && (
<Tag color="orange">{clientCount[statsRecord.id].expiring.length} {t('depletingSoon')}</Tag>
)}
</div>
)}
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.expireDate')}</span>
{statsRecord.expiryTime > 0 ? (
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)}>
{IntlUtil.formatRelativeTime(statsRecord.expiryTime)}
</Tag>
) : (
<Tag color="purple"><InfinityIcon /></Tag>
)}
</div>
</div>
)}
</Modal>
record={statsRecord}
hasActiveNode={hasActiveNode}
nodesById={nodesById}
clientCount={clientCount}
trafficDiff={trafficDiff}
expireDiff={expireDiff}
onClose={() => setStatsRecord(null)}
/>
</Card>
);
}

View file

@ -0,0 +1,141 @@
import { useTranslation } from 'react-i18next';
import { Modal, Tag } from 'antd';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { InfinityIcon } from '@/components/ui';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import {
readStreamHints,
networkLabel,
networkL4,
shadowsocksNetworkLabel,
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord } from './types';
interface InboundStatsModalProps {
open: boolean;
record: DBInboundRecord | null;
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
trafficDiff: number;
expireDiff: number;
onClose: () => void;
}
export default function InboundStatsModal({
open,
record,
hasActiveNode,
nodesById,
clientCount,
trafficDiff,
expireDiff,
onClose,
}: InboundStatsModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
footer={null}
width={360}
centered
title={record ? `#${record.id} ${record.remark || ''}`.trim() : ''}
onCancel={onClose}
destroyOnHidden
>
{record && (
<div className="card-stats">
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
<Tag color="purple">{record.protocol}</Tag>
{(record.isWireguard || record.isHysteria) && (
<Tag color="green">UDP</Tag>
)}
{record.isSS && (() => {
const stream = readStreamHints(record.streamSettings);
return (
<>
<Tag color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>
{stream.isTls && <Tag color="blue">TLS</Tag>}
</>
);
})()}
{record.isTunnel && (
<Tag color="green">{tunnelNetworkLabel(record.settings)}</Tag>
)}
{record.isMixed && (
<Tag color="green">{mixedNetworkLabel(record.settings)}</Tag>
)}
{(record.isVMess || record.isVLess || record.isTrojan) && (() => {
const stream = readStreamHints(record.streamSettings);
const l4 = networkL4(stream.network);
return (
<>
<Tag color="green">{networkLabel(stream.network)}</Tag>
{l4 && <Tag color="green">{l4}</Tag>}
{stream.isTls && <Tag color="blue">TLS</Tag>}
{stream.isReality && <Tag color="blue">Reality</Tag>}
</>
);
})()}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.port')}</span>
<Tag>{record.port}</Tag>
</div>
{hasActiveNode && (
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.node')}</span>
{record.nodeId == null ? (
<Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
) : nodesById.get(record.nodeId) ? (
<Tag color={nodesById.get(record.nodeId)!.status === 'online' ? 'blue' : 'red'}>
{nodesById.get(record.nodeId)!.name}
</Tag>
) : (
<Tag color="orange">#{record.nodeId}</Tag>
)}
</div>
)}
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.traffic')}</span>
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
{SizeFormatter.sizeFormat(record.up + record.down)} /
{' '}
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</div>
{clientCount[record.id] && (
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
<Tag color="green" className="client-count-tag">{clientCount[record.id].clients}</Tag>
{clientCount[record.id].online.length > 0 && (
<Tag color="blue">{clientCount[record.id].online.length} {t('online')}</Tag>
)}
{clientCount[record.id].depleted.length > 0 && (
<Tag color="red">{clientCount[record.id].depleted.length} {t('depleted')}</Tag>
)}
{clientCount[record.id].expiring.length > 0 && (
<Tag color="orange">{clientCount[record.id].expiring.length} {t('depletingSoon')}</Tag>
)}
</div>
)}
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.expireDate')}</span>
{record.expiryTime > 0 ? (
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)}>
{IntlUtil.formatRelativeTime(record.expiryTime)}
</Tag>
) : (
<Tag color="purple"><InfinityIcon /></Tag>
)}
</div>
</div>
)}
</Modal>
);
}

View file

@ -0,0 +1,81 @@
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, type MenuProps } from 'antd';
import {
MoreOutlined,
EditOutlined,
QrcodeOutlined,
CopyOutlined,
ExportOutlined,
RetweetOutlined,
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
TagsOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { isInboundMultiUser, showQrCodeMenu } from './helpers';
import type { DBInboundRecord, RowAction } from './types';
interface RowActionsMenuProps {
record: DBInboundRecord;
subEnable: boolean;
hasClients: boolean;
onClick: (key: RowAction) => void;
isMobile?: boolean;
}
export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] {
const items: MenuProps['items'] = [];
if (isMobile) {
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
}
if (showQrCodeMenu(record)) {
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
}
if (isInboundMultiUser(record)) {
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
if (subEnable) {
items.push({
key: 'subs',
icon: <ExportOutlined />,
label: `${t('pages.inbounds.export')}${t('pages.settings.subSettings')}`,
});
}
} else {
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
}
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
if (isInboundMultiUser(record) && hasClients) {
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
items.push({ type: 'divider' });
items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
} else {
items.push({ type: 'divider' });
}
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
return items;
}
export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) {
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
items: buildRowActionsMenu({ record, subEnable, t, hasClients }),
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
);
}

View file

@ -0,0 +1,106 @@
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { coerceInboundJsonField } from '@/models/dbinbound';
import type { ClientCountEntry, DBInboundRecord, SortKey, StreamHints } from './types';
export function readStreamHints(streamSettings: unknown): StreamHints {
const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
return {
network: stream.network ?? '',
isTls: stream.security === 'tls',
isReality: stream.security === 'reality',
};
}
// Display label for a network value. All known transports render in
// upper-case for visual consistency with the TCP/UDP/TLS/Reality tags
// already shown alongside; compound names (`httpupgrade`, `splithttp`,
// `xhttp`) get a tiny touch of casing so they don't read as one word.
export function networkLabel(network: string): string {
const n = (network || '').toLowerCase();
if (!n) return 'TCP';
switch (n) {
case 'httpupgrade': return 'HTTPUpgrade';
case 'splithttp': return 'SplitHTTP';
case 'xhttp': return 'XHTTP';
}
return n.toUpperCase();
}
// Returns the underlying L4 protocol for transports whose name isn't
// already TCP/UDP. `kcp` and `quic` both ride on UDP; everything else
// (`ws`, `grpc`, `http`, `httpupgrade`, `xhttp`) is TCP-based and gets
// no extra tag (the transport name implies TCP).
export function networkL4(network: string): 'UDP' | '' {
const n = (network || '').toLowerCase();
if (n === 'kcp' || n === 'quic') return 'UDP';
return '';
}
// Shadowsocks settings.network ("tcp" / "udp" / "tcp,udp") and Tunnel
// settings.allowedNetwork (same shape, different field name) both carry
// the L4 transport list independent of streamSettings. Returns a
// comma-separated label.
export function commaNetworkLabel(raw: string): string {
const parts = (raw || 'tcp').toLowerCase().split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return 'TCP';
return parts.map(networkLabel).join(',');
}
export function shadowsocksNetworkLabel(settings: unknown): string {
return commaNetworkLabel(readSettings(settings).network || '');
}
export function tunnelNetworkLabel(settings: unknown): string {
return commaNetworkLabel(readSettings(settings).allowedNetwork || '');
}
// Mixed (socks+http combo) is always TCP at L4; settings.udp=true adds
// UDP-associate support on the same port (SOCKS5 UDP).
export function mixedNetworkLabel(settings: unknown): string {
const st = coerceInboundJsonField(settings) as { udp?: boolean };
return st.udp ? 'TCP,UDP' : 'TCP';
}
export function readSettings(settings: unknown): { method?: string; network?: string; allowedNetwork?: string } {
return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
}
export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
switch (record.protocol) {
case 'vmess':
case 'vless':
case 'trojan':
case 'hysteria':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
default:
return false;
}
}
export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
if (dbInbound.isWireguard) return true;
if (dbInbound.isSS) {
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
}
return false;
}
export const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
id: (a, b) => a.id - b.id,
enable: (a, b) => Number(a.enable) - Number(b.enable),
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
port: (a, b) => a.port - b.port,
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
node: (a, b, ctx) => {
const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '￿' : `node #${a.nodeId}`);
const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '￿' : `node #${b.nodeId}`);
return nameA.localeCompare(nameB);
},
clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
};

View file

@ -1 +1,2 @@
export { default as InboundList, isInboundMultiUser } from './InboundList';
export { default as InboundList } from './InboundList';
export { isInboundMultiUser } from './helpers';

View file

@ -0,0 +1,88 @@
import type { NodeRecord } from '@/api/queries/useNodesQuery';
export interface StreamHints {
network: string;
isTls: boolean;
isReality: boolean;
}
export type ProtocolFlags = {
isVMess?: boolean;
isVLess?: boolean;
isTrojan?: boolean;
isSS?: boolean;
isHysteria?: boolean;
isMixed?: boolean;
isHTTP?: boolean;
isWireguard?: boolean;
isTunnel?: boolean;
};
export interface DBInboundRecord extends ProtocolFlags {
id: number;
enable: boolean;
remark: string;
port: number;
protocol: string;
up: number;
down: number;
total: number;
expiryTime: number;
_expiryTime: { valueOf(): number } | null;
nodeId?: number | null;
settings: unknown;
streamSettings: unknown;
}
export interface ClientCountEntry {
clients: number;
active: string[];
deactive: string[];
depleted: string[];
expiring: string[];
online: string[];
}
export type RowAction =
| 'edit'
| 'showInfo'
| 'qrcode'
| 'export'
| 'subs'
| 'clipboard'
| 'delete'
| 'resetTraffic'
| 'delAllClients'
| 'clone';
export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
export interface InboundListProps {
dbInbounds: DBInboundRecord[];
clientCount: Record<number, ClientCountEntry>;
onlineClients: string[];
lastOnlineMap: Record<string, number>;
expireDiff: number;
trafficDiff: number;
pageSize: number;
isMobile: boolean;
subEnable: boolean;
nodesById: Map<number, NodeRecord>;
hasActiveNode: boolean;
onAddInbound: () => void;
onGeneralAction: (key: GeneralAction) => void;
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
}
export type SortKey =
| 'id'
| 'enable'
| 'remark'
| 'port'
| 'protocol'
| 'traffic'
| 'expiryTime'
| 'node'
| 'clients';
export type SortOrder = 'ascend' | 'descend' | null;

View file

@ -0,0 +1,290 @@
import { useCallback, useMemo, type ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, Switch, Tag, type TableColumnType } from 'antd';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { InfinityIcon } from '@/components/ui';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { RowActionsCell } from './RowActions';
import {
readStreamHints,
networkLabel,
networkL4,
shadowsocksNetworkLabel,
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord, RowAction, SortKey, SortOrder } from './types';
interface UseInboundColumnsParams {
hasAnyRemark: boolean;
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
subEnable: boolean;
expireDiff: number;
trafficDiff: number;
sortKey: SortKey | null;
sortOrder: SortOrder;
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
onSwitchEnable: (dbInbound: DBInboundRecord, next: boolean) => void;
}
export function useInboundColumns({
hasAnyRemark,
hasActiveNode,
nodesById,
clientCount,
subEnable,
expireDiff,
trafficDiff,
sortKey,
sortOrder,
onRowAction,
onSwitchEnable,
}: UseInboundColumnsParams): TableColumnType<DBInboundRecord>[] {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const sorterFor = useCallback((key: SortKey) => ({
sorter: true as const,
showSorterTooltip: false,
sortOrder: sortKey === key ? sortOrder : null,
sortDirections: ['ascend' as const, 'descend' as const],
}), [sortKey, sortOrder]);
return useMemo(() => {
const cols: TableColumnType<DBInboundRecord>[] = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'right',
width: 30,
...sorterFor('id'),
},
{
title: t('pages.inbounds.operate'),
key: 'action',
align: 'center',
width: 60,
render: (_, record) => (
<RowActionsCell
record={record}
subEnable={subEnable}
hasClients={(clientCount[record.id]?.clients || 0) > 0}
onClick={(key) => onRowAction({ key, dbInbound: record })}
/>
),
},
{
title: t('pages.inbounds.enable'),
key: 'enable',
align: 'center',
width: 35,
...sorterFor('enable'),
render: (_, record) => (
<Switch
checked={record.enable}
onChange={(next) => onSwitchEnable(record, next)}
/>
),
},
];
if (hasAnyRemark) {
cols.push({
title: t('pages.inbounds.remark'),
dataIndex: 'remark',
key: 'remark',
align: 'center',
width: 60,
...sorterFor('remark'),
});
}
if (hasActiveNode) {
cols.push({
title: t('pages.inbounds.node'),
key: 'node',
align: 'center',
width: 60,
...sorterFor('node'),
render: (_, record) => {
if (record.nodeId == null) {
return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
}
const node = nodesById.get(record.nodeId);
if (!node) {
return <Tag color="orange">node #{record.nodeId}</Tag>;
}
return (
<Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
);
},
});
}
cols.push(
{
title: t('pages.inbounds.port'),
dataIndex: 'port',
key: 'port',
align: 'center',
width: 40,
...sorterFor('port'),
},
{
title: t('pages.inbounds.protocol'),
key: 'protocol',
align: 'left',
width: 130,
...sorterFor('protocol'),
render: (_, record) => {
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
if (record.isWireguard || record.isHysteria) {
tags.push(<Tag key="n" color="green">UDP</Tag>);
} else if (record.isSS) {
const stream = readStreamHints(record.streamSettings);
tags.push(<Tag key="n" color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>);
if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
} else if (record.isTunnel) {
tags.push(<Tag key="n" color="green">{tunnelNetworkLabel(record.settings)}</Tag>);
} else if (record.isMixed) {
tags.push(<Tag key="n" color="green">{mixedNetworkLabel(record.settings)}</Tag>);
} else if (record.isVMess || record.isVLess || record.isTrojan) {
const stream = readStreamHints(record.streamSettings);
tags.push(<Tag key="n" color="green">{networkLabel(stream.network)}</Tag>);
const l4 = networkL4(stream.network);
if (l4) tags.push(<Tag key="l4" color="green">{l4}</Tag>);
if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
}
return <div className="protocol-tags">{tags}</div>;
},
},
{
title: t('clients'),
key: 'clients',
align: 'left',
width: 50,
...sorterFor('clients'),
render: (_, record) => {
const cc = clientCount[record.id];
if (!cc) return null;
return (
<>
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{cc.clients}
</Tag>
{cc.deactive.length > 0 && (
<Popover
title={t('disabled')}
content={(
<div className="client-email-list">
{cc.deactive.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
</Popover>
)}
{cc.depleted.length > 0 && (
<Popover
title={t('depleted')}
content={(
<div className="client-email-list">
{cc.depleted.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
</Popover>
)}
{cc.expiring.length > 0 && (
<Popover
title={t('depletingSoon')}
content={(
<div className="client-email-list">
{cc.expiring.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="orange" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.expiring.length}</Tag>
</Popover>
)}
{cc.online.length > 0 && (
<Popover
title={t('online')}
content={(
<div className="client-email-list">
{cc.online.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
</Popover>
)}
</>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'center',
width: 90,
...sorterFor('traffic'),
render: (_, record) => (
<Popover
content={(
<table cellPadding={2}>
<tbody>
<tr>
<td> {SizeFormatter.sizeFormat(record.up)}</td>
<td> {SizeFormatter.sizeFormat(record.down)}</td>
</tr>
{record.total > 0 && record.up + record.down < record.total && (
<tr>
<td>{t('remained')}</td>
<td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
</tr>
)}
</tbody>
</table>
)}
>
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
{SizeFormatter.sizeFormat(record.up + record.down)} /
{' '}
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</Popover>
),
},
{
title: t('pages.inbounds.expireDate'),
key: 'expiryTime',
align: 'center',
width: 40,
...sorterFor('expiryTime'),
render: (_, record) => {
if (record.expiryTime > 0) {
return (
<Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
{IntlUtil.formatRelativeTime(record.expiryTime)}
</Tag>
</Popover>
);
}
return <Tag color="purple"><InfinityIcon /></Tag>;
},
},
);
return cols;
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
}