| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766 |
- 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',
- };
- }
- // 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 };
- }
- 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'
- | '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;
- 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: <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('info') });
- }
- 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') });
- items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
- return items;
- }
- function RowActionsCell({ record, subEnable, 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 }),
- onClick: ({ key }) => onClick(key as RowAction),
- }}
- >
- <Button type="text" size="small" icon={<MoreOutlined />} />
- </Dropdown>
- </div>
- );
- }
- 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<SortKey | null>(null);
- const [sortOrder, setSortOrder] = useState<SortOrder>(null);
- const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(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<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}
- 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 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: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
- { key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
- ...(subEnable
- ? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
- : []),
- { key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
- ],
- onClick: ({ key }) => onGeneralAction(key as GeneralAction),
- };
- return (
- <Card
- hoverable
- title={(
- <Space>
- <Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
- {!isMobile && t('pages.inbounds.addInbound')}
- </Button>
- <Dropdown trigger={['click']} menu={generalActionsMenu}>
- <Button type="primary" icon={<MenuOutlined />}>
- {!isMobile && t('pages.inbounds.generalActions')}
- </Button>
- </Dropdown>
- </Space>
- )}
- >
- <Space orientation="vertical" style={{ width: '100%' }}>
- {isMobile ? (
- <div className="inbound-cards">
- {sortedInbounds.length === 0 ? (
- <div className="card-empty">
- <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
- <div>{t('noData')}</div>
- </div>
- ) : (
- sortedInbounds.map((record) => (
- <div key={record.id} className="inbound-card">
- <div className="card-head">
- <span className="card-id">#{record.id}</span>
- <span className="tag-name">{record.remark}</span>
- <div className="card-actions" onClick={(e) => e.stopPropagation()}>
- <Tooltip title={t('info')}>
- <InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
- </Tooltip>
- <Switch
- checked={record.enable}
- size="small"
- onChange={(next) => onSwitchEnable(record, next)}
- />
- <Dropdown
- trigger={['click']}
- placement="bottomRight"
- menu={{
- items: buildRowActionsMenu({ record, subEnable, t, isMobile: true }),
- onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
- }}
- >
- <MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
- </Dropdown>
- </div>
- </div>
- </div>
- ))
- )}
- </div>
- ) : (
- <Table
- columns={columns}
- dataSource={sortedInbounds}
- rowKey={(r) => r.id}
- pagination={paginationFor(sortedInbounds)}
- scroll={{ x: 1000 }}
- style={{ marginTop: 10 }}
- size="small"
- locale={{
- emptyText: (
- <div className="card-empty">
- <ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
- <div>{t('noData')}</div>
- </div>
- ),
- }}
- 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);
- }}
- />
- )}
- </Space>
- <Modal
- 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>
- </Card>
- );
- }
|