NodesPage.tsx 13 KB

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