import { useCallback, useMemo, useState, type ReactElement } 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, } from '@ant-design/icons'; import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils'; import InfinityIcon from '@/components/InfinityIcon'; 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 './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', }; } function readSettings(settings: unknown): { method?: string } { return coerceInboundJsonField(settings) as { method?: string }; } 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; }; 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' | 'clone'; export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds'; interface InboundListProps { dbInbounds: DBInboundRecord[]; clientCount: Record; onlineClients: string[]; lastOnlineMap: Record; expireDiff: number; trafficDiff: number; pageSize: number; isMobile: boolean; subEnable: boolean; nodesById: Map; 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; clientCount: Record }) => 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; onClick: (key: RowAction) => void; isMobile?: boolean; } function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean }): MenuProps['items'] { const items: MenuProps['items'] = []; if (isMobile) { items.push({ key: 'edit', icon: , label: t('edit') }); } if (showQrCodeMenu(record)) { items.push({ key: 'qrcode', icon: , label: t('qrCode') }); } if (isInboundMultiUser(record)) { items.push({ key: 'export', icon: , label: t('pages.inbounds.export') }); if (subEnable) { items.push({ key: 'subs', icon: , label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`, }); } } else { items.push({ key: 'showInfo', icon: , label: t('info') }); } items.push({ key: 'clipboard', icon: , label: t('pages.inbounds.exportInbound') }); items.push({ key: 'resetTraffic', icon: , label: t('pages.inbounds.resetTraffic') }); items.push({ key: 'clone', icon: , label: t('pages.inbounds.clone') }); items.push({ key: 'delete', icon: , danger: true, label: t('delete') }); return items; } function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) { const { t } = useTranslation(); return (
); } export default function InboundList({ dbInbounds, clientCount, lastOnlineMap: _lastOnlineMap, expireDiff, trafficDiff, pageSize, isMobile, subEnable, nodesById, hasActiveNode, onAddInbound, onGeneralAction, onRowAction, }: InboundListProps) { const { t } = useTranslation(); const { datepicker } = useDatepicker(); const [sortKey, setSortKey] = useState(null); const [sortOrder, setSortOrder] = useState(null); const [statsRecord, setStatsRecord] = useState(null); const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => { const previous = dbInbound.enable; dbInbound.enable = next; try { const formData = new FormData(); formData.append('enable', String(next)); const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData); if (!msg?.success) dbInbound.enable = previous; } catch { dbInbound.enable = previous; } }, []); const sortedInbounds = useMemo(() => { if (!sortKey || !sortOrder) return dbInbounds; const fn = SORT_FNS[sortKey]; if (!fn) return dbInbounds; const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount })); return sortOrder === 'descend' ? sorted.reverse() : sorted; }, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]); const hasAnyRemark = useMemo( () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''), [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[] = useMemo(() => { const cols: TableColumnType[] = [ { 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) => ( onRowAction({ key, dbInbound: record })} /> ), }, { title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35, ...sorterFor('enable'), render: (_, record) => ( 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 {t('pages.inbounds.localPanel')}; } const node = nodesById.get(record.nodeId); if (!node) { return node #{record.nodeId}; } return ( {node.name} ); }, }); } 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[] = [{record.protocol}]; if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) { const stream = readStreamHints(record.streamSettings); tags.push( {record.isHysteria ? 'UDP' : stream.network} , ); if (stream.isTls) tags.push(TLS); if (stream.isReality) tags.push(Reality); } return
{tags}
; }, }, { title: t('clients'), key: 'clients', align: 'left', width: 50, ...sorterFor('clients'), render: (_, record) => { const cc = clientCount[record.id]; if (!cc) return null; return ( <> {cc.clients} {cc.deactive.length > 0 && ( {cc.deactive.map((e) =>
{e}
)} )} > {cc.deactive.length}
)} {cc.depleted.length > 0 && ( {cc.depleted.map((e) =>
{e}
)} )} > {cc.depleted.length}
)} {cc.expiring.length > 0 && ( {cc.expiring.map((e) =>
{e}
)} )} > {cc.expiring.length}
)} {cc.online.length > 0 && ( {cc.online.map((e) =>
{e}
)} )} > {cc.online.length}
)} ); }, }, { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90, ...sorterFor('traffic'), render: (_, record) => ( ↑ {SizeFormatter.sizeFormat(record.up)} ↓ {SizeFormatter.sizeFormat(record.down)} {record.total > 0 && record.up + record.down < record.total && ( {t('remained')} {SizeFormatter.sizeFormat(record.total - record.up - record.down)} )} )} > {SizeFormatter.sizeFormat(record.up + record.down)} / {' '} {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : } ), }, { title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40, ...sorterFor('expiryTime'), render: (_, record) => { if (record.expiryTime > 0) { return ( {IntlUtil.formatRelativeTime(record.expiryTime)} ); } return ; }, }, ); return cols; }, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]); const paginationFor = (rows: DBInboundRecord[]) => { const size = pageSize > 0 ? pageSize : rows.length || 1; return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true }; }; const generalActionsMenu: MenuProps = { items: [ { key: 'import', icon: , label: t('pages.inbounds.importInbound') }, { key: 'export', icon: , label: t('pages.inbounds.export') }, ...(subEnable ? [{ key: 'subs', icon: , label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }] : []), { key: 'resetInbounds', icon: , label: t('pages.inbounds.resetAllTraffic') }, ], onClick: ({ key }) => onGeneralAction(key as GeneralAction), }; return ( )} > {isMobile ? (
{sortedInbounds.length === 0 ? (
) : ( sortedInbounds.map((record) => (
#{record.id} {record.remark}
e.stopPropagation()}> setStatsRecord(record)} /> onSwitchEnable(record, next)} /> onRowAction({ key: key as RowAction, dbInbound: record }), }} > e.preventDefault()} />
)) )}
) : ( r.id} pagination={paginationFor(sortedInbounds)} scroll={{ x: 1000 }} style={{ marginTop: 10 }} size="small" onChange={(_p, _f, sorter) => { const single = Array.isArray(sorter) ? sorter[0] : sorter; const colKey = (single?.columnKey || single?.field) as SortKey | undefined; setSortKey(colKey || null); setSortOrder((single?.order as SortOrder) || null); }} /> )} setStatsRecord(null)} destroyOnHidden > {statsRecord && (
{t('pages.inbounds.protocol')} {statsRecord.protocol} {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (() => { const stream = readStreamHints(statsRecord.streamSettings); return ( <> {statsRecord.isHysteria ? 'UDP' : stream.network} {stream.isTls && TLS} {stream.isReality && Reality} ); })()}
{t('pages.inbounds.port')} {statsRecord.port}
{hasActiveNode && (
{t('pages.inbounds.node')} {statsRecord.nodeId == null ? ( {t('pages.inbounds.localPanel')} ) : nodesById.get(statsRecord.nodeId) ? ( {nodesById.get(statsRecord.nodeId)!.name} ) : ( #{statsRecord.nodeId} )}
)}
{t('pages.inbounds.traffic')} {SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} / {' '} {statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : }
{clientCount[statsRecord.id] && (
{t('clients')} {clientCount[statsRecord.id].clients} {clientCount[statsRecord.id].online.length > 0 && ( {clientCount[statsRecord.id].online.length} {t('online')} )} {clientCount[statsRecord.id].depleted.length > 0 && ( {clientCount[statsRecord.id].depleted.length} {t('depleted')} )} {clientCount[statsRecord.id].expiring.length > 0 && ( {clientCount[statsRecord.id].expiring.length} {t('depletingSoon')} )}
)}
{t('pages.inbounds.expireDate')} {statsRecord.expiryTime > 0 ? ( {IntlUtil.formatRelativeTime(statsRecord.expiryTime)} ) : ( )}
)}
); }