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 { 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 './NodeList.css'; interface NodeListProps { nodes: NodeRecord[]; loading?: boolean; isMobile?: boolean; onAdd: () => void; onEdit: (node: NodeRecord) => void; onDelete: (node: NodeRecord) => void; onProbe: (node: NodeRecord) => void; onToggleEnable: (node: NodeRecord, next: boolean) => void; } interface NodeRow extends NodeRecord { url: string; key: number; } function badgeStatus(status?: string): BadgeProps['status'] { switch (status) { case 'online': return 'success'; case 'offline': return 'error'; default: return 'default'; } } 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, onAdd, onEdit, onDelete, onProbe, onToggleEnable, }: NodeListProps) { const { t } = useTranslation(); const relativeTime = useRelativeTime(); const [showAddress, setShowAddress] = useState(false); const [statsNode, setStatsNode] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); const dataSource = useMemo( () => nodes.map((n) => ({ ...n, url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`, key: n.id, })), [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: 160, render: (_value, record) => ( {isMobile ? ( <>
{dataSource.length === 0 ? (
) : ( dataSource.map((record) => (
toggleExpanded(record.id)}> {record.name}
e.stopPropagation()}> setStatsNode(record)} /> onToggleEnable(record, v)} /> {t('pages.nodes.probe')}, onClick: () => onProbe(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)} /> ) : ( setShowAddress(true)} /> )}
{t('pages.nodes.status')} {t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)} {statsNode.lastError && ( )}
{t('pages.nodes.cpu')} {formatPct(statsNode.cpuPct)}
{t('pages.nodes.mem')} {formatPct(statsNode.memPct)}
{t('pages.nodes.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.onlineCount ? ( {statsNode.onlineCount} {t('online')} ) : null} {statsNode.depletedCount ? ( {statsNode.depletedCount} {t('depleted')} ) : null}
{t('pages.nodes.lastHeartbeat')} {relativeTime(statsNode.lastHeartbeat)}
)}
) : ( dataSource={dataSource} columns={columns} pagination={false} loading={loading} scroll={{ x: 'max-content' }} size="middle" rowKey="id" expandable={{ expandedRowRender: (record) => , }} /> )} ); }