import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Alert, Button, Card, Checkbox, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
CloudServerOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { useNodeMutations } from '@/api/queries/useNodeMutations';
import AppSidebar from '@/layouts/AppSidebar';
import NodeList from './NodeList';
import NodeFormModal from './NodeFormModal';
import { setMessageInstance } from '@/utils/messageBus';
import { HttpUtil } from '@/utils';
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
// Confirm-dialog body that lets the operator pick the stable or dev channel for
// a node panel update. Reports changes via onChange so the imperative
// modal.confirm onOk can read the latest choice through a ref.
function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
const { t } = useTranslation();
const [dev, setDev] = useState(false);
return (
{t('pages.nodes.updateConfirmContent')}
{ setDev(e.target.checked); onChange(e.target.checked); }}
>
{t('pages.nodes.updateDevChannel')}
{dev && (
)}
);
}
export default function NodesPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, fetchFingerprint, fetchInbounds, probe, updatePanels } = useNodeMutations();
const { data: latestVersion = '' } = useQuery({
queryKey: ['server', 'panelUpdateInfo'],
queryFn: async () => {
const msg = await HttpUtil.get('/panel/api/server/getPanelUpdateInfo');
return msg?.obj?.latestVersion || '';
},
staleTime: 5 * 60 * 1000,
});
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formNode, setFormNode] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);
const [mtlsOpen, setMtlsOpen] = useState(false);
const [trustCa, setTrustCa] = useState('');
const [copyingCa, setCopyingCa] = useState(false);
const [savingTrustCa, setSavingTrustCa] = useState(false);
const onCopyNodeCa = useCallback(async () => {
setCopyingCa(true);
try {
const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
const ca = msg?.obj?.caCert;
if (msg?.success && ca) {
await navigator.clipboard.writeText(ca);
messageApi.success(t('pages.nodes.mtls.caCopied'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
}
} catch {
messageApi.error(t('pages.nodes.mtls.caFailed'));
} finally {
setCopyingCa(false);
}
}, [messageApi, t]);
const onSaveTrustCa = useCallback(async () => {
setSavingTrustCa(true);
try {
const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
if (msg?.success) {
messageApi.success(t('pages.nodes.mtls.saved'));
setMtlsOpen(false);
} else {
messageApi.error(msg?.msg || t('somethingWentWrong'));
}
} catch {
messageApi.error(t('somethingWentWrong'));
} finally {
setSavingTrustCa(false);
}
}, [trustCa, messageApi, t]);
const onAdd = useCallback(() => {
setFormMode('add');
setFormNode(null);
setFormOpen(true);
}, []);
const onEdit = useCallback((node: NodeRecord) => {
setFormMode('edit');
setFormNode({ ...node });
setFormOpen(true);
}, []);
const onSave = useCallback(async (payload: Partial) => {
if (formMode === 'edit' && formNode?.id) {
return update(formNode.id, payload);
}
return create(payload);
}, [formMode, formNode, update, create]);
const onDelete = useCallback((node: NodeRecord) => {
modal.confirm({
title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
content: t('pages.nodes.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(node.id);
if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
},
});
}, [modal, t, remove, messageApi]);
const onProbe = useCallback(async (node: NodeRecord) => {
const msg = await probe(node.id);
if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') {
// Even if xray is in error/stop on the node we still reached its panel API.
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else {
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
}
}
// Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
refetch();
}, [probe, t, messageApi, refetch]);
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next);
}, [setEnable]);
const devRef = useRef(false);
const runUpdate = useCallback(async (ids: number[], dev: boolean) => {
const msg = await updatePanels(ids, dev);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
return;
}
const results = msg.obj ?? [];
const ok = results.filter((r) => r.ok).length;
const failed = results.length - ok;
if (failed === 0) {
messageApi.success(t('pages.nodes.toasts.updateStarted'));
} else {
const firstError = results.find((r) => !r.ok)?.error ?? '';
const base = t('pages.nodes.toasts.updateResult', { ok, failed });
messageApi.warning(firstError ? `${base} — ${firstError}` : base);
}
setSelectedIds([]);
}, [updatePanels, messageApi, t]);
const onUpdateNode = useCallback((node: NodeRecord) => {
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
content: { devRef.current = v; }} />,
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate([node.id], devRef.current),
});
}, [modal, t, runUpdate]);
const onUpdateSelected = useCallback(() => {
const eligible = nodes
.filter((n) => selectedIds.includes(n.id) && n.enable && n.status === 'online')
.map((n) => n.id);
if (eligible.length === 0) {
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
return;
}
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
content: { devRef.current = v; }} />,
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate(eligible, devRef.current),
});
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
const pageClass = useMemo(() => {
const classes = ['nodes-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
{messageContextHolder}
{modalContextHolder}
{!fetched ? (
) : fetchError ? (
refetch()}>{t('refresh')}}
/>
) : (
}
/>
}
/>
}
/>
0 ? `${totals.avgLatency} ms` : '-'}
prefix={ }
/>
setMtlsOpen(true)}
onEdit={onEdit}
onDelete={onDelete}
onProbe={onProbe}
onToggleEnable={onToggleEnable}
onUpdateNode={onUpdateNode}
onUpdateSelected={onUpdateSelected}
/>
)}
setMtlsOpen(false)}
destroyOnHidden
>
{t('pages.nodes.mtls.intro')}
{t('pages.nodes.mtls.copyCa')}
{t('pages.nodes.mtls.copyCaHint')}
{t('pages.nodes.mtls.trustLabel')}
setTrustCa(e.target.value)}
placeholder={t('pages.nodes.mtls.trustPlaceholder')}
style={{ marginTop: 4, fontFamily: 'monospace' }}
/>
{t('pages.nodes.mtls.trustHint')}
{t('pages.nodes.mtls.save')}
);
}