import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge, Button, Card, Dropdown, Modal, Popover, 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, RadarChartOutlined, RightOutlined, SafetyCertificateOutlined, TeamOutlined, ThunderboltOutlined, } from '@ant-design/icons'; import NodeHistoryPanel from './NodeHistoryPanel'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; import { isPanelUpdateAvailable } from '@/lib/panel-version'; import { activateOnKey } from '@/utils/a11y'; import './NodeList.css'; interface NodeListProps { nodes: NodeRecord[]; loading?: boolean; isMobile?: boolean; latestVersion?: string; selectedIds: number[]; onSelectionChange: (ids: number[]) => void; onAdd: () => void; onMtls: () => void; onProbeTasks: () => 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'; } // One category chip of the nodes table's clients cell. When the backend // sent the category's email list, hovering pops the list over (same // pattern as the inbounds page); without a list it keeps the plain // category tooltip. Chip styling is identical either way. function CategoryChip({ title, emails, color, count, }: { title: string; emails?: string[]; color?: string; count: number; }) { const tag = ( {count} ); if (emails && emails.length > 0) { return ( {emails.map((e) => (
{e}
))} } > {tag}
); } return {tag}; } 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) { 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) ? ( ) : ( ); } return ; } 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 ( {t('pages.nodes.statusValues.online')} ({detail}) ); } return ( {t('pages.nodes.statusValues.online')} ); } return {t(`pages.nodes.statusValues.${status || 'unknown'}`)}; } 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, onMtls, onProbeTasks, onEdit, onDelete, onProbe, onToggleEnable, onUpdateNode, onUpdateSelected, }: NodeListProps) { const { t } = useTranslation(); const relativeTime = useRelativeTime(); const [showAddress, setShowAddress] = useState(false); const [statsNode, setStatsNode] = useState(null); const [expandedIds, setExpandedIds] = useState>(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(); 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(() => { 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(); 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(); 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>( () => [ { title: t('pages.nodes.actions'), align: 'center', width: 150, render: (_value, record) => record.transitive ? ( } style={{ margin: 0 }}> {t('pages.nodes.subNode')} ) : ( {selectedIds.length > 0 && ( )} {isMobile ? ( <>
{dataSource.length === 0 ? (
{t('noData')}
) : ( dataSource.map((record) => record.transitive ? (
{record.name}
} style={{ margin: 0 }}> {t('pages.nodes.subNode')}
) : (
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
{ if (!(e.target as HTMLElement).closest('.card-actions')) toggleExpanded(record.id); }} > toggleExpanded(record.id))} /> {record.name}
setStatsNode(record)} onKeyDown={activateOnKey(() => setStatsNode(record))} /> onToggleEnable(record, v)} /> {t('pages.nodes.probe')} ), onClick: () => onProbe(record), }, ...(isUpdateEligible(record) ? [ { key: 'update', label: ( <> {' '} {t( record.nodeType === 'agent' ? 'pages.nodes.updateAgent' : 'pages.nodes.updatePanel', )} ), onClick: () => onUpdateNode(record), }, ] : []), { key: 'edit', label: ( <> {t('edit')} ), onClick: () => onEdit(record), }, { key: 'delete', danger: true, label: ( <> {t('delete')} ), onClick: () => onDelete(record), }, ], }} >
{expandedIds.has(record.id) && (
)}
), ) )}
setStatsNode(null)} > {statsNode && (
{statsNode.remark && (
{t('pages.nodes.name')} {statsNode.remark}
)}
{t('pages.nodes.address')} {statsNode.url} {showAddress ? ( setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} /> ) : ( setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} /> )}
{t('pages.nodes.status')} {(() => { const { tip, iconColor } = statusIssue(statsNode); return tip ? ( ) : null; })()}
{t('pages.nodes.cpu')} {formatPct(statsNode.cpuPct)}
{t('pages.nodes.mem')} {formatPct(statsNode.memPct)}
{t('pages.nodes.coreVersion')} {statsNode.nodeType === 'agent' ? statsNode.singboxVersion || statsNode.xrayVersion || '-' : statsNode.xrayVersion || '-'}
{t('pages.nodes.panelVersion') || 'Panel version'} {statsNode.panelVersion || '-'}
{t('pages.nodes.uptime')} {formatUptime(statsNode.uptimeSecs)}
{t('pages.nodes.latency')} {statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'}
{t('clients')} {statsNode.clientCount || 0} {statsNode.activeCount ? ( {statsNode.activeCount} {t('subscription.active')} ) : null} {statsNode.disabledCount ? ( {statsNode.disabledCount} {t('disabled')} ) : null} {statsNode.depletedCount ? ( {statsNode.depletedCount} {t('depleted')} ) : null} {statsNode.onlineCount ? ( {statsNode.onlineCount} {t('online')} ) : null}
{t('pages.nodes.lastHeartbeat')} {relativeTime(statsNode.lastHeartbeat)}
)}
) : ( dataSource={dataSource} columns={columns} pagination={false} loading={loading} scroll={{ x: 'max-content' }} size="middle" rowKey="key" 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: (
{t('noData')}
), }} expandable={{ expandedRowRender: (record) => , rowExpandable: (record) => !record.transitive, }} /> )} ); }