'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 { createNode, deleteNode, getNodeBootstrapToken, getNodes, requestNodeAgentUpdate, rotateNodeBootstrapToken, 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 bootstrapQueryKey = ['nodes', 'bootstrap-token']; 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 getServerUrl(value: string) { return value.trim().replace(/\/+$/, ''); } function buildDiscoveryInstallCommand(serverUrl: string, discoveryToken: string) { return [ `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, ` --server-url ${serverUrl} \\`, ` --discovery-token ${discoveryToken}`, ].join('\n'); } 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 [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 bootstrapQuery = useQuery({ queryKey: bootstrapQueryKey, queryFn: getNodeBootstrapToken, }); 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 Promise.all([ queryClient.invalidateQueries({ queryKey: nodesQueryKey }), queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }), ]); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const rotateMutation = useMutation({ mutationFn: rotateNodeBootstrapToken, onSuccess: async () => { setFeedback({ tone: 'success', message: '全局 Discovery Token 已重新生成。' }); await queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }); }, 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 bootstrapToken = bootstrapQuery.data?.discovery_token ?? ''; const normalizedServerUrl = getServerUrl(serverUrl); const selectedNodeFromList = useMemo(() => { if (!selectedNode) { return null; } return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode; }, [nodes, selectedNode]); const summary = useMemo(() => { return [ { label: '节点总数', value: nodes.length }, { label: '在线节点', value: nodes.filter((item) => item.status === 'online').length }, { label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length }, { label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length }, ]; }, [nodes]); 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 handleDelete = (node: NodeItem) => { if ( !window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`) ) { return; } setFeedback(null); deleteMutation.mutate(node.id); }; const handleRotateToken = () => { if (!window.confirm('确认重新生成全局 Discovery Token 吗?现有批量部署命令需要同步更新。')) { return; } setFeedback(null); rotateMutation.mutate(); }; 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 discoveryCommand = normalizedServerUrl && bootstrapToken ? buildDiscoveryInstallCommand(normalizedServerUrl, bootstrapToken) : ''; const nodeInstallCommand = normalizedServerUrl && selectedNodeFromList?.agent_token ? buildNodeInstallCommand(normalizedServerUrl, selectedNodeFromList.agent_token) : ''; return ( <>
新增节点 应用记录 } /> {feedback ? : null} void queryClient.invalidateQueries({ queryKey: nodesQueryKey })} > 刷新列表 } >
{summary.map((item) => (

{item.label}

{item.value}

))}
{rotateMutation.isPending ? '生成中...' : '重新生成 Token'} {discoveryCommand ? ( void handleCopy(discoveryCommand, 'Discovery 部署命令已复制。')} > 复制命令 ) : null}
} > {bootstrapQuery.isLoading ? ( ) : bootstrapQuery.isError ? ( ) : (
setServerUrl(event.target.value)} />

Discovery Token

{bootstrapToken || '暂无'}

创建节点后也可以使用节点专属 Token 安装,这种方式适合预分配节点名与更新策略。
{discoveryCommand ? {discoveryCommand} : null}
)} void handleCopy(nodeInstallCommand, '节点专属部署命令已复制。')} > 复制命令 ) : null } > {selectedNodeFromList ? (

{selectedNodeFromList.name}

Node ID:{selectedNodeFromList.node_id}

Agent Token

{selectedNodeFromList.agent_token || '暂无'}

{nodeInstallCommand ? {nodeInstallCommand} : null}
) : ( )}
{nodesQuery.isLoading ? ( ) : nodesQuery.isError ? ( ) : nodes.length === 0 ? ( ) : (
{nodes.map((node) => { const updateMode = getUpdateMode(node); 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 || '无'}

updateAgentMutation.mutate(node.id)} disabled={updateAgentMutation.isPending || node.update_requested} className='px-3 py-2 text-xs' > 立即更新 setSelectedNode(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' > 删除
)}
取消 {saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'} } >
form.setValue('auto_update_enabled', checked, { shouldDirty: true, shouldValidate: true, }) } />
); }