NodeList.tsx 15 KB

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