Files
3x-ui/frontend/src/pages/nodes/NodeList.tsx
T
MHSanaei 6e20588236 style(ui): enlarge row action icons and rebalance clients table widths
Bump row action icons to 18px across the clients, inbounds, groups and
nodes tables for better visibility.

In the clients table, cap the Client column at 220px and give Duration a
fixed width so the Traffic column becomes the flexible one that absorbs
the remaining horizontal space instead of Client growing oversized.
2026-06-12 18:25:27 +02:00

631 lines
22 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Dropdown,
Modal,
Space,
Switch,
Table,
Tag,
Tooltip,
} from 'antd';
import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ApartmentOutlined,
ClusterOutlined,
CloudDownloadOutlined,
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
EyeInvisibleOutlined,
EyeOutlined,
InfoCircleOutlined,
MoreOutlined,
PlusOutlined,
RightOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import NodeHistoryPanel from './NodeHistoryPanel';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isPanelUpdateAvailable } from '@/lib/panel-version';
import './NodeList.css';
interface NodeListProps {
nodes: NodeRecord[];
loading?: boolean;
isMobile?: boolean;
latestVersion?: string;
selectedIds: number[];
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onEdit: (node: NodeRecord) => void;
onDelete: (node: NodeRecord) => void;
onProbe: (node: NodeRecord) => void;
onToggleEnable: (node: NodeRecord, next: boolean) => void;
onUpdateNode: (node: NodeRecord) => void;
onUpdateSelected: () => void;
}
function isUpdateEligible(n: NodeRecord): boolean {
return !!n.enable && n.status === 'online';
}
interface NodeRow extends NodeRecord {
url: string;
key: string | number;
}
function badgeStatus(status?: string): BadgeProps['status'] {
switch (status) {
case 'online': return 'success';
case 'offline': return 'error';
default: return 'default';
}
}
interface HealthProps {
status?: string;
xrayState?: string;
xrayError?: string;
}
// Purple: the node's panel API is reachable (status=online) but its Xray core
// has failed or been stopped. Distinct from a normal offline/unknown node.
const XRAY_ERROR_COLOR = '#722ED1';
// True when the panel is online but Xray itself reports error/stop.
function hasXrayProblem(status?: string, xrayState?: string): boolean {
if (status !== 'online') return false;
const xs = (xrayState || '').toLowerCase().trim();
return xs === 'error' || xs === 'stop';
}
// Tooltip text + icon color for the status cell. A real probe error (lastError)
// is a warning and takes precedence; otherwise an Xray-core problem shows purple.
function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
const tip = record.lastError || (hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') || '';
const iconColor = !record.lastError && hasXrayProblem(record.status, record.xrayState)
? XRAY_ERROR_COLOR
: 'var(--ant-color-warning)';
return { tip, iconColor };
}
function StatusDot({ status, xrayState }: HealthProps) {
if (status === 'online') {
return hasXrayProblem(status, xrayState)
? <span className="xray-error-dot" />
: <span className="online-dot" />;
}
return <Badge status={badgeStatus(status)} />;
}
function StatusLabel({ status, xrayState }: HealthProps) {
const { t } = useTranslation();
if (status === 'online') {
const xs = (xrayState || '').toLowerCase().trim();
if (xs === 'error' || xs === 'stop') {
const detail = xs === 'error'
? t('pages.nodes.statusValues.xrayError')
: t('pages.nodes.statusValues.xrayStopped');
return (
<span style={{ color: XRAY_ERROR_COLOR }}>
{t('pages.nodes.statusValues.online')} ({detail})
</span>
);
}
return (
<span style={{ color: 'var(--ant-color-success)' }}>
{t('pages.nodes.statusValues.online')}
</span>
);
}
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
}
function formatPct(p?: number): string {
if (typeof p !== 'number' || Number.isNaN(p)) return '-';
return `${p.toFixed(1)}%`;
}
function formatUptime(secs?: number): string {
if (!secs) return '-';
const days = Math.floor(secs / 86400);
const hours = Math.floor((secs % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h`;
const mins = Math.floor((secs % 3600) / 60);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function useRelativeTime() {
const { t } = useTranslation();
return (unixSeconds?: number) => {
if (!unixSeconds) return t('pages.nodes.never');
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
if (diffSec < 5) return t('pages.nodes.justNow');
if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
return `${Math.floor(diffSec / 86400)}d`;
};
}
export default function NodeList({
nodes,
loading = false,
isMobile = false,
latestVersion = '',
selectedIds,
onSelectionChange,
onAdd,
onEdit,
onDelete,
onProbe,
onToggleEnable,
onUpdateNode,
onUpdateSelected,
}: NodeListProps) {
const { t } = useTranslation();
const relativeTime = useRelativeTime();
const [showAddress, setShowAddress] = useState(false);
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
// Map a node GUID to its display name so a transitive sub-node can show which
// parent it is reached through (#4983).
const nameByGuid = useMemo(() => {
const m = new Map<string, string>();
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
return m;
}, [nodes]);
// Order direct nodes first, each immediately followed by its transitive
// sub-nodes, so the table reads as a parent -> child tree without colliding
// with the per-row history expander (transitive nodes carry id 0).
const dataSource = useMemo<NodeRow[]>(() => {
const toRow = (n: NodeRecord): NodeRow => ({
...n,
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
key: n.transitive ? `t-${n.guid || ''}` : n.id,
});
const childrenByParent = new Map<string, NodeRecord[]>();
for (const n of nodes) {
if (n.transitive && n.parentGuid) {
const arr = childrenByParent.get(n.parentGuid) || [];
arr.push(n);
childrenByParent.set(n.parentGuid, arr);
}
}
const ordered: NodeRow[] = [];
const added = new Set<string>();
const push = (n: NodeRecord) => {
const row = toRow(n);
ordered.push(row);
added.add(String(row.key));
};
for (const n of nodes) {
if (n.transitive) continue;
push(n);
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
}
// Transitive nodes whose parent isn't in the list still get shown.
for (const n of nodes) {
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
}
return ordered;
}, [nodes]);
function toggleExpanded(id: number) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
const columns = useMemo<ColumnsType<NodeRow>>(() => [
{
title: t('pages.nodes.actions'),
align: 'center',
width: 190,
render: (_value, record) => record.transitive ? (
<Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</Tooltip>
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
},
{
title: t('pages.nodes.enable'),
dataIndex: 'enable',
align: 'center',
width: 80,
render: (_value, record) => record.transitive ? (
<span style={{ opacity: 0.4 }}>—</span>
) : (
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
),
},
{
title: t('pages.nodes.name'),
dataIndex: 'name',
ellipsis: true,
render: (_value, record) => (
<div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
<span className="name">
{record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
{record.name}
</span>
{record.remark && <span className="remark">{record.remark}</span>}
</div>
),
},
{
title: (
<span className="address-header">
{t('pages.nodes.address')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
)}
</Tooltip>
</span>
),
dataIndex: 'url',
ellipsis: true,
render: (_value, record) => (
<a
href={record.url}
target="_blank"
rel="noopener noreferrer"
className={showAddress ? 'address-visible' : 'address-hidden'}
>
{record.url}
</a>
),
},
{
title: t('pages.nodes.status'),
dataIndex: 'status',
align: 'center',
render: (_value, record) => {
const { tip, iconColor } = statusIssue(record);
return (
<Space size={4}>
<StatusDot status={record.status} xrayState={record.xrayState} />
<StatusLabel status={record.status} xrayState={record.xrayState} />
{tip && (
<Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} />
</Tooltip>
)}
</Space>
);
},
},
{
title: t('pages.nodes.cpu'),
dataIndex: 'cpuPct',
align: 'center',
width: 90,
render: (_value, record) => formatPct(record.cpuPct),
},
{
title: t('pages.nodes.mem'),
dataIndex: 'memPct',
align: 'center',
width: 90,
render: (_value, record) => formatPct(record.memPct),
},
{
title: t('pages.nodes.xrayVersion'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) => record.xrayVersion || '-',
},
{
title: t('pages.nodes.panelVersion') || 'Panel version',
dataIndex: 'panelVersion',
align: 'center',
render: (_value, record) => {
const canUpdate = isUpdateEligible(record)
&& isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
return (
<Space size={4}>
<span>{record.panelVersion || '-'}</span>
{canUpdate && (
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
{t('pages.nodes.updateAvailable')}
</Tag>
</Tooltip>
)}
</Space>
);
},
},
{
title: t('pages.nodes.uptime'),
dataIndex: 'uptimeSecs',
align: 'center',
render: (_value, record) => formatUptime(record.uptimeSecs),
},
{
title: t('clients'),
align: 'center',
width: 160,
render: (_value, record) => (
<Space size={4}>
<Tag color="green">{record.clientCount || 0}</Tag>
{record.onlineCount ? (
<Tag color="blue">{record.onlineCount} {t('online')}</Tag>
) : null}
{record.depletedCount ? (
<Tag color="red">{record.depletedCount} {t('depleted')}</Tag>
) : null}
</Space>
),
},
{
title: t('pages.nodes.latency'),
dataIndex: 'latencyMs',
align: 'center',
width: 100,
render: (_value, record) =>
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
},
{
title: t('pages.nodes.lastHeartbeat'),
dataIndex: 'lastHeartbeat',
align: 'center',
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
return (
<Card size="small" hoverable>
<div className="toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{t('pages.nodes.addNode')}
</Button>
{selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
</Button>
)}
</div>
{isMobile ? (
<>
<div className="node-cards">
{dataSource.length === 0 ? (
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div>
</div>
) : (
dataSource.map((record) => record.transitive ? (
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
<div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} />
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</div>
</div>
</div>
) : (
<div key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('info')}>
<InfoCircleOutlined
className="row-action-trigger"
onClick={() => setStatsNode(record)}
/>
</Tooltip>
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: [
{
key: 'probe',
label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
onClick: () => onProbe(record),
},
...(isUpdateEligible(record) ? [{
key: 'update',
label: <><CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}</>,
onClick: () => onUpdateNode(record),
}] : []),
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
onClick: () => onEdit(record),
},
{
key: 'delete',
danger: true,
label: <><DeleteOutlined /> {t('delete')}</>,
onClick: () => onDelete(record),
},
],
}}
>
<MoreOutlined className="row-action-trigger" />
</Dropdown>
</div>
</div>
{expandedIds.has(record.id) && (
<div className="card-history">
<NodeHistoryPanel node={record} />
</div>
)}
</div>
))
)}
</div>
<Modal
open={!!statsNode}
footer={null}
width={360}
centered
title={statsNode?.name || ''}
onCancel={() => setStatsNode(null)}
>
{statsNode && (
<div className="card-stats">
{statsNode.remark && (
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.name')}</span>
<span>{statsNode.remark}</span>
</div>
)}
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.address')}</span>
<a
href={statsNode.url}
target="_blank"
rel="noopener noreferrer"
className={showAddress ? 'address-visible' : 'address-hidden'}
>
{statsNode.url}
</a>
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
)}
</Tooltip>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.status')}</span>
<StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
<StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
{(() => {
const { tip, iconColor } = statusIssue(statsNode);
return tip ? (
<Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} />
</Tooltip>
) : null;
})()}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.cpu')}</span>
<Tag>{formatPct(statsNode.cpuPct)}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.mem')}</span>
<Tag>{formatPct(statsNode.memPct)}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
<Tag>{statsNode.xrayVersion || '-'}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.panelVersion') || 'Panel version'}</span>
<Tag>{statsNode.panelVersion || '-'}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.uptime')}</span>
<Tag>{formatUptime(statsNode.uptimeSecs)}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.latency')}</span>
<Tag>
{statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'}
</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
<Tag color="green">{statsNode.clientCount || 0}</Tag>
{statsNode.onlineCount ? (
<Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
) : null}
{statsNode.depletedCount ? (
<Tag color="red">{statsNode.depletedCount} {t('depleted')}</Tag>
) : null}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.lastHeartbeat')}</span>
<Tag>{relativeTime(statsNode.lastHeartbeat)}</Tag>
</div>
</div>
)}
</Modal>
</>
) : (
<Table<NodeRow>
dataSource={dataSource}
columns={columns}
pagination={false}
loading={loading}
scroll={{ x: 'max-content' }}
size="middle"
rowKey="id"
rowSelection={dataSource.length > 1 ? {
selectedRowKeys: selectedIds,
onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
} : undefined}
locale={{
emptyText: (
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}}
/>
)}
</Card>
);
}