NodeList.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import { useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Badge,
  5. Button,
  6. Card,
  7. Dropdown,
  8. Modal,
  9. Space,
  10. Switch,
  11. Table,
  12. Tag,
  13. Tooltip,
  14. } from 'antd';
  15. import type { BadgeProps } from 'antd';
  16. import type { ColumnsType } from 'antd/es/table';
  17. import {
  18. ClusterOutlined,
  19. CloudDownloadOutlined,
  20. DeleteOutlined,
  21. EditOutlined,
  22. ExclamationCircleOutlined,
  23. EyeInvisibleOutlined,
  24. EyeOutlined,
  25. InfoCircleOutlined,
  26. MoreOutlined,
  27. PlusOutlined,
  28. RightOutlined,
  29. ThunderboltOutlined,
  30. } from '@ant-design/icons';
  31. import NodeHistoryPanel from './NodeHistoryPanel';
  32. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  33. import { isPanelUpdateAvailable } from '@/lib/panel-version';
  34. import './NodeList.css';
  35. interface NodeListProps {
  36. nodes: NodeRecord[];
  37. loading?: boolean;
  38. isMobile?: boolean;
  39. latestVersion?: string;
  40. selectedIds: number[];
  41. onSelectionChange: (ids: number[]) => void;
  42. onAdd: () => void;
  43. onEdit: (node: NodeRecord) => void;
  44. onDelete: (node: NodeRecord) => void;
  45. onProbe: (node: NodeRecord) => void;
  46. onToggleEnable: (node: NodeRecord, next: boolean) => void;
  47. onUpdateNode: (node: NodeRecord) => void;
  48. onUpdateSelected: () => void;
  49. }
  50. function isUpdateEligible(n: NodeRecord): boolean {
  51. return !!n.enable && n.status === 'online';
  52. }
  53. interface NodeRow extends NodeRecord {
  54. url: string;
  55. key: number;
  56. }
  57. function badgeStatus(status?: string): BadgeProps['status'] {
  58. switch (status) {
  59. case 'online': return 'success';
  60. case 'offline': return 'error';
  61. default: return 'default';
  62. }
  63. }
  64. function StatusDot({ status }: { status?: string }) {
  65. if (status === 'online') return <span className="online-dot" />;
  66. return <Badge status={badgeStatus(status)} />;
  67. }
  68. function StatusLabel({ status }: { status?: string }) {
  69. const { t } = useTranslation();
  70. return (
  71. <span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}>
  72. {t(`pages.nodes.statusValues.${status || 'unknown'}`)}
  73. </span>
  74. );
  75. }
  76. function formatPct(p?: number): string {
  77. if (typeof p !== 'number' || Number.isNaN(p)) return '-';
  78. return `${p.toFixed(1)}%`;
  79. }
  80. function formatUptime(secs?: number): string {
  81. if (!secs) return '-';
  82. const days = Math.floor(secs / 86400);
  83. const hours = Math.floor((secs % 86400) / 3600);
  84. if (days > 0) return `${days}d ${hours}h`;
  85. const mins = Math.floor((secs % 3600) / 60);
  86. if (hours > 0) return `${hours}h ${mins}m`;
  87. return `${mins}m`;
  88. }
  89. function useRelativeTime() {
  90. const { t } = useTranslation();
  91. return (unixSeconds?: number) => {
  92. if (!unixSeconds) return t('pages.nodes.never');
  93. const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
  94. if (diffSec < 5) return t('pages.nodes.justNow');
  95. if (diffSec < 60) return `${diffSec}s`;
  96. if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
  97. if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
  98. return `${Math.floor(diffSec / 86400)}d`;
  99. };
  100. }
  101. export default function NodeList({
  102. nodes,
  103. loading = false,
  104. isMobile = false,
  105. latestVersion = '',
  106. selectedIds,
  107. onSelectionChange,
  108. onAdd,
  109. onEdit,
  110. onDelete,
  111. onProbe,
  112. onToggleEnable,
  113. onUpdateNode,
  114. onUpdateSelected,
  115. }: NodeListProps) {
  116. const { t } = useTranslation();
  117. const relativeTime = useRelativeTime();
  118. const [showAddress, setShowAddress] = useState(false);
  119. const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
  120. const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
  121. const dataSource = useMemo<NodeRow[]>(
  122. () => nodes.map((n) => ({
  123. ...n,
  124. url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
  125. key: n.id,
  126. })),
  127. [nodes],
  128. );
  129. function toggleExpanded(id: number) {
  130. setExpandedIds((prev) => {
  131. const next = new Set(prev);
  132. if (next.has(id)) next.delete(id); else next.add(id);
  133. return next;
  134. });
  135. }
  136. const columns = useMemo<ColumnsType<NodeRow>>(() => [
  137. {
  138. title: t('pages.nodes.actions'),
  139. align: 'center',
  140. width: 190,
  141. render: (_value, record) => (
  142. <Space>
  143. <Tooltip title={t('pages.nodes.probe')}>
  144. <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
  145. </Tooltip>
  146. {isUpdateEligible(record) && (
  147. <Tooltip title={t('pages.nodes.updatePanel')}>
  148. <Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
  149. </Tooltip>
  150. )}
  151. <Tooltip title={t('edit')}>
  152. <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
  153. </Tooltip>
  154. <Tooltip title={t('delete')}>
  155. <Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
  156. </Tooltip>
  157. </Space>
  158. ),
  159. },
  160. {
  161. title: t('pages.nodes.enable'),
  162. dataIndex: 'enable',
  163. align: 'center',
  164. width: 80,
  165. render: (_value, record) => (
  166. <Switch
  167. checked={!!record.enable}
  168. size="small"
  169. onChange={(v) => onToggleEnable(record, v)}
  170. />
  171. ),
  172. },
  173. {
  174. title: t('pages.nodes.name'),
  175. dataIndex: 'name',
  176. ellipsis: true,
  177. render: (_value, record) => (
  178. <div className="name-cell">
  179. <span className="name">{record.name}</span>
  180. {record.remark && <span className="remark">{record.remark}</span>}
  181. </div>
  182. ),
  183. },
  184. {
  185. title: (
  186. <span className="address-header">
  187. {t('pages.nodes.address')}
  188. <Tooltip title={t('pages.index.toggleIpVisibility')}>
  189. {showAddress ? (
  190. <EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
  191. ) : (
  192. <EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
  193. )}
  194. </Tooltip>
  195. </span>
  196. ),
  197. dataIndex: 'url',
  198. ellipsis: true,
  199. render: (_value, record) => (
  200. <a
  201. href={record.url}
  202. target="_blank"
  203. rel="noopener noreferrer"
  204. className={showAddress ? 'address-visible' : 'address-hidden'}
  205. >
  206. {record.url}
  207. </a>
  208. ),
  209. },
  210. {
  211. title: t('pages.nodes.status'),
  212. dataIndex: 'status',
  213. align: 'center',
  214. render: (_value, record) => (
  215. <Space size={4}>
  216. <StatusDot status={record.status} />
  217. <StatusLabel status={record.status} />
  218. {record.lastError && (
  219. <Tooltip title={record.lastError}>
  220. <ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
  221. </Tooltip>
  222. )}
  223. </Space>
  224. ),
  225. },
  226. {
  227. title: t('pages.nodes.cpu'),
  228. dataIndex: 'cpuPct',
  229. align: 'center',
  230. width: 90,
  231. render: (_value, record) => formatPct(record.cpuPct),
  232. },
  233. {
  234. title: t('pages.nodes.mem'),
  235. dataIndex: 'memPct',
  236. align: 'center',
  237. width: 90,
  238. render: (_value, record) => formatPct(record.memPct),
  239. },
  240. {
  241. title: t('pages.nodes.xrayVersion'),
  242. dataIndex: 'xrayVersion',
  243. align: 'center',
  244. render: (_value, record) => record.xrayVersion || '-',
  245. },
  246. {
  247. title: t('pages.nodes.panelVersion') || 'Panel version',
  248. dataIndex: 'panelVersion',
  249. align: 'center',
  250. render: (_value, record) => {
  251. const canUpdate = isUpdateEligible(record)
  252. && isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
  253. return (
  254. <Space size={4}>
  255. <span>{record.panelVersion || '-'}</span>
  256. {canUpdate && (
  257. <Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
  258. <Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
  259. {t('pages.nodes.updateAvailable')}
  260. </Tag>
  261. </Tooltip>
  262. )}
  263. </Space>
  264. );
  265. },
  266. },
  267. {
  268. title: t('pages.nodes.uptime'),
  269. dataIndex: 'uptimeSecs',
  270. align: 'center',
  271. render: (_value, record) => formatUptime(record.uptimeSecs),
  272. },
  273. {
  274. title: t('clients'),
  275. align: 'center',
  276. width: 160,
  277. render: (_value, record) => (
  278. <Space size={4}>
  279. <Tag color="green">{record.clientCount || 0}</Tag>
  280. {record.onlineCount ? (
  281. <Tag color="blue">{record.onlineCount} {t('online')}</Tag>
  282. ) : null}
  283. {record.depletedCount ? (
  284. <Tag color="red">{record.depletedCount} {t('depleted')}</Tag>
  285. ) : null}
  286. </Space>
  287. ),
  288. },
  289. {
  290. title: t('pages.nodes.latency'),
  291. dataIndex: 'latencyMs',
  292. align: 'center',
  293. width: 100,
  294. render: (_value, record) =>
  295. record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
  296. },
  297. {
  298. title: t('pages.nodes.lastHeartbeat'),
  299. dataIndex: 'lastHeartbeat',
  300. align: 'center',
  301. width: 120,
  302. render: (_value, record) => relativeTime(record.lastHeartbeat),
  303. },
  304. ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
  305. return (
  306. <Card size="small" hoverable>
  307. <div className="toolbar">
  308. <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
  309. {t('pages.nodes.addNode')}
  310. </Button>
  311. {selectedIds.length > 0 && (
  312. <Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
  313. {t('pages.nodes.updateSelected', { count: selectedIds.length })}
  314. </Button>
  315. )}
  316. </div>
  317. {isMobile ? (
  318. <>
  319. <div className="node-cards">
  320. {dataSource.length === 0 ? (
  321. <div className="card-empty">
  322. <ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} />
  323. <div>{t('noData')}</div>
  324. </div>
  325. ) : (
  326. dataSource.map((record) => (
  327. <div key={record.id} className="node-card">
  328. <div className="card-head" onClick={() => toggleExpanded(record.id)}>
  329. <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
  330. <StatusDot status={record.status} />
  331. <span className="node-name">{record.name}</span>
  332. <div className="card-actions" onClick={(e) => e.stopPropagation()}>
  333. <Tooltip title={t('info')}>
  334. <InfoCircleOutlined
  335. className="row-action-trigger"
  336. onClick={() => setStatsNode(record)}
  337. />
  338. </Tooltip>
  339. <Switch
  340. checked={!!record.enable}
  341. size="small"
  342. onChange={(v) => onToggleEnable(record, v)}
  343. />
  344. <Dropdown
  345. trigger={['click']}
  346. placement="bottomRight"
  347. menu={{
  348. items: [
  349. {
  350. key: 'probe',
  351. label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
  352. onClick: () => onProbe(record),
  353. },
  354. ...(isUpdateEligible(record) ? [{
  355. key: 'update',
  356. label: <><CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}</>,
  357. onClick: () => onUpdateNode(record),
  358. }] : []),
  359. {
  360. key: 'edit',
  361. label: <><EditOutlined /> {t('edit')}</>,
  362. onClick: () => onEdit(record),
  363. },
  364. {
  365. key: 'delete',
  366. danger: true,
  367. label: <><DeleteOutlined /> {t('delete')}</>,
  368. onClick: () => onDelete(record),
  369. },
  370. ],
  371. }}
  372. >
  373. <MoreOutlined className="row-action-trigger" />
  374. </Dropdown>
  375. </div>
  376. </div>
  377. {expandedIds.has(record.id) && (
  378. <div className="card-history">
  379. <NodeHistoryPanel node={record} />
  380. </div>
  381. )}
  382. </div>
  383. ))
  384. )}
  385. </div>
  386. <Modal
  387. open={!!statsNode}
  388. footer={null}
  389. width={360}
  390. centered
  391. title={statsNode?.name || ''}
  392. onCancel={() => setStatsNode(null)}
  393. >
  394. {statsNode && (
  395. <div className="card-stats">
  396. {statsNode.remark && (
  397. <div className="stat-row">
  398. <span className="stat-label">{t('pages.nodes.name')}</span>
  399. <span>{statsNode.remark}</span>
  400. </div>
  401. )}
  402. <div className="stat-row">
  403. <span className="stat-label">{t('pages.nodes.address')}</span>
  404. <a
  405. href={statsNode.url}
  406. target="_blank"
  407. rel="noopener noreferrer"
  408. className={showAddress ? 'address-visible' : 'address-hidden'}
  409. >
  410. {statsNode.url}
  411. </a>
  412. <Tooltip title={t('pages.index.toggleIpVisibility')}>
  413. {showAddress ? (
  414. <EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
  415. ) : (
  416. <EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
  417. )}
  418. </Tooltip>
  419. </div>
  420. <div className="stat-row">
  421. <span className="stat-label">{t('pages.nodes.status')}</span>
  422. <StatusDot status={statsNode.status} />
  423. <StatusLabel status={statsNode.status} />
  424. {statsNode.lastError && (
  425. <Tooltip title={statsNode.lastError}>
  426. <ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
  427. </Tooltip>
  428. )}
  429. </div>
  430. <div className="stat-row">
  431. <span className="stat-label">{t('pages.nodes.cpu')}</span>
  432. <Tag>{formatPct(statsNode.cpuPct)}</Tag>
  433. </div>
  434. <div className="stat-row">
  435. <span className="stat-label">{t('pages.nodes.mem')}</span>
  436. <Tag>{formatPct(statsNode.memPct)}</Tag>
  437. </div>
  438. <div className="stat-row">
  439. <span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
  440. <Tag>{statsNode.xrayVersion || '-'}</Tag>
  441. </div>
  442. <div className="stat-row">
  443. <span className="stat-label">{t('pages.nodes.panelVersion') || 'Panel version'}</span>
  444. <Tag>{statsNode.panelVersion || '-'}</Tag>
  445. </div>
  446. <div className="stat-row">
  447. <span className="stat-label">{t('pages.nodes.uptime')}</span>
  448. <Tag>{formatUptime(statsNode.uptimeSecs)}</Tag>
  449. </div>
  450. <div className="stat-row">
  451. <span className="stat-label">{t('pages.nodes.latency')}</span>
  452. <Tag>
  453. {statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'}
  454. </Tag>
  455. </div>
  456. <div className="stat-row">
  457. <span className="stat-label">{t('clients')}</span>
  458. <Tag color="green">{statsNode.clientCount || 0}</Tag>
  459. {statsNode.onlineCount ? (
  460. <Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
  461. ) : null}
  462. {statsNode.depletedCount ? (
  463. <Tag color="red">{statsNode.depletedCount} {t('depleted')}</Tag>
  464. ) : null}
  465. </div>
  466. <div className="stat-row">
  467. <span className="stat-label">{t('pages.nodes.lastHeartbeat')}</span>
  468. <Tag>{relativeTime(statsNode.lastHeartbeat)}</Tag>
  469. </div>
  470. </div>
  471. )}
  472. </Modal>
  473. </>
  474. ) : (
  475. <Table<NodeRow>
  476. dataSource={dataSource}
  477. columns={columns}
  478. pagination={false}
  479. loading={loading}
  480. scroll={{ x: 'max-content' }}
  481. size="middle"
  482. rowKey="id"
  483. rowSelection={{
  484. selectedRowKeys: selectedIds,
  485. onChange: (keys) => onSelectionChange(keys as number[]),
  486. getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
  487. }}
  488. locale={{
  489. emptyText: (
  490. <div className="card-empty">
  491. <ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  492. <div>{t('noData')}</div>
  493. </div>
  494. ),
  495. }}
  496. expandable={{
  497. expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
  498. }}
  499. />
  500. )}
  501. </Card>
  502. );
  503. }