'use client'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; 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 { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { getConfigVersions } from '@/features/config-versions/api/config-versions'; import { createNode, deleteNode, getNodes, updateNode, } from '@/features/nodes/api/nodes'; import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; import { DangerButton, SecondaryButton, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { getApplyLabel, getApplyVariant, getNodeStatusLabel, getNodeStatusVariant, getOpenrestyStatusLabel, getOpenrestyStatusVariant, isMeaningfulTime, isWSConnectedLastSeen, } from '@/features/nodes/utils'; const nodesQueryKey = ['nodes']; const supportedRiskFilters = [ 'all', 'offline', 'unhealthy', 'lagging', ] as const; type NodeRiskFilter = (typeof supportedRiskFilters)[number]; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } export function NodesPage() { const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); const [editingNode, setEditingNode] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const nodesQuery = useQuery({ queryKey: nodesQueryKey, queryFn: getNodes, refetchInterval: 5000, }); const configVersionsQuery = useQuery({ queryKey: ['config-versions'], queryFn: getConfigVersions, refetchInterval: 5000, }); const saveMutation = useMutation({ mutationFn: async (payload: NodeMutationPayload) => { return editingNode ? updateNode(editingNode.id, payload) : createNode(payload); }, onSuccess: async () => { setFeedback({ tone: 'success', message: editingNode ? '节点已更新。' : '节点已创建。', }); setEditingNode(null); setIsEditorOpen(false); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteMutation = useMutation({ mutationFn: deleteNode, onSuccess: async () => { setFeedback({ tone: 'success', message: '节点已删除。' }); setEditingNode(null); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); const activeVersion = useMemo( () => (configVersionsQuery.data ?? []).find((item) => item.is_active) ?.version ?? '', [configVersionsQuery.data], ); const riskFilter = useMemo(() => { const current = searchParams.get('risk')?.trim().toLowerCase() ?? 'all'; return supportedRiskFilters.includes(current as NodeRiskFilter) ? (current as NodeRiskFilter) : 'all'; }, [searchParams]); const filteredNodes = useMemo(() => { switch (riskFilter) { case 'offline': return nodes.filter((node) => node.status === 'offline'); case 'unhealthy': return nodes.filter((node) => node.openresty_status === 'unhealthy'); case 'lagging': return nodes.filter((node) => { if (!activeVersion) { return false; } return node.current_version !== activeVersion; }); default: return nodes; } }, [activeVersion, nodes, riskFilter]); const filterDescription = useMemo(() => { switch (riskFilter) { case 'offline': return '当前仅展示离线节点。'; case 'unhealthy': return '当前仅展示 OpenResty 异常节点。'; case 'lagging': return activeVersion ? `当前仅展示未追平激活版本 ${activeVersion} 的节点。` : '当前没有激活版本,无法筛选配置落后节点。'; default: return '列表每 5 秒自动刷新一次。'; } }, [activeVersion, riskFilter]); const handleReset = () => { setFeedback(null); setEditingNode(null); setIsEditorOpen(false); }; const handleCreate = () => { setFeedback(null); setEditingNode(null); setIsEditorOpen(true); }; const handleEdit = (node: NodeItem) => { setFeedback(null); setEditingNode(node); setIsEditorOpen(true); }; const handleDelete = (nodeId: number, nodeName: string) => { if ( !window.confirm( `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`, ) ) { return; } setFeedback(null); deleteMutation.mutate(nodeId); }; return ( <>
新增节点 应用记录 } /> {feedback ? ( ) : null} void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) } > 立即刷新 } > {nodesQuery.isLoading ? ( ) : nodesQuery.isError ? ( ) : filteredNodes.length === 0 ? ( ) : (
全部节点 离线节点 OpenResty 异常 配置落后
{filteredNodes.map((node) => ( ))}
节点 状态 Agent / OpenResty 运行健康 当前版本 最近应用 最近心跳 操作

{node.name}

IP:{node.ip || 'null'} {node.ip_manual_override ? '(已锁定)' : ''}

位置:{node.geo_name || '未配置地图点位'}

{node.agent_version || 'unknown'} /{' '} {node.nginx_version || 'unknown'}
{node.current_version || '未应用'}
{isWSConnectedLastSeen(node.last_seen_at) ? 'WS 已连接' : isMeaningfulTime(node.last_seen_at) ? `${formatRelativeTime( node.last_seen_at, )} · ${formatDateTime(node.last_seen_at)}` : '暂无'}
详情 handleEdit(node)} className="px-3 py-2 text-xs" > 编辑 handleDelete(node.id, node.name)} disabled={deleteMutation.isPending} className="px-3 py-2 text-xs" > 删除
)}
{ setFeedback(null); saveMutation.mutate(payload); }} /> ); }