diff --git a/frontend/src/pages/inbounds/list/InboundList.tsx b/frontend/src/pages/inbounds/list/InboundList.tsx index b1b08cc1..484587db 100644 --- a/frontend/src/pages/inbounds/list/InboundList.tsx +++ b/frontend/src/pages/inbounds/list/InboundList.tsx @@ -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; - 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; - 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: , 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('pages.inbounds.inboundInfo') }); - } - 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') }); - if (isInboundMultiUser(record) && hasClients) { - items.push({ key: 'attachClients', icon: , label: t('pages.inbounds.attachClients') }); - items.push({ key: 'detachClients', icon: , label: t('pages.inbounds.detachClients') }); - items.push({ key: 'addToGroup', icon: , label: t('pages.inbounds.addClientsToGroup') }); - items.push({ type: 'divider' }); - items.push({ key: 'delAllClients', icon: , danger: true, label: t('pages.inbounds.delAllClients') }); - } else { - items.push({ type: 'divider' }); - } - items.push({ key: 'delete', icon: , danger: true, label: t('delete') }); - return items; -} - -function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) { - const { t } = useTranslation(); - return ( -
-
- ); -} - 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(null); const [sortOrder, setSortOrder] = useState(null); const [statsRecord, setStatsRecord] = useState(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[] = 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) => ( - 0} - onClick={(key) => 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.isWireguard || record.isHysteria) { - tags.push(UDP); - } else if (record.isSS) { - const stream = readStreamHints(record.streamSettings); - tags.push({shadowsocksNetworkLabel(record.settings)}); - if (stream.isTls) tags.push(TLS); - } else if (record.isTunnel) { - tags.push({tunnelNetworkLabel(record.settings)}); - } else if (record.isMixed) { - tags.push({mixedNetworkLabel(record.settings)}); - } else if (record.isVMess || record.isVLess || record.isTrojan) { - const stream = readStreamHints(record.streamSettings); - tags.push({networkLabel(stream.network)}); - const l4 = networkL4(stream.network); - if (l4) tags.push({l4}); - 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 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({ )} - setStatsRecord(null)} - destroyOnHidden - > - {statsRecord && ( -
-
- {t('pages.inbounds.protocol')} - {statsRecord.protocol} - {(statsRecord.isWireguard || statsRecord.isHysteria) && ( - UDP - )} - {statsRecord.isSS && (() => { - const stream = readStreamHints(statsRecord.streamSettings); - return ( - <> - {shadowsocksNetworkLabel(statsRecord.settings)} - {stream.isTls && TLS} - - ); - })()} - {statsRecord.isTunnel && ( - {tunnelNetworkLabel(statsRecord.settings)} - )} - {statsRecord.isMixed && ( - {mixedNetworkLabel(statsRecord.settings)} - )} - {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan) && (() => { - const stream = readStreamHints(statsRecord.streamSettings); - const l4 = networkL4(stream.network); - return ( - <> - {networkLabel(stream.network)} - {l4 && {l4}} - {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)} - - ) : ( - - )} -
-
- )} -
+ record={statsRecord} + hasActiveNode={hasActiveNode} + nodesById={nodesById} + clientCount={clientCount} + trafficDiff={trafficDiff} + expireDiff={expireDiff} + onClose={() => setStatsRecord(null)} + /> ); } diff --git a/frontend/src/pages/inbounds/list/InboundStatsModal.tsx b/frontend/src/pages/inbounds/list/InboundStatsModal.tsx new file mode 100644 index 00000000..3468d4cd --- /dev/null +++ b/frontend/src/pages/inbounds/list/InboundStatsModal.tsx @@ -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; + clientCount: Record; + trafficDiff: number; + expireDiff: number; + onClose: () => void; +} + +export default function InboundStatsModal({ + open, + record, + hasActiveNode, + nodesById, + clientCount, + trafficDiff, + expireDiff, + onClose, +}: InboundStatsModalProps) { + const { t } = useTranslation(); + return ( + + {record && ( +
+
+ {t('pages.inbounds.protocol')} + {record.protocol} + {(record.isWireguard || record.isHysteria) && ( + UDP + )} + {record.isSS && (() => { + const stream = readStreamHints(record.streamSettings); + return ( + <> + {shadowsocksNetworkLabel(record.settings)} + {stream.isTls && TLS} + + ); + })()} + {record.isTunnel && ( + {tunnelNetworkLabel(record.settings)} + )} + {record.isMixed && ( + {mixedNetworkLabel(record.settings)} + )} + {(record.isVMess || record.isVLess || record.isTrojan) && (() => { + const stream = readStreamHints(record.streamSettings); + const l4 = networkL4(stream.network); + return ( + <> + {networkLabel(stream.network)} + {l4 && {l4}} + {stream.isTls && TLS} + {stream.isReality && Reality} + + ); + })()} +
+
+ {t('pages.inbounds.port')} + {record.port} +
+ {hasActiveNode && ( +
+ {t('pages.inbounds.node')} + {record.nodeId == null ? ( + {t('pages.inbounds.localPanel')} + ) : nodesById.get(record.nodeId) ? ( + + {nodesById.get(record.nodeId)!.name} + + ) : ( + #{record.nodeId} + )} +
+ )} +
+ {t('pages.inbounds.traffic')} + + {SizeFormatter.sizeFormat(record.up + record.down)} / + {' '} + {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : } + +
+ {clientCount[record.id] && ( +
+ {t('clients')} + {clientCount[record.id].clients} + {clientCount[record.id].online.length > 0 && ( + {clientCount[record.id].online.length} {t('online')} + )} + {clientCount[record.id].depleted.length > 0 && ( + {clientCount[record.id].depleted.length} {t('depleted')} + )} + {clientCount[record.id].expiring.length > 0 && ( + {clientCount[record.id].expiring.length} {t('depletingSoon')} + )} +
+ )} +
+ {t('pages.inbounds.expireDate')} + {record.expiryTime > 0 ? ( + + {IntlUtil.formatRelativeTime(record.expiryTime)} + + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/inbounds/list/RowActions.tsx b/frontend/src/pages/inbounds/list/RowActions.tsx new file mode 100644 index 00000000..4f06fd74 --- /dev/null +++ b/frontend/src/pages/inbounds/list/RowActions.tsx @@ -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: , 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('pages.inbounds.inboundInfo') }); + } + 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') }); + if (isInboundMultiUser(record) && hasClients) { + items.push({ key: 'attachClients', icon: , label: t('pages.inbounds.attachClients') }); + items.push({ key: 'detachClients', icon: , label: t('pages.inbounds.detachClients') }); + items.push({ key: 'addToGroup', icon: , label: t('pages.inbounds.addClientsToGroup') }); + items.push({ type: 'divider' }); + items.push({ key: 'delAllClients', icon: , danger: true, label: t('pages.inbounds.delAllClients') }); + } else { + items.push({ type: 'divider' }); + } + items.push({ key: 'delete', icon: , danger: true, label: t('delete') }); + return items; +} + +export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) { + const { t } = useTranslation(); + return ( +
+
+ ); +} diff --git a/frontend/src/pages/inbounds/list/helpers.ts b/frontend/src/pages/inbounds/list/helpers.ts new file mode 100644 index 00000000..ba5be2ce --- /dev/null +++ b/frontend/src/pages/inbounds/list/helpers.ts @@ -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; 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), +}; diff --git a/frontend/src/pages/inbounds/list/index.ts b/frontend/src/pages/inbounds/list/index.ts index 96c54429..622d10ca 100644 --- a/frontend/src/pages/inbounds/list/index.ts +++ b/frontend/src/pages/inbounds/list/index.ts @@ -1 +1,2 @@ -export { default as InboundList, isInboundMultiUser } from './InboundList'; +export { default as InboundList } from './InboundList'; +export { isInboundMultiUser } from './helpers'; diff --git a/frontend/src/pages/inbounds/list/types.ts b/frontend/src/pages/inbounds/list/types.ts new file mode 100644 index 00000000..cb093ecc --- /dev/null +++ b/frontend/src/pages/inbounds/list/types.ts @@ -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; + 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; +} + +export type SortKey = + | 'id' + | 'enable' + | 'remark' + | 'port' + | 'protocol' + | 'traffic' + | 'expiryTime' + | 'node' + | 'clients'; + +export type SortOrder = 'ascend' | 'descend' | null; diff --git a/frontend/src/pages/inbounds/list/useInboundColumns.tsx b/frontend/src/pages/inbounds/list/useInboundColumns.tsx new file mode 100644 index 00000000..90bd6831 --- /dev/null +++ b/frontend/src/pages/inbounds/list/useInboundColumns.tsx @@ -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; + clientCount: Record; + 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[] { + 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[] = [ + { + 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) => ( + 0} + onClick={(key) => 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.isWireguard || record.isHysteria) { + tags.push(UDP); + } else if (record.isSS) { + const stream = readStreamHints(record.streamSettings); + tags.push({shadowsocksNetworkLabel(record.settings)}); + if (stream.isTls) tags.push(TLS); + } else if (record.isTunnel) { + tags.push({tunnelNetworkLabel(record.settings)}); + } else if (record.isMixed) { + tags.push({mixedNetworkLabel(record.settings)}); + } else if (record.isVMess || record.isVLess || record.isTrojan) { + const stream = readStreamHints(record.streamSettings); + tags.push({networkLabel(stream.network)}); + const l4 = networkL4(stream.network); + if (l4) tags.push({l4}); + 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]); +}