'use client'; import Link from 'next/link'; import {useRouter} from 'next/navigation'; import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import {useState} from 'react'; import {ArrowLeft, FileText, Loader2, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react'; import {toast} from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card'; import {formatDateTime} from '@/lib/utils'; import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare'; import {NodeService} from '@/lib/services/openflare'; import {AgentUpdateDialog} from './agent-update-dialog'; import {NodeEditorDialog} from './node-editor-dialog'; import {NodeStatusBadge} from './node-status-badge'; import { formatRelativeTime, getApplyLabel, getApplyTone, getErrorMessage, getNodeStatusLabel, getNodeStatusTone, getOpenrestyStatusLabel, getOpenrestyStatusTone, isMeaningfulTime, } from './node-utils'; const nodesQueryKey = ['openflare', 'nodes']; export function EdgeNodeDetail({ node }: { node: NodeItem }) { const router = useRouter(); const queryClient = useQueryClient(); const [editorOpen, setEditorOpen] = useState(false); const [upgradeOpen, setUpgradeOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const observabilityQuery = useQuery({ queryKey: ['openflare', 'node-observability', node.id], queryFn: () => NodeService.getObservability(node.id, { hours: 24, limit: 48 }), refetchInterval: 10000, }); const saveMutation = useMutation({ mutationFn: (payload: Parameters[1]) => NodeService.updateNode(node.id, payload), onSuccess: async () => { toast.success('节点已更新'); setEditorOpen(false); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => toast.error(getErrorMessage(error)), }); const forceSyncMutation = useMutation({ mutationFn: () => NodeService.requestForceSync(node.id), onSuccess: async (updated) => { toast.success(`已向节点 ${updated.name} 下发强制同步指令`); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => toast.error(getErrorMessage(error)), }); const restartMutation = useMutation({ mutationFn: () => NodeService.requestOpenrestyRestart(node.id), onSuccess: async (updated) => { toast.success(`已向节点 ${updated.name} 下发 OpenResty 重启指令`); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => toast.error(getErrorMessage(error)), }); const upgradeMutation = useMutation({ mutationFn: ({ release, channel, }: { release: NodeAgentReleaseInfo | null; channel: ReleaseChannel; }) => NodeService.requestAgentUpdate(node.id, { channel: release?.channel ?? channel, tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined, }), onSuccess: async (updated) => { toast.success( `已向节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`, ); setUpgradeOpen(false); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => toast.error(getErrorMessage(error)), }); const deleteMutation = useMutation({ mutationFn: () => NodeService.deleteNode(node.id), onSuccess: async () => { toast.success('节点已删除'); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); router.push('/nodes'); }, onError: (error) => toast.error(getErrorMessage(error)), }); const profile = observabilityQuery.data?.profile ?? null; const latestMetric = observabilityQuery.data?.metric_snapshots?.[0] ?? null; const activeHealthEvents = observabilityQuery.data?.health_events.filter((event) => event.status === 'active') ?? []; const handleRefresh = () => { void Promise.all([ queryClient.invalidateQueries({ queryKey: nodesQueryKey }), queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }), ]); }; const isRefreshing = observabilityQuery.isFetching; return (

{node.name}

节点 ID {node.node_id} Agent 版本 {node.version || 'unknown'} 当前配置版本 {node.current_version || '未应用'} 最近心跳 {isMeaningfulTime(node.last_seen_at) ? formatRelativeTime(node.last_seen_at) : '暂无'}
基本信息 边缘代理节点 (edge_node)
运行状态
OpenResty 健康
最近应用
IP 地址 {node.ip || '—'} {node.ip_manual_override ? '(已锁定)' : ''}
地图点位 {node.geo_name || '未配置'}
创建时间 {formatDateTime(node.created_at)}
更新时间 {formatDateTime(node.updated_at)}
{node.last_error ? (
{node.last_error}
) : null}
运行快照 来自 observability 接口的最近上报
{observabilityQuery.isLoading ? (
加载运行快照中...
) : observabilityQuery.isError ? (

{getErrorMessage(observabilityQuery.error)}

) : ( <>
活动健康事件 {activeHealthEvents.length}
主机名 {profile?.hostname || '—'}
操作系统 {profile ? `${profile.os_name || 'unknown'} ${profile.os_version || ''}`.trim() : '—'}
CPU 使用率 {latestMetric ? `${latestMetric.cpu_usage_percent.toFixed(1)}%` : '—'}
OpenResty 连接数 {latestMetric?.openresty_connections ?? '—'}
OpenResty 状态消息 {node.openresty_message || '无'}
)}
setEditorOpen(false)} onSubmit={async (payload) => { await saveMutation.mutateAsync(payload); }} /> setUpgradeOpen(false)} onConfirm={async (release, channel) => { await upgradeMutation.mutateAsync({ release, channel }); }} /> 确认删除节点 确认删除节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。 取消 deleteMutation.mutate()} > {deleteMutation.isPending ? '删除中...' : '确认删除'}
); }