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 { 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: number; } function badgeStatus(status?: string): BadgeProps['status'] { switch (status) { case 'online': return 'success'; case 'offline': return 'error'; default: return 'default'; } } function StatusDot({ status }: { status?: string }) { if (status === 'online') return ; return ; } function StatusLabel({ status }: { status?: string }) { const { t } = useTranslation(); 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, 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()); 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: 190, render: (_value, record) => ( {selectedIds.length > 0 && ( )} {isMobile ? ( <>
{dataSource.length === 0 ? (
{t('noData')}
) : ( dataSource.map((record) => (
toggleExpanded(record.id)}> {record.name}
e.stopPropagation()}> setStatsNode(record)} /> onToggleEnable(record, v)} /> {t('pages.nodes.probe')}, onClick: () => onProbe(record), }, ...(isUpdateEligible(record) ? [{ key: 'update', label: <> {t('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)} /> ) : ( setShowAddress(true)} /> )}
{t('pages.nodes.status')} {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" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => onSelectionChange(keys as number[]), getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }), }} locale={{ emptyText: (
{t('noData')}
), }} expandable={{ expandedRowRender: (record) => , }} /> )} ); }