'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, getRelayStatusLabel, getRelayStatusVariant, isMeaningfulTime, isNodeAbnormal, isWSConnectedLastSeen, } from '@/features/nodes/utils'; const nodesQueryKey = ['nodes']; const supportedNodeFilters = ['all', 'edge', 'relay', 'tunnel', 'abnormal'] as const; type NodeFilter = (typeof supportedNodeFilters)[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 nodeFilter = useMemo(() => { const current = searchParams.get('filter')?.trim().toLowerCase() ?? ''; if (supportedNodeFilters.includes(current as NodeFilter)) { return current as NodeFilter; } const legacyRisk = searchParams.get('risk')?.trim().toLowerCase() ?? ''; if ( legacyRisk === 'offline' || legacyRisk === 'unhealthy' || legacyRisk === 'lagging' ) { return 'abnormal'; } return 'all'; }, [searchParams]); const filteredNodes = useMemo(() => { switch (nodeFilter) { case 'all': return nodes; case 'relay': return nodes.filter((node) => node.node_type === 'tunnel_relay'); case 'tunnel': return nodes.filter((node) => node.node_type === 'tunnel_client'); case 'abnormal': return nodes.filter((node) => isNodeAbnormal(node, activeVersion)); case 'edge': default: return nodes.filter((node) => node.node_type === 'edge_node'); } }, [activeVersion, nodeFilter, nodes]); const filterDescription = useMemo(() => { switch (nodeFilter) { case 'all': return '当前展示全部节点。'; case 'relay': return '当前仅展示 Relay 节点。'; case 'tunnel': return '当前仅展示 Tunnel 节点。'; case 'abnormal': return activeVersion ? `当前仅展示异常节点(离线、运行异常或未追平激活版本 ${activeVersion})。` : '当前仅展示异常节点(离线或运行异常)。'; case 'edge': default: return '当前仅展示 Edge 节点。'; } }, [activeVersion, nodeFilter]); 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 ? ( setFeedback(null)} /> ) : null} void queryClient.invalidateQueries({queryKey: nodesQueryKey}) } > 立即刷新 } > {nodesQuery.isLoading ? ( ) : nodesQuery.isError ? ( ) : filteredNodes.length === 0 ? ( ) : (
全部节点 Edge Relay Tunnel 异常
{filteredNodes.map((node) => ( ))}
节点 状态 Version 运行健康 当前版本 最近应用 最近心跳 操作

{node.name}

{node.node_type === 'tunnel_relay' ? ( Relay ) : node.node_type === 'tunnel_client' ? ( Client ) : ( Edge )}

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

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

{node.version || 'unknown'}
{node.node_type === 'tunnel_relay' ? ( ) : node.node_type === 'tunnel_client' ? ( ) : ( )}
{node.current_version || (node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
{node.node_type === 'tunnel_relay' ? ( — ) : ( )}
{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); }} /> ); }