'use client'; import Link from 'next/link'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; import { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { getPublicStatus } from '@/features/auth/api/public'; import { createNode, deleteNode, getNodes, requestNodeAgentUpdate, updateNode, } from '@/features/nodes/api/nodes'; import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; import { CodeBlock, DangerButton, PrimaryButton, ResourceField, ResourceInput, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; const nodesQueryKey = ['nodes']; const installerScriptUrl = 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh'; const nodeSchema = z.object({ name: z .string() .trim() .min(1, '请输入节点名') .max(128, '节点名不能超过 128 个字符'), auto_update_enabled: z.boolean(), }); type NodeFormValues = z.infer; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; const defaultValues: NodeFormValues = { name: '', auto_update_enabled: false, }; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } function toPayload(values: NodeFormValues): NodeMutationPayload { return { name: values.name.trim(), auto_update_enabled: values.auto_update_enabled, }; } function isMeaningfulTime(value: string | null | undefined) { return Boolean(value) && !String(value).startsWith('0001-01-01'); } function getStatusVariant(status: NodeItem['status']) { if (status === 'online') { return 'success'; } if (status === 'pending') { return 'warning'; } return 'danger'; } function getStatusLabel(status: NodeItem['status']) { if (status === 'online') { return '在线'; } if (status === 'pending') { return '待接入'; } return '离线'; } function getApplyVariant(result: NodeItem['latest_apply_result']) { if (result === 'success') { return 'success'; } if (result === 'failed') { return 'danger'; } return 'warning'; } function getApplyLabel(result: NodeItem['latest_apply_result']) { if (result === 'success') { return '成功'; } if (result === 'failed') { return '失败'; } return '暂无'; } function getUpdateMode(node: NodeItem) { if (node.update_requested) { return { label: '等待更新', variant: 'warning' as const }; } if (node.auto_update_enabled) { return { label: '自动更新', variant: 'success' as const }; } return { label: '手动更新', variant: 'info' as const }; } function parseVersionParts(version: string) { const normalized = version.trim().replace(/^v/i, ''); if (!normalized || normalized.toLowerCase() === 'unknown') { return null; } return normalized.split('.').map((segment) => { const matched = segment.trim().match(/^\d+/); return matched ? Number.parseInt(matched[0], 10) : 0; }); } function isOlderVersion(current: string, target: string) { const currentParts = parseVersionParts(current); const targetParts = parseVersionParts(target); if (!currentParts || !targetParts) { return false; } const maxLength = Math.max(currentParts.length, targetParts.length); for (let index = 0; index < maxLength; index += 1) { const currentPart = currentParts[index] ?? 0; const targetPart = targetParts[index] ?? 0; if (currentPart < targetPart) { return true; } if (currentPart > targetPart) { return false; } } return false; } function shouldShowManualUpdate(agentVersion: string, serverVersion: string) { const normalizedServerVersion = serverVersion.trim(); const normalizedAgentVersion = agentVersion.trim(); if ( !normalizedServerVersion || normalizedServerVersion.toLowerCase() === 'dev' || !normalizedAgentVersion || normalizedAgentVersion.toLowerCase() === 'unknown' ) { return false; } return isOlderVersion(normalizedAgentVersion, normalizedServerVersion); } function getServerUrl(value: string) { return value.trim().replace(/\/+$/, ''); } function buildNodeInstallCommand(serverUrl: string, agentToken: string) { return [ `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, ` --server-url ${serverUrl} \\`, ` --agent-token ${agentToken}`, ].join('\n'); } async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } export function NodesPage() { const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); const [editingNodeId, setEditingNodeId] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const [isDeployModalOpen, setIsDeployModalOpen] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [serverUrl, setServerUrl] = useState(''); const [, setRefreshTick] = useState(0); const form = useForm({ resolver: zodResolver(nodeSchema), defaultValues, }); const watchedAutoUpdate = useWatch({ control: form.control, name: 'auto_update_enabled', }); const nodesQuery = useQuery({ queryKey: nodesQueryKey, queryFn: getNodes, }); const publicStatusQuery = useQuery({ queryKey: ['public-status'], queryFn: getPublicStatus, }); useEffect(() => { if (typeof window !== 'undefined' && !serverUrl) { setServerUrl(window.location.origin); } }, [serverUrl]); useEffect(() => { const timer = window.setInterval(() => { void queryClient.invalidateQueries({ queryKey: nodesQueryKey }); setRefreshTick((value) => value + 1); }, 30000); return () => { window.clearInterval(timer); }; }, [queryClient]); const saveMutation = useMutation({ mutationFn: async (values: NodeFormValues) => { const payload = toPayload(values); return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload); }, onSuccess: async (node) => { setFeedback({ tone: 'success', message: editingNodeId ? '节点已更新。' : '节点已创建。', }); setEditingNodeId(null); setIsEditorOpen(false); setSelectedNode(node); form.reset(defaultValues); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const updateAgentMutation = useMutation({ mutationFn: requestNodeAgentUpdate, onSuccess: async (node) => { setFeedback({ tone: 'success', message: `已向节点 ${node.name} 下发更新指令。`, }); setSelectedNode(node); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteMutation = useMutation({ mutationFn: deleteNode, onSuccess: async () => { setFeedback({ tone: 'success', message: '节点已删除。' }); setEditingNodeId(null); setSelectedNode(null); form.reset(defaultValues); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); const normalizedServerUrl = getServerUrl(serverUrl); const serverVersion = publicStatusQuery.data?.version ?? ''; const selectedNodeFromList = useMemo(() => { if (!selectedNode) { return null; } return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode; }, [nodes, selectedNode]); const handleReset = () => { setFeedback(null); setEditingNodeId(null); setIsEditorOpen(false); form.reset(defaultValues); }; const handleCreate = () => { setFeedback(null); setEditingNodeId(null); form.reset(defaultValues); setIsEditorOpen(true); }; const handleEdit = (node: NodeItem) => { setFeedback(null); setEditingNodeId(node.id); setSelectedNode(node); form.reset({ name: node.name, auto_update_enabled: node.auto_update_enabled, }); setIsEditorOpen(true); }; const handleOpenDeployModal = (node: NodeItem) => { setFeedback(null); setSelectedNode(node); setIsDeployModalOpen(true); }; const handleDelete = (node: NodeItem) => { if ( !window.confirm( `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, ) ) { return; } setFeedback(null); deleteMutation.mutate(node.id); }; const handleCopy = async (value: string, successMessage: string) => { try { await copyToClipboard(value); setFeedback({ tone: 'success', message: successMessage }); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); } }; const handleSubmit = form.handleSubmit((values) => { setFeedback(null); saveMutation.mutate(values); }); const nodeInstallCommand = normalizedServerUrl && selectedNodeFromList?.agent_token ? buildNodeInstallCommand( normalizedServerUrl, selectedNodeFromList.agent_token, ) : ''; return ( <>
新增节点 应用记录 } /> {feedback ? ( ) : null} void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) } > 刷新列表 } > {nodesQuery.isLoading ? ( ) : nodesQuery.isError ? ( ) : nodes.length === 0 ? ( ) : (
{nodes.map((node) => { const updateMode = getUpdateMode(node); const showManualUpdate = shouldShowManualUpdate( node.agent_version || '', serverVersion, ); return ( ); })}
节点 状态 更新模式 Agent / Nginx 当前版本 最近应用 最近心跳 错误 操作

{node.name}

Node ID:{node.node_id}

Token:{node.agent_token || '暂无'}

IP:{node.ip || '暂无'}

{node.agent_version || 'unknown'} /{' '} {node.nginx_version || 'unknown'} {node.current_version || '未应用'}

{node.latest_apply_message || '暂无记录'}

{isMeaningfulTime(node.latest_apply_at) ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}` : '暂无'}

{isMeaningfulTime(node.last_seen_at) ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` : '暂无'}

{node.last_error || '无'}

{showManualUpdate ? ( updateAgentMutation.mutate(node.id) } disabled={ updateAgentMutation.isPending || node.update_requested } className="px-3 py-2 text-xs" > 升级 ) : null} handleOpenDeployModal(node)} className="px-3 py-2 text-xs" > 部署 handleEdit(node)} className="px-3 py-2 text-xs" > 编辑 handleDelete(node)} disabled={deleteMutation.isPending} className="px-3 py-2 text-xs" > 删除
)}
setIsDeployModalOpen(false)} title="节点专属部署命令" description="仅展示节点专属 Agent Token 部署方式。Discovery Token 部署入口已从管理端移除。" footer={
setIsDeployModalOpen(false)} > 关闭 {nodeInstallCommand ? ( void handleCopy( nodeInstallCommand, '节点专属部署命令已复制。', ) } > 复制命令 ) : null}
} > {selectedNodeFromList ? (

{selectedNodeFromList.name}

Node ID:{selectedNodeFromList.node_id}

IP:{selectedNodeFromList.ip || '暂无'}

Agent Token

{selectedNodeFromList.agent_token || '暂无'}

setServerUrl(event.target.value)} /> {nodeInstallCommand ? ( {nodeInstallCommand} ) : null}
) : ( )}
取消 {saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'} } >
form.setValue('auto_update_enabled', checked, { shouldDirty: true, shouldValidate: true, }) } />
); }