NodeList.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  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. ApartmentOutlined,
  19. ClusterOutlined,
  20. CloudDownloadOutlined,
  21. DeleteOutlined,
  22. EditOutlined,
  23. ExclamationCircleOutlined,
  24. EyeInvisibleOutlined,
  25. EyeOutlined,
  26. InfoCircleOutlined,
  27. MoreOutlined,
  28. PlusOutlined,
  29. RightOutlined,
  30. ThunderboltOutlined,
  31. } from '@ant-design/icons';
  32. import NodeHistoryPanel from './NodeHistoryPanel';
  33. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  34. import { isPanelUpdateAvailable } from '@/lib/panel-version';
  35. import './NodeList.css';
  36. interface NodeListProps {
  37. nodes: NodeRecord[];
  38. loading?: boolean;
  39. isMobile?: boolean;
  40. latestVersion?: string;
  41. selectedIds: number[];
  42. onSelectionChange: (ids: number[]) => void;
  43. onAdd: () => void;
  44. onEdit: (node: NodeRecord) => void;
  45. onDelete: (node: NodeRecord) => void;
  46. onProbe: (node: NodeRecord) => void;
  47. onToggleEnable: (node: NodeRecord, next: boolean) => void;
  48. onUpdateNode: (node: NodeRecord) => void;
  49. onUpdateSelected: () => void;
  50. }
  51. function isUpdateEligible(n: NodeRecord): boolean {
  52. return !!n.enable && n.status === 'online';
  53. }
  54. interface NodeRow extends NodeRecord {
  55. url: string;
  56. key: string | number;
  57. }
  58. function badgeStatus(status?: string): BadgeProps['status'] {
  59. switch (status) {
  60. case 'online': return 'success';
  61. case 'offline': return 'error';
  62. default: return 'default';
  63. }
  64. }
  65. interface HealthProps {
  66. status?: string;
  67. xrayState?: string;
  68. xrayError?: string;
  69. }
  70. // Purple: the node's panel API is reachable (status=online) but its Xray core
  71. // has failed or been stopped. Distinct from a normal offline/unknown node.
  72. const XRAY_ERROR_COLOR = '#722ED1';
  73. // True when the panel is online but Xray itself reports error/stop.
  74. function hasXrayProblem(status?: string, xrayState?: string): boolean {
  75. if (status !== 'online') return false;
  76. const xs = (xrayState || '').toLowerCase().trim();
  77. return xs === 'error' || xs === 'stop';
  78. }
  79. // Tooltip text + icon color for the status cell. A real probe error (lastError)
  80. // is a warning and takes precedence; otherwise an Xray-core problem shows purple.
  81. function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
  82. const tip = record.lastError || (hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') || '';
  83. const iconColor = !record.lastError && hasXrayProblem(record.status, record.xrayState)
  84. ? XRAY_ERROR_COLOR
  85. : 'var(--ant-color-warning)';
  86. return { tip, iconColor };
  87. }
  88. function StatusDot({ status, xrayState }: HealthProps) {
  89. if (status === 'online') {
  90. return hasXrayProblem(status, xrayState)
  91. ? <span className="xray-error-dot" />
  92. : <span className="online-dot" />;
  93. }
  94. return <Badge status={badgeStatus(status)} />;
  95. }
  96. function StatusLabel({ status, xrayState }: HealthProps) {
  97. const { t } = useTranslation();
  98. if (status === 'online') {
  99. const xs = (xrayState || '').toLowerCase().trim();
  100. if (xs === 'error' || xs === 'stop') {
  101. const detail = xs === 'error'
  102. ? t('pages.nodes.statusValues.xrayError')
  103. : t('pages.nodes.statusValues.xrayStopped');
  104. return (
  105. <span style={{ color: XRAY_ERROR_COLOR }}>
  106. {t('pages.nodes.statusValues.online')} ({detail})
  107. </span>
  108. );
  109. }
  110. return (
  111. <span style={{ color: 'var(--ant-color-success)' }}>
  112. {t('pages.nodes.statusValues.online')}
  113. </span>
  114. );
  115. }
  116. return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
  117. }
  118. function formatPct(p?: number): string {
  119. if (typeof p !== 'number' || Number.isNaN(p)) return '-';
  120. return `${p.toFixed(1)}%`;
  121. }
  122. function formatUptime(secs?: number): string {
  123. if (!secs) return '-';
  124. const days = Math.floor(secs / 86400);
  125. const hours = Math.floor((secs % 86400) / 3600);
  126. if (days > 0) return `${days}d ${hours}h`;
  127. const mins = Math.floor((secs % 3600) / 60);
  128. if (hours > 0) return `${hours}h ${mins}m`;
  129. return `${mins}m`;
  130. }
  131. function useRelativeTime() {
  132. const { t } = useTranslation();
  133. return (unixSeconds?: number) => {
  134. if (!unixSeconds) return t('pages.nodes.never');
  135. const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
  136. if (diffSec < 5) return t('pages.nodes.justNow');
  137. if (diffSec < 60) return `${diffSec}s`;
  138. if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
  139. if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
  140. return `${Math.floor(diffSec / 86400)}d`;
  141. };
  142. }
  143. export default function NodeList({
  144. nodes,
  145. loading = false,
  146. isMobile = false,
  147. latestVersion = '',
  148. selectedIds,
  149. onSelectionChange,
  150. onAdd,
  151. onEdit,
  152. onDelete,
  153. onProbe,
  154. onToggleEnable,
  155. onUpdateNode,
  156. onUpdateSelected,
  157. }: NodeListProps) {
  158. const { t } = useTranslation();
  159. const relativeTime = useRelativeTime();
  160. const [showAddress, setShowAddress] = useState(false);
  161. const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
  162. const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
  163. // Map a node GUID to its display name so a transitive sub-node can show which
  164. // parent it is reached through (#4983).
  165. const nameByGuid = useMemo(() => {
  166. const m = new Map<string, string>();
  167. for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
  168. return m;
  169. }, [nodes]);
  170. // Order direct nodes first, each immediately followed by its transitive
  171. // sub-nodes, so the table reads as a parent -> child tree without colliding
  172. // with the per-row history expander (transitive nodes carry id 0).
  173. const dataSource = useMemo<NodeRow[]>(() => {
  174. const toRow = (n: NodeRecord): NodeRow => ({
  175. ...n,
  176. url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
  177. key: n.transitive ? `t-${n.guid || ''}` : n.id,
  178. });
  179. const childrenByParent = new Map<string, NodeRecord[]>();
  180. for (const n of nodes) {
  181. if (n.transitive && n.parentGuid) {
  182. const arr = childrenByParent.get(n.parentGuid) || [];
  183. arr.push(n);
  184. childrenByParent.set(n.parentGuid, arr);
  185. }
  186. }
  187. const ordered: NodeRow[] = [];
  188. const added = new Set<string>();
  189. const push = (n: NodeRecord) => {
  190. const row = toRow(n);
  191. ordered.push(row);
  192. added.add(String(row.key));
  193. };
  194. for (const n of nodes) {
  195. if (n.transitive) continue;
  196. push(n);
  197. if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
  198. }
  199. // Transitive nodes whose parent isn't in the list still get shown.
  200. for (const n of nodes) {
  201. if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
  202. }
  203. return ordered;
  204. }, [nodes]);
  205. function toggleExpanded(id: number) {
  206. setExpandedIds((prev) => {
  207. const next = new Set(prev);
  208. if (next.has(id)) next.delete(id); else next.add(id);
  209. return next;
  210. });
  211. }
  212. const columns = useMemo<ColumnsType<NodeRow>>(() => [
  213. {
  214. title: t('pages.nodes.actions'),
  215. align: 'center',
  216. width: 190,
  217. render: (_value, record) => record.transitive ? (
  218. <Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
  219. <Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
  220. </Tooltip>
  221. ) : (
  222. <Space>
  223. <Tooltip title={t('pages.nodes.probe')}>
  224. <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
  225. </Tooltip>
  226. {isUpdateEligible(record) && (
  227. <Tooltip title={t('pages.nodes.updatePanel')}>
  228. <Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
  229. </Tooltip>
  230. )}
  231. <Tooltip title={t('edit')}>
  232. <Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
  233. </Tooltip>
  234. <Tooltip title={t('delete')}>
  235. <Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
  236. </Tooltip>
  237. </Space>
  238. ),
  239. },
  240. {
  241. title: t('pages.nodes.enable'),
  242. dataIndex: 'enable',
  243. align: 'center',
  244. width: 80,
  245. render: (_value, record) => record.transitive ? (
  246. <span style={{ opacity: 0.4 }}>—</span>
  247. ) : (
  248. <Switch
  249. checked={!!record.enable}
  250. size="small"
  251. onChange={(v) => onToggleEnable(record, v)}
  252. />
  253. ),
  254. },
  255. {
  256. title: t('pages.nodes.name'),
  257. dataIndex: 'name',
  258. ellipsis: true,
  259. render: (_value, record) => (
  260. <div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
  261. <span className="name">
  262. {record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
  263. {record.name}
  264. </span>
  265. {record.remark && <span className="remark">{record.remark}</span>}
  266. </div>
  267. ),
  268. },
  269. {
  270. title: (
  271. <span className="address-header">
  272. {t('pages.nodes.address')}
  273. <Tooltip title={t('pages.index.toggleIpVisibility')}>
  274. {showAddress ? (
  275. <EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
  276. ) : (
  277. <EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
  278. )}
  279. </Tooltip>
  280. </span>
  281. ),
  282. dataIndex: 'url',
  283. ellipsis: true,
  284. render: (_value, record) => (
  285. <a
  286. href={record.url}
  287. target="_blank"
  288. rel="noopener noreferrer"
  289. className={showAddress ? 'address-visible' : 'address-hidden'}
  290. >
  291. {record.url}
  292. </a>
  293. ),
  294. },
  295. {
  296. title: t('pages.nodes.status'),
  297. dataIndex: 'status',
  298. align: 'center',
  299. render: (_value, record) => {
  300. const { tip, iconColor } = statusIssue(record);
  301. return (
  302. <Space size={4}>
  303. <StatusDot status={record.status} xrayState={record.xrayState} />
  304. <StatusLabel status={record.status} xrayState={record.xrayState} />
  305. {tip && (
  306. <Tooltip title={tip}>
  307. <ExclamationCircleOutlined style={{ color: iconColor }} />
  308. </Tooltip>
  309. )}
  310. </Space>
  311. );
  312. },
  313. },
  314. {
  315. title: t('pages.nodes.cpu'),
  316. dataIndex: 'cpuPct',
  317. align: 'center',
  318. width: 90,
  319. render: (_value, record) => formatPct(record.cpuPct),
  320. },
  321. {
  322. title: t('pages.nodes.mem'),
  323. dataIndex: 'memPct',
  324. align: 'center',
  325. width: 90,
  326. render: (_value, record) => formatPct(record.memPct),
  327. },
  328. {
  329. title: t('pages.nodes.xrayVersion'),
  330. dataIndex: 'xrayVersion',
  331. align: 'center',
  332. render: (_value, record) => record.xrayVersion || '-',
  333. },
  334. {
  335. title: t('pages.nodes.panelVersion') || 'Panel version',
  336. dataIndex: 'panelVersion',
  337. align: 'center',
  338. render: (_value, record) => {
  339. const canUpdate = isUpdateEligible(record)
  340. && isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
  341. return (
  342. <Space size={4}>
  343. <span>{record.panelVersion || '-'}</span>
  344. {canUpdate && (
  345. <Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
  346. <Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
  347. {t('pages.nodes.updateAvailable')}
  348. </Tag>
  349. </Tooltip>
  350. )}
  351. </Space>
  352. );
  353. },
  354. },
  355. {
  356. title: t('pages.nodes.uptime'),
  357. dataIndex: 'uptimeSecs',
  358. align: 'center',
  359. render: (_value, record) => formatUptime(record.uptimeSecs),
  360. },
  361. {
  362. title: t('clients'),
  363. align: 'center',
  364. width: 160,
  365. render: (_value, record) => (
  366. <Space size={4}>
  367. <Tag color="green">{record.clientCount || 0}</Tag>
  368. {record.onlineCount ? (
  369. <Tag color="blue">{record.onlineCount} {t('online')}</Tag>
  370. ) : null}
  371. {record.depletedCount ? (
  372. <Tag color="red">{record.depletedCount} {t('depleted')}</Tag>
  373. ) : null}
  374. </Space>
  375. ),
  376. },
  377. {
  378. title: t('pages.nodes.latency'),
  379. dataIndex: 'latencyMs',
  380. align: 'center',
  381. width: 100,
  382. render: (_value, record) =>
  383. record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
  384. },
  385. {
  386. title: t('pages.nodes.lastHeartbeat'),
  387. dataIndex: 'lastHeartbeat',
  388. align: 'center',
  389. width: 120,
  390. render: (_value, record) => relativeTime(record.lastHeartbeat),
  391. },
  392. ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
  393. return (
  394. <Card size="small" hoverable>
  395. <div className="toolbar">
  396. <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
  397. {t('pages.nodes.addNode')}
  398. </Button>
  399. {selectedIds.length > 0 && (
  400. <Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
  401. {t('pages.nodes.updateSelected', { count: selectedIds.length })}
  402. </Button>
  403. )}
  404. </div>
  405. {isMobile ? (
  406. <>
  407. <div className="node-cards">
  408. {dataSource.length === 0 ? (
  409. <div className="card-empty">
  410. <ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} />
  411. <div>{t('noData')}</div>
  412. </div>
  413. ) : (
  414. dataSource.map((record) => record.transitive ? (
  415. <div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
  416. <div className="card-head">
  417. <ApartmentOutlined style={{ opacity: 0.6 }} />
  418. <StatusDot status={record.status} xrayState={record.xrayState} />
  419. <span className="node-name">{record.name}</span>
  420. <div className="card-actions">
  421. <Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
  422. </div>
  423. </div>
  424. </div>
  425. ) : (
  426. <div key={record.id} className="node-card">
  427. <div className="card-head" onClick={() => toggleExpanded(record.id)}>
  428. <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
  429. <StatusDot status={record.status} xrayState={record.xrayState} />
  430. <span className="node-name">{record.name}</span>
  431. <div className="card-actions" onClick={(e) => e.stopPropagation()}>
  432. <Tooltip title={t('info')}>
  433. <InfoCircleOutlined
  434. className="row-action-trigger"
  435. onClick={() => setStatsNode(record)}
  436. />
  437. </Tooltip>
  438. <Switch
  439. checked={!!record.enable}
  440. size="small"
  441. onChange={(v) => onToggleEnable(record, v)}
  442. />
  443. <Dropdown
  444. trigger={['click']}
  445. placement="bottomRight"
  446. menu={{
  447. items: [
  448. {
  449. key: 'probe',
  450. label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
  451. onClick: () => onProbe(record),
  452. },
  453. ...(isUpdateEligible(record) ? [{
  454. key: 'update',
  455. label: <><CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}</>,
  456. onClick: () => onUpdateNode(record),
  457. }] : []),
  458. {
  459. key: 'edit',
  460. label: <><EditOutlined /> {t('edit')}</>,
  461. onClick: () => onEdit(record),
  462. },
  463. {
  464. key: 'delete',
  465. danger: true,
  466. label: <><DeleteOutlined /> {t('delete')}</>,
  467. onClick: () => onDelete(record),
  468. },
  469. ],
  470. }}
  471. >
  472. <MoreOutlined className="row-action-trigger" />
  473. </Dropdown>
  474. </div>
  475. </div>
  476. {expandedIds.has(record.id) && (
  477. <div className="card-history">
  478. <NodeHistoryPanel node={record} />
  479. </div>
  480. )}
  481. </div>
  482. ))
  483. )}
  484. </div>
  485. <Modal
  486. open={!!statsNode}
  487. footer={null}
  488. width={360}
  489. centered
  490. title={statsNode?.name || ''}
  491. onCancel={() => setStatsNode(null)}
  492. >
  493. {statsNode && (
  494. <div className="card-stats">
  495. {statsNode.remark && (
  496. <div className="stat-row">
  497. <span className="stat-label">{t('pages.nodes.name')}</span>
  498. <span>{statsNode.remark}</span>
  499. </div>
  500. )}
  501. <div className="stat-row">
  502. <span className="stat-label">{t('pages.nodes.address')}</span>
  503. <a
  504. href={statsNode.url}
  505. target="_blank"
  506. rel="noopener noreferrer"
  507. className={showAddress ? 'address-visible' : 'address-hidden'}
  508. >
  509. {statsNode.url}
  510. </a>
  511. <Tooltip title={t('pages.index.toggleIpVisibility')}>
  512. {showAddress ? (
  513. <EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
  514. ) : (
  515. <EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
  516. )}
  517. </Tooltip>
  518. </div>
  519. <div className="stat-row">
  520. <span className="stat-label">{t('pages.nodes.status')}</span>
  521. <StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
  522. <StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
  523. {(() => {
  524. const { tip, iconColor } = statusIssue(statsNode);
  525. return tip ? (
  526. <Tooltip title={tip}>
  527. <ExclamationCircleOutlined style={{ color: iconColor }} />
  528. </Tooltip>
  529. ) : null;
  530. })()}
  531. </div>
  532. <div className="stat-row">
  533. <span className="stat-label">{t('pages.nodes.cpu')}</span>
  534. <Tag>{formatPct(statsNode.cpuPct)}</Tag>
  535. </div>
  536. <div className="stat-row">
  537. <span className="stat-label">{t('pages.nodes.mem')}</span>
  538. <Tag>{formatPct(statsNode.memPct)}</Tag>
  539. </div>
  540. <div className="stat-row">
  541. <span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
  542. <Tag>{statsNode.xrayVersion || '-'}</Tag>
  543. </div>
  544. <div className="stat-row">
  545. <span className="stat-label">{t('pages.nodes.panelVersion') || 'Panel version'}</span>
  546. <Tag>{statsNode.panelVersion || '-'}</Tag>
  547. </div>
  548. <div className="stat-row">
  549. <span className="stat-label">{t('pages.nodes.uptime')}</span>
  550. <Tag>{formatUptime(statsNode.uptimeSecs)}</Tag>
  551. </div>
  552. <div className="stat-row">
  553. <span className="stat-label">{t('pages.nodes.latency')}</span>
  554. <Tag>
  555. {statsNode.latencyMs && statsNode.latencyMs > 0 ? `${statsNode.latencyMs} ms` : '-'}
  556. </Tag>
  557. </div>
  558. <div className="stat-row">
  559. <span className="stat-label">{t('clients')}</span>
  560. <Tag color="green">{statsNode.clientCount || 0}</Tag>
  561. {statsNode.onlineCount ? (
  562. <Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
  563. ) : null}
  564. {statsNode.depletedCount ? (
  565. <Tag color="red">{statsNode.depletedCount} {t('depleted')}</Tag>
  566. ) : null}
  567. </div>
  568. <div className="stat-row">
  569. <span className="stat-label">{t('pages.nodes.lastHeartbeat')}</span>
  570. <Tag>{relativeTime(statsNode.lastHeartbeat)}</Tag>
  571. </div>
  572. </div>
  573. )}
  574. </Modal>
  575. </>
  576. ) : (
  577. <Table<NodeRow>
  578. dataSource={dataSource}
  579. columns={columns}
  580. pagination={false}
  581. loading={loading}
  582. scroll={{ x: 'max-content' }}
  583. size="middle"
  584. rowKey="id"
  585. rowSelection={dataSource.length > 1 ? {
  586. selectedRowKeys: selectedIds,
  587. onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
  588. getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
  589. } : undefined}
  590. locale={{
  591. emptyText: (
  592. <div className="card-empty">
  593. <ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  594. <div>{t('noData')}</div>
  595. </div>
  596. ),
  597. }}
  598. expandable={{
  599. expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
  600. rowExpandable: (record) => !record.transitive,
  601. }}
  602. />
  603. )}
  604. </Card>
  605. );
  606. }