NodesPage.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery } from '@tanstack/react-query';
  4. import {
  5. Alert,
  6. Button,
  7. Card,
  8. Checkbox,
  9. Col,
  10. ConfigProvider,
  11. Input,
  12. Layout,
  13. Modal,
  14. Result,
  15. Row,
  16. Spin,
  17. Statistic,
  18. Typography,
  19. message,
  20. } from 'antd';
  21. import {
  22. CheckCircleOutlined,
  23. CloseCircleOutlined,
  24. CloudServerOutlined,
  25. ThunderboltOutlined,
  26. } from '@ant-design/icons';
  27. import { useTheme } from '@/hooks/useTheme';
  28. import { useMediaQuery } from '@/hooks/useMediaQuery';
  29. import { useNodesQuery } from '@/api/queries/useNodesQuery';
  30. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  31. import { useNodeMutations } from '@/api/queries/useNodeMutations';
  32. import AppSidebar from '@/layouts/AppSidebar';
  33. import NodeList from './NodeList';
  34. import NodeFormModal from './NodeFormModal';
  35. import { setMessageInstance } from '@/utils/messageBus';
  36. import { HttpUtil } from '@/utils';
  37. import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
  38. // Confirm-dialog body that lets the operator pick the stable or dev channel for
  39. // a node panel update. Reports changes via onChange so the imperative
  40. // modal.confirm onOk can read the latest choice through a ref.
  41. function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
  42. const { t } = useTranslation();
  43. const [dev, setDev] = useState(false);
  44. return (
  45. <div>
  46. <p>{t('pages.nodes.updateConfirmContent')}</p>
  47. <Checkbox
  48. checked={dev}
  49. onChange={(e) => {
  50. setDev(e.target.checked);
  51. onChange(e.target.checked);
  52. }}
  53. >
  54. {t('pages.nodes.updateDevChannel')}
  55. </Checkbox>
  56. {dev && (
  57. <Alert
  58. type="info"
  59. showIcon
  60. style={{ marginTop: 8 }}
  61. title={t('pages.index.devChannelWarning')}
  62. />
  63. )}
  64. </div>
  65. );
  66. }
  67. export default function NodesPage() {
  68. const { t } = useTranslation();
  69. const { isDark, isUltra, antdThemeConfig } = useTheme();
  70. const { isMobile } = useMediaQuery();
  71. const [modal, modalContextHolder] = Modal.useModal();
  72. const [messageApi, messageContextHolder] = message.useMessage();
  73. useEffect(() => {
  74. setMessageInstance(messageApi);
  75. }, [messageApi]);
  76. const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
  77. const {
  78. create,
  79. update,
  80. remove,
  81. setEnable,
  82. testConnection,
  83. fetchFingerprint,
  84. fetchInbounds,
  85. probe,
  86. updatePanels,
  87. } = useNodeMutations();
  88. const { data: latestVersion = '' } = useQuery({
  89. queryKey: ['server', 'panelUpdateInfo'],
  90. queryFn: async () => {
  91. const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
  92. return msg?.obj?.latestVersion || '';
  93. },
  94. staleTime: 5 * 60 * 1000,
  95. });
  96. const [formOpen, setFormOpen] = useState(false);
  97. const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
  98. const [formNode, setFormNode] = useState<NodeRecord | null>(null);
  99. const [selectedIds, setSelectedIds] = useState<number[]>([]);
  100. const [mtlsOpen, setMtlsOpen] = useState(false);
  101. const [trustCa, setTrustCa] = useState('');
  102. const [copyingCa, setCopyingCa] = useState(false);
  103. const [savingTrustCa, setSavingTrustCa] = useState(false);
  104. const onCopyNodeCa = useCallback(async () => {
  105. setCopyingCa(true);
  106. try {
  107. const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
  108. const ca = msg?.obj?.caCert;
  109. if (msg?.success && ca) {
  110. await navigator.clipboard.writeText(ca);
  111. messageApi.success(t('pages.nodes.mtls.caCopied'));
  112. } else {
  113. messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
  114. }
  115. } catch {
  116. messageApi.error(t('pages.nodes.mtls.caFailed'));
  117. } finally {
  118. setCopyingCa(false);
  119. }
  120. }, [messageApi, t]);
  121. const onSaveTrustCa = useCallback(async () => {
  122. setSavingTrustCa(true);
  123. try {
  124. const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
  125. if (msg?.success) {
  126. messageApi.success(t('pages.nodes.mtls.saved'));
  127. setMtlsOpen(false);
  128. } else {
  129. messageApi.error(msg?.msg || t('somethingWentWrong'));
  130. }
  131. } catch {
  132. messageApi.error(t('somethingWentWrong'));
  133. } finally {
  134. setSavingTrustCa(false);
  135. }
  136. }, [trustCa, messageApi, t]);
  137. const onAdd = useCallback(() => {
  138. setFormMode('add');
  139. setFormNode(null);
  140. setFormOpen(true);
  141. }, []);
  142. const onEdit = useCallback((node: NodeRecord) => {
  143. setFormMode('edit');
  144. setFormNode({ ...node });
  145. setFormOpen(true);
  146. }, []);
  147. const onSave = useCallback(
  148. async (payload: Partial<NodeRecord>) => {
  149. if (formMode === 'edit' && formNode?.id) {
  150. return update(formNode.id, payload);
  151. }
  152. return create(payload);
  153. },
  154. [formMode, formNode, update, create],
  155. );
  156. const onDelete = useCallback(
  157. (node: NodeRecord) => {
  158. modal.confirm({
  159. title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
  160. content: t('pages.nodes.deleteConfirmContent'),
  161. okText: t('delete'),
  162. okType: 'danger',
  163. cancelText: t('cancel'),
  164. onOk: async () => {
  165. const msg = await remove(node.id);
  166. if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
  167. },
  168. });
  169. },
  170. [modal, t, remove, messageApi],
  171. );
  172. const onProbe = useCallback(
  173. async (node: NodeRecord) => {
  174. const msg = await probe(node.id);
  175. if (msg?.success && msg.obj) {
  176. if (msg.obj.status === 'online') {
  177. // Even if xray is in error/stop on the node we still reached its panel API.
  178. messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
  179. } else {
  180. messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
  181. }
  182. }
  183. // Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
  184. refetch();
  185. },
  186. [probe, t, messageApi, refetch],
  187. );
  188. const onToggleEnable = useCallback(
  189. async (node: NodeRecord, next: boolean) => {
  190. await setEnable(node.id, next);
  191. },
  192. [setEnable],
  193. );
  194. const devRef = useRef(false);
  195. const runUpdate = useCallback(
  196. async (ids: number[], dev: boolean) => {
  197. const msg = await updatePanels(ids, dev);
  198. if (!msg?.success) {
  199. messageApi.error(msg?.msg || t('somethingWentWrong'));
  200. return;
  201. }
  202. const results = msg.obj ?? [];
  203. const ok = results.filter((r) => r.ok).length;
  204. const failed = results.length - ok;
  205. if (failed === 0) {
  206. messageApi.success(t('pages.nodes.toasts.updateStarted'));
  207. } else {
  208. const firstError = results.find((r) => !r.ok)?.error ?? '';
  209. const base = t('pages.nodes.toasts.updateResult', { ok, failed });
  210. messageApi.warning(firstError ? `${base} — ${firstError}` : base);
  211. }
  212. setSelectedIds([]);
  213. },
  214. [updatePanels, messageApi, t],
  215. );
  216. const onUpdateNode = useCallback(
  217. (node: NodeRecord) => {
  218. devRef.current = false;
  219. modal.confirm({
  220. title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
  221. content: (
  222. <UpdateChannelChoice
  223. onChange={(v) => {
  224. devRef.current = v;
  225. }}
  226. />
  227. ),
  228. okText: t('update'),
  229. cancelText: t('cancel'),
  230. onOk: () => runUpdate([node.id], devRef.current),
  231. });
  232. },
  233. [modal, t, runUpdate],
  234. );
  235. const onUpdateSelected = useCallback(() => {
  236. const eligible = nodes
  237. .filter((n) => selectedIds.includes(n.id) && n.enable && n.status === 'online')
  238. .map((n) => n.id);
  239. if (eligible.length === 0) {
  240. messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
  241. return;
  242. }
  243. devRef.current = false;
  244. modal.confirm({
  245. title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
  246. content: (
  247. <UpdateChannelChoice
  248. onChange={(v) => {
  249. devRef.current = v;
  250. }}
  251. />
  252. ),
  253. okText: t('update'),
  254. cancelText: t('cancel'),
  255. onOk: () => runUpdate(eligible, devRef.current),
  256. });
  257. }, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
  258. const pageClass = useMemo(() => {
  259. const classes = ['nodes-page'];
  260. if (isDark) classes.push('is-dark');
  261. if (isUltra) classes.push('is-ultra');
  262. return classes.join(' ');
  263. }, [isDark, isUltra]);
  264. return (
  265. <ConfigProvider theme={antdThemeConfig}>
  266. {messageContextHolder}
  267. {modalContextHolder}
  268. <Layout className={pageClass}>
  269. <AppSidebar />
  270. <Layout className="content-shell">
  271. <Layout.Content id="content-layout" className="content-area">
  272. <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
  273. {!fetched ? (
  274. <div className="loading-spacer" />
  275. ) : fetchError ? (
  276. <Result
  277. status="error"
  278. title={t('somethingWentWrong')}
  279. subTitle={fetchError}
  280. extra={
  281. <Button type="primary" loading={loading} onClick={() => refetch()}>
  282. {t('refresh')}
  283. </Button>
  284. }
  285. />
  286. ) : (
  287. <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
  288. <Col span={24}>
  289. <Card size="small" hoverable className="summary-card">
  290. <Row gutter={[16, isMobile ? 16 : 12]}>
  291. <Col xs={12} sm={12} md={6}>
  292. <Statistic
  293. title={t('pages.nodes.totalNodes')}
  294. value={String(totals.total)}
  295. prefix={<CloudServerOutlined />}
  296. />
  297. </Col>
  298. <Col xs={12} sm={12} md={6}>
  299. <Statistic
  300. title={t('pages.nodes.onlineNodes')}
  301. value={String(totals.online)}
  302. prefix={
  303. <CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />
  304. }
  305. />
  306. </Col>
  307. <Col xs={12} sm={12} md={6}>
  308. <Statistic
  309. title={t('pages.nodes.offlineNodes')}
  310. value={String(totals.offline)}
  311. prefix={
  312. <CloseCircleOutlined style={{ color: 'var(--ant-color-error)' }} />
  313. }
  314. />
  315. </Col>
  316. <Col xs={12} sm={12} md={6}>
  317. <Statistic
  318. title={t('pages.nodes.avgLatency')}
  319. value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'}
  320. prefix={<ThunderboltOutlined />}
  321. />
  322. </Col>
  323. </Row>
  324. </Card>
  325. </Col>
  326. <Col span={24}>
  327. <NodeList
  328. nodes={nodes}
  329. loading={loading}
  330. isMobile={isMobile}
  331. latestVersion={latestVersion}
  332. selectedIds={selectedIds}
  333. onSelectionChange={setSelectedIds}
  334. onAdd={onAdd}
  335. onMtls={() => setMtlsOpen(true)}
  336. onEdit={onEdit}
  337. onDelete={onDelete}
  338. onProbe={onProbe}
  339. onToggleEnable={onToggleEnable}
  340. onUpdateNode={onUpdateNode}
  341. onUpdateSelected={onUpdateSelected}
  342. />
  343. </Col>
  344. </Row>
  345. )}
  346. </Spin>
  347. </Layout.Content>
  348. </Layout>
  349. <NodeFormModal
  350. open={formOpen}
  351. mode={formMode}
  352. node={formNode}
  353. testConnection={testConnection}
  354. fetchFingerprint={fetchFingerprint}
  355. fetchInbounds={fetchInbounds}
  356. save={onSave}
  357. onOpenChange={setFormOpen}
  358. />
  359. <Modal
  360. open={mtlsOpen}
  361. title={t('pages.nodes.mtls.title')}
  362. footer={null}
  363. onCancel={() => setMtlsOpen(false)}
  364. destroyOnHidden
  365. >
  366. <Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
  367. {t('pages.nodes.mtls.intro')}
  368. </Typography.Paragraph>
  369. <Button onClick={onCopyNodeCa} loading={copyingCa} style={{ marginBottom: 4 }}>
  370. {t('pages.nodes.mtls.copyCa')}
  371. </Button>
  372. <Typography.Paragraph type="secondary">
  373. {t('pages.nodes.mtls.copyCaHint')}
  374. </Typography.Paragraph>
  375. <Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
  376. <Input.TextArea
  377. rows={5}
  378. value={trustCa}
  379. onChange={(e) => setTrustCa(e.target.value)}
  380. placeholder={t('pages.nodes.mtls.trustPlaceholder')}
  381. style={{ marginTop: 4, fontFamily: 'monospace' }}
  382. />
  383. <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
  384. {t('pages.nodes.mtls.trustHint')}
  385. </Typography.Paragraph>
  386. <Button type="primary" onClick={onSaveTrustCa} loading={savingTrustCa} block>
  387. {t('pages.nodes.mtls.save')}
  388. </Button>
  389. </Modal>
  390. </Layout>
  391. </ConfigProvider>
  392. );
  393. }