'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 { getPublicStatus } from '@/features/auth/api/public'; import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { deleteNode, getNodes, requestNodeAgentUpdate, updateNode, } from '@/features/nodes/api/nodes'; import type { 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'; import { buildNodeInstallCommand, getApplyLabel, getApplyVariant, getNodeStatusLabel, getNodeStatusVariant, getServerUrl, getUpdateMode, isMeaningfulTime, shouldShowManualUpdate, } 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 [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 publicStatusQuery = useQuery({ queryKey: ['public-status'], queryFn: getPublicStatus, }); 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: () => requestNodeAgentUpdate(Number(nodeId)), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', message: `已向节点 ${updatedNode.name} 下发更新指令。`, }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); 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 serverVersion = publicStatusQuery.data?.version ?? ''; const showManualUpdate = shouldShowManualUpdate( node.agent_version || '', serverVersion, ); const applyLogs = applyLogsQuery.data ?? []; return ( <>
返回列表 setIsEditorOpen(true)} > 编辑节点 {showManualUpdate ? ( updateAgentMutation.mutate()} disabled={ updateAgentMutation.isPending || node.update_requested } > {node.update_requested ? '等待更新中...' : '手动升级 Agent'} ) : null} 删除节点 } /> {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.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, }) } />
); }