mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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:
parent
1645664f03
commit
a32fe94872
7 changed files with 738 additions and 609 deletions
|
|
@ -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'),
|
||||
const columns = useInboundColumns({
|
||||
hasAnyRemark,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
clientCount,
|
||||
subEnable,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
sortKey,
|
||||
sortOrder,
|
||||
onRowAction,
|
||||
onSwitchEnable,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
141
frontend/src/pages/inbounds/list/InboundStatsModal.tsx
Normal file
141
frontend/src/pages/inbounds/list/InboundStatsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/inbounds/list/RowActions.tsx
Normal file
81
frontend/src/pages/inbounds/list/RowActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
frontend/src/pages/inbounds/list/helpers.ts
Normal file
106
frontend/src/pages/inbounds/list/helpers.ts
Normal 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),
|
||||
};
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { default as InboundList, isInboundMultiUser } from './InboundList';
|
||||
export { default as InboundList } from './InboundList';
|
||||
export { isInboundMultiUser } from './helpers';
|
||||
|
|
|
|||
88
frontend/src/pages/inbounds/list/types.ts
Normal file
88
frontend/src/pages/inbounds/list/types.ts
Normal 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;
|
||||
290
frontend/src/pages/inbounds/list/useInboundColumns.tsx
Normal file
290
frontend/src/pages/inbounds/list/useInboundColumns.tsx
Normal 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]);
|
||||
}
|
||||
Loading…
Reference in a new issue