'use client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; 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 { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { deleteNode, getNodeAgentRelease, getNodes, requestNodeAgentUpdate, updateNode, } from '@/features/nodes/api/nodes'; import type { NodeAgentReleaseInfo, NodeMutationPayload, } from '@/features/nodes/types'; import { CodeBlock, DangerButton, PrimaryButton, ResourceField, ResourceInput, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import type { ReleaseChannel } from '@/features/update/types'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { buildNodeInstallCommand, getApplyLabel, getApplyVariant, getNodeStatusLabel, getNodeStatusVariant, getServerUrl, getUpdateMode, isMeaningfulTime, } from '@/features/nodes/utils'; const nodesQueryKey = ['nodes']; 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, }; } async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } export function NodeDetailPage({ nodeId }: { nodeId: string }) { const router = useRouter(); const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); const [selectedReleaseChannel, setSelectedReleaseChannel] = useState('stable'); const [agentUpdateFeedback, setAgentUpdateFeedback] = useState(null); const [serverUrl, setServerUrl] = useState(''); const form = useForm({ resolver: zodResolver(nodeSchema), defaultValues, }); const watchedAutoUpdate = useWatch({ control: form.control, name: 'auto_update_enabled', }); const nodesQuery = useQuery({ queryKey: nodesQueryKey, queryFn: getNodes, refetchInterval: 5000, }); const stableAgentReleaseQuery = useQuery({ queryKey: ['node-agent-release', nodeId, 'stable'], queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'), enabled: false, }); const previewAgentReleaseQuery = useQuery({ queryKey: ['node-agent-release', nodeId, 'preview'], queryFn: () => getNodeAgentRelease(Number(nodeId), 'preview'), enabled: false, }); const node = useMemo(() => { return ( (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null ); }, [nodeId, nodesQuery.data]); const applyLogsQuery = useQuery({ queryKey: ['apply-logs', node?.node_id ?? ''], queryFn: () => getApplyLogs(node?.node_id), enabled: Boolean(node?.node_id), refetchInterval: 5000, }); useEffect(() => { if (typeof window !== 'undefined' && !serverUrl) { setServerUrl(window.location.origin); } }, [serverUrl]); useEffect(() => { if (!node || isEditorOpen) { return; } form.reset({ name: node.name, auto_update_enabled: node.auto_update_enabled, }); }, [form, isEditorOpen, node]); const saveMutation = useMutation({ mutationFn: async (values: NodeFormValues) => updateNode(Number(nodeId), toPayload(values)), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', message: '节点已更新。' }); setIsEditorOpen(false); form.reset({ name: updatedNode.name, auto_update_enabled: updatedNode.auto_update_enabled, }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const updateAgentMutation = useMutation({ mutationFn: (release: NodeAgentReleaseInfo | null) => requestNodeAgentUpdate(Number(nodeId), { channel: release?.channel ?? selectedReleaseChannel, tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined, }), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', message: `已向节点 ${updatedNode.name} 下发${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}更新指令。`, }); setAgentUpdateFeedback({ tone: 'success', message: `节点将在下一次心跳后执行${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'} Agent 更新。`, }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { const message = getErrorMessage(error); setFeedback({ tone: 'danger', message }); setAgentUpdateFeedback({ tone: 'danger', message }); }, }); const deleteMutation = useMutation({ mutationFn: () => deleteNode(Number(nodeId)), onSuccess: async () => { setFeedback({ tone: 'success', message: '节点已删除。' }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); router.push('/node'); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const handleSubmit = form.handleSubmit((values) => { setFeedback(null); saveMutation.mutate(values); }); const handleDelete = () => { if (!node) { return; } if ( !window.confirm( `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, ) ) { return; } setFeedback(null); deleteMutation.mutate(); }; const handleCopy = async (value: string, message: string) => { try { await copyToClipboard(value); setFeedback({ tone: 'success', message }); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); } }; if (nodesQuery.isLoading) { return ; } if (nodesQuery.isError) { return ( ); } if (!node) { return ( ); } const normalizedServerUrl = getServerUrl(serverUrl); const nodeInstallCommand = normalizedServerUrl && node.agent_token ? buildNodeInstallCommand(normalizedServerUrl, node.agent_token) : ''; const updateMode = getUpdateMode(node); const selectedAgentRelease = selectedReleaseChannel === 'preview' ? previewAgentReleaseQuery.data : stableAgentReleaseQuery.data; const selectedAgentReleaseError = selectedReleaseChannel === 'preview' ? previewAgentReleaseQuery.error : stableAgentReleaseQuery.error; const isCheckingAgentRelease = selectedReleaseChannel === 'preview' ? previewAgentReleaseQuery.isFetching : stableAgentReleaseQuery.isFetching; const applyLogs = applyLogsQuery.data ?? []; const handleOpenAgentUpdateModal = () => { setAgentUpdateFeedback(null); setSelectedReleaseChannel('stable'); setIsAgentUpdateModalOpen(true); void stableAgentReleaseQuery.refetch(); }; const handleCheckStableAgentRelease = () => { setAgentUpdateFeedback(null); setSelectedReleaseChannel('stable'); void stableAgentReleaseQuery.refetch(); }; const handleCheckPreviewAgentRelease = () => { setAgentUpdateFeedback(null); setSelectedReleaseChannel('preview'); void previewAgentReleaseQuery.refetch(); }; const handleRequestAgentUpdate = () => { updateAgentMutation.mutate(selectedAgentRelease ?? null); }; return ( <>
返回列表 setIsEditorOpen(true)} > 编辑节点 {node.update_requested ? '查看 Agent 更新' : 'Agent 更新'} 删除节点 } /> {feedback ? ( ) : null}

最近心跳: {isMeaningfulTime(node.last_seen_at) ? ` ${formatRelativeTime( node.last_seen_at, )} · ${formatDateTime(node.last_seen_at)}` : ' 暂无'}

Agent:{node.agent_version || 'unknown'}

Nginx:{node.nginx_version || 'unknown'}

当前配置:{node.current_version || '未应用'}

{node.update_requested ? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。` : node.auto_update_enabled ? '节点已启用正式版自动更新。' : '当前仅支持手动触发更新。'}

{isMeaningfulTime(node.latest_apply_at) ? `${formatRelativeTime( node.latest_apply_at, )} · ${formatDateTime(node.latest_apply_at)}` : '暂无应用记录'}

void handleCopy( nodeInstallCommand, '节点专属部署命令已复制。', ) } > 复制部署命令 ) : null } >

Node ID

{node.node_id}

Agent Token

{node.agent_token || '暂无'}

setServerUrl(event.target.value)} /> {nodeInstallCommand ? ( {nodeInstallCommand} ) : null}

IP 地址

{node.ip || '暂无'}

最近错误

{node.last_error || '无'}

创建时间

{formatDateTime(node.created_at)}

更新时间

{formatDateTime(node.updated_at)}

查看完整记录 } > {applyLogsQuery.isLoading ? ( ) : applyLogsQuery.isError ? ( ) : applyLogs.length === 0 ? ( ) : (
{applyLogs.slice(0, 10).map((log) => ( ))}
版本 结果 时间 消息
{log.version} {formatRelativeTime(log.created_at)} ·{' '} {formatDateTime(log.created_at)}

{log.message || '—'}

)}
setIsEditorOpen(false)} title="编辑节点" description="更新模式和节点名都在详情页维护。" footer={
setIsEditorOpen(false)} disabled={saveMutation.isPending} > 取消 {saveMutation.isPending ? '保存中...' : '保存修改'}
} >
form.setValue('auto_update_enabled', checked, { shouldDirty: true, shouldValidate: true, }) } />
setIsAgentUpdateModalOpen(false)} title="Agent 更新" description="默认检查正式版;你也可以手动检查 preview 发布,并选择向当前节点下发对应版本的升级指令。" footer={
{isCheckingAgentRelease && selectedReleaseChannel === 'stable' ? '检查中...' : '检查正式版'} {isCheckingAgentRelease && selectedReleaseChannel === 'preview' ? '检查中...' : '检查预览版'} {updateAgentMutation.isPending ? '下发中...' : selectedReleaseChannel === 'preview' ? '升级到预览版' : '升级到正式版'}
} >
{agentUpdateFeedback ? ( ) : null}

{node.agent_version || 'unknown'}

{selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}

{isCheckingAgentRelease && !selectedAgentRelease ? ( ) : null} {!isCheckingAgentRelease && selectedAgentReleaseError ? ( ) : null} {!isCheckingAgentRelease && !selectedAgentReleaseError && !selectedAgentRelease ? ( ) : null} {selectedAgentRelease ? (
{selectedAgentRelease.prerelease ? ( ) : ( )} {node.update_requested ? ( ) : null}

当前版本

{selectedAgentRelease.current_version || 'unknown'}

目标版本

{selectedAgentRelease.tag_name || '未找到'}

{selectedAgentRelease.body || '暂无更新说明'}
{selectedAgentRelease.html_url ? ( 查看发布详情 ) : null}
) : null}
); }