'use client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, 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 { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { getConfigVersions } from '@/features/config-versions/api/config-versions'; import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal'; import type { ConfigVersionSummary } from '@/features/config-versions/types'; import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { cleanupNodeHealthEvents, deleteNode, getNodeAgentRelease, getNodeObservability, requestNodeForceSync, requestNodeAgentUpdate, updateNode, } from '@/features/nodes/api/nodes'; import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; import type { NodeItem, NodeAgentReleaseInfo } from '@/features/nodes/types'; import { CodeBlock, DangerButton, PrimaryButton, ResourceField, ResourceInput, SecondaryButton, } from '@/features/shared/components/resource-primitives'; import type { ReleaseChannel } from '@/features/update/types'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatBytes, formatPercent } from '@/lib/utils/metrics'; import { buildRelayInstallCommand, buildRelayDockerInstallCommand, getApplyLabel, getApplyVariant, getServerUrl, getUpdateMode, isMeaningfulTime, isWSConnectedLastSeen, } from '@/features/nodes/utils'; import { copyToClipboard, formatUsageRatio, formatUptime, getErrorMessage, getHealthEventLabel, getHealthEventVariant, FeedbackState, HealthEventFilter, NodeDetailTab, MetricBar, SummaryStat, } from './node-shared'; export function RelayDetailPage({ node }: { node: NodeItem }) { const router = useRouter(); const queryClient = useQueryClient(); const nodeId = String(node.id); const [feedback, setFeedback] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); const [isTargetSnapshotOpen, setIsTargetSnapshotOpen] = useState(false); const [selectedReleaseChannel, setSelectedReleaseChannel] = useState('stable'); const [agentUpdateFeedback, setAgentUpdateFeedback] = useState(null); const [serverUrl, setServerUrl] = useState(''); const [healthEventFilter, setHealthEventFilter] = useState('all'); const [activeTab, setActiveTab] = useState('dashboard'); const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] = useState(false); 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 applyLogsQuery = useQuery({ queryKey: ['apply-logs', node.node_id, 1, 10], queryFn: () => getApplyLogs({ node_id: node.node_id, pageNo: 1, pageSize: 10, }), refetchInterval: 5000, }); const configVersionsQuery = useQuery({ queryKey: ['config-versions'], queryFn: getConfigVersions, refetchInterval: 5000, }); const observabilityQuery = useQuery({ queryKey: ['node-observability', nodeId], queryFn: () => getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }), refetchInterval: 10000, }); useEffect(() => { if (typeof window !== 'undefined' && !serverUrl) { setServerUrl(window.location.origin); } }, [serverUrl]); const saveMutation = useMutation({ mutationFn: async (payload: Parameters[1]) => updateNode(Number(nodeId), payload), onSuccess: async () => { setFeedback({ tone: 'success', message: '中继节点已更新。' }); setIsEditorOpen(false); await queryClient.invalidateQueries({ queryKey: ['nodes'] }); }, 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' ? '预览版' : '正式版'}中继代理更新。`, }); await queryClient.invalidateQueries({ queryKey: ['nodes'] }); }, onError: (error) => { const message = getErrorMessage(error); setFeedback({ tone: 'danger', message }); setAgentUpdateFeedback({ tone: 'danger', message }); }, }); const forceSyncMutation = useMutation({ mutationFn: () => requestNodeForceSync(Number(nodeId)), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', message: `已向中继节点 ${updatedNode.name} 下发强制同步指令,无视当前错误拦截。`, }); await queryClient.invalidateQueries({ queryKey: ['nodes'] }); }, 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: ['nodes'] }); router.push('/node'); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const cleanupHealthEventsMutation = useMutation({ mutationFn: () => cleanupNodeHealthEvents(Number(nodeId)), onSuccess: async (result) => { setFeedback({ tone: 'success', message: result.deleted_count > 0 ? `已清理 ${result.deleted_count} 条中继健康事件日志。` : '当前没有可清理的健康事件日志。', }); setHealthEventCleanupModalOpen(false); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId], }), queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }), ]); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const handleDelete = () => { 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) }); } }; const activeConfigVersion = useMemo(() => { return ( (configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null ); }, [configVersionsQuery.data]); const observability = observabilityQuery.data ?? null; const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null; const activeHealthEvents = useMemo( () => observability?.health_events.filter( (event) => event.status === 'active', ) ?? [], [observability?.health_events], ); const resolvedHealthEvents = useMemo( () => observability?.health_events.filter( (event) => event.status === 'resolved', ) ?? [], [observability?.health_events], ); const filteredHealthEvents = useMemo(() => { switch (healthEventFilter) { case 'active': return activeHealthEvents; case 'resolved': return resolvedHealthEvents; default: return observability?.health_events ?? []; } }, [ activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents, ]); const tabs = useMemo( () => [ { key: 'dashboard' as const, label: '数据看板', description: '隧道状态、资源快照与运行诊断。', }, { key: 'info' as const, label: '节点信息', description: '更新模式、版本状态、部署信息与应用记录。', }, ] satisfies Array<{ key: NodeDetailTab; label: string; description: string; }>, [], ); const normalizedServerUrl = getServerUrl(serverUrl); const relayInstallCommand = normalizedServerUrl && node.access_token ? buildRelayInstallCommand(normalizedServerUrl, node.access_token) : ''; const relayDockerInstallCommand = normalizedServerUrl && node.access_token ? buildRelayDockerInstallCommand(normalizedServerUrl, node.access_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?.rows ?? []; const latestHealthEvent = activeHealthEvents[0] ?? null; const memoryUsageRatio = formatUsageRatio( latestMetricSnapshot?.memory_used_bytes, latestMetricSnapshot?.memory_total_bytes, ); const storageUsageRatio = formatUsageRatio( latestMetricSnapshot?.storage_used_bytes, latestMetricSnapshot?.storage_total_bytes, ); const isTargetVersionApplied = activeConfigVersion !== null && activeConfigVersion.version === node.current_version; 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); }; const isRefreshing = applyLogsQuery.isFetching || observabilityQuery.isFetching || configVersionsQuery.isFetching; const handleRefresh = async () => { setFeedback(null); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['nodes'] }), queryClient.invalidateQueries({ queryKey: ['apply-logs', node.node_id], }), queryClient.invalidateQueries({ queryKey: ['config-versions'], }), queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId], }), ]); }; return ( <>
返回 setIsEditorOpen(true)} > 编辑节点 void handleRefresh()} disabled={isRefreshing} > {isRefreshing ? '刷新中...' : '刷新'} { setFeedback(null); forceSyncMutation.mutate(); }} disabled={forceSyncMutation.isPending} > {forceSyncMutation.isPending ? '同步中...' : '同步'} {node.update_requested ? '查看升级' : '升级'} 删除 } /> {feedback ? ( setFeedback(null)} /> ) : null}
{tabs.map((tab) => ( ))}
{activeTab === 'dashboard' ? ( <>
{observabilityQuery.isLoading ? ( ) : observabilityQuery.isError ? ( ) : observability?.profile ? (

主机名

{observability.profile.hostname || node.name}

操作系统

{observability.profile.os_name || 'unknown'} {observability.profile.os_version ? ` ${observability.profile.os_version}` : ''}

内核 / 架构

{observability.profile.kernel_version || 'unknown'} ·{' '} {observability.profile.architecture || 'unknown'}

在线时长

{formatUptime(observability.profile.uptime_seconds)}

CPU

{observability.profile.cpu_model || 'unknown'}

{observability.profile.cpu_cores || 0} 核

总内存

{formatBytes( observability.profile.total_memory_bytes, )}

总存储

{formatBytes(observability.profile.total_disk_bytes)}

上报时间

{isMeaningfulTime(observability.profile.reported_at) ? formatDateTime(observability.profile.reported_at) : '—'}

) : ( )}
{observabilityQuery.isLoading ? ( ) : observabilityQuery.isError ? ( ) : latestMetricSnapshot ? (
) : ( )}
setHealthEventCleanupModalOpen(true)} > {cleanupHealthEventsMutation.isPending ? '清理中...' : '清理日志'} } > {observability?.health_events.length ? (
{filteredHealthEvents.slice(0, 8).map((event) => (

{event.message || '暂无详细消息'}

首次触发: {isMeaningfulTime(event.first_triggered_at) ? ` ${formatDateTime(event.first_triggered_at)}` : ' —'}

最近触发: {isMeaningfulTime(event.last_triggered_at) ? ` ${formatDateTime(event.last_triggered_at)}` : ' —'}

恢复时间: {isMeaningfulTime(event.resolved_at) ? ` ${formatDateTime(event.resolved_at)}` : ' —'}

))} {filteredHealthEvents.length === 0 ? (
当前筛选下没有健康事件。
) : null}
) : ( )}
) : null} {activeTab === 'info' ? ( <>

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

Relay 中继版本:{node.version || 'unknown'}

frps 核心版本:{node.ext_version || 'unknown'}

中继网络接入:{node.relay_agent_access_addr || '—'}

{node.relay_web_server_enabled ? (

FRPS WebUI: 点击打开 Web 界面

) : (

FRPS WebUI:已禁用

)}

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

{node.latest_apply_checksum ? (

同步文件:{node.latest_support_file_count} 个

) : null}
setIsTargetSnapshotOpen(true)} > 查看目标快照 ) : null } > {configVersionsQuery.isLoading ? ( ) : configVersionsQuery.isError ? ( ) : activeConfigVersion ? (

追平状态

{isTargetVersionApplied ? '当前节点已应用全局最新配置。' : '当前中继代理版本落后于全局激活配置,请检查下面配置应用历史。'}

目标配置版本

{activeConfigVersion.version}

配置校验和

{activeConfigVersion.checksum}

全局激活时间

{formatDateTime(activeConfigVersion.created_at)}

) : ( )}
{relayInstallCommand ? ( void handleCopy( relayInstallCommand, '部署脚本命令已复制。', ) } > 复制脚本命令 ) : null} {relayDockerInstallCommand ? ( void handleCopy( relayDockerInstallCommand, 'Docker 部署命令已复制。', ) } > 复制 Docker 命令 ) : null}
} >

Node ID

{node.node_id}

Discovery Token (中继注册 Token)

{node.access_token || '暂无'}

setServerUrl(event.target.value)} /> {relayInstallCommand ? (

一键脚本部署 (Linux / macOS)

{relayInstallCommand}
) : null} {relayDockerInstallCommand ? (

Docker 容器部署

{relayDockerInstallCommand}
) : null}

中继 IP 地址

{node.ip || '暂无上报'} {node.ip_manual_override ? '(管理端锁定)' : ''}

绑定控制端口

{node.relay_bind_port || '—'}

最后心跳

{isWSConnectedLastSeen(node.last_seen_at) ? 'WS 已连接' : isMeaningfulTime(node.last_seen_at) ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` : '暂无'}

中继节点最近错误

{node.last_error || '无'}

创建时间

{formatDateTime(node.created_at)}

更新时间

{formatDateTime(node.updated_at)}

查看完整记录 } > {applyLogsQuery.isLoading ? ( ) : applyLogsQuery.isError ? ( ) : applyLogs.length === 0 ? ( ) : (
{applyLogs.map((log) => ( ))}
配置版本 结果 校验和 应用时间 消息
{log.version} {log.checksum ? `${log.checksum.slice(0, 12)}...` : '—'} {formatRelativeTime(log.created_at)} ·{' '} {formatDateTime(log.created_at)}

{log.message || '—'}

)}
) : null} setIsTargetSnapshotOpen(false)} /> setIsEditorOpen(false)} title="编辑中继节点" description="可以修改中继节点名、中继控制端口等信息。" submitLabel="保存修改" onSubmit={(payload) => { setFeedback(null); saveMutation.mutate(payload); }} /> setHealthEventCleanupModalOpen(false)} title="清理运行诊断日志" description={`确认清理中继节点“${node.name}”的健康诊断事件日志吗?`} footer={
setHealthEventCleanupModalOpen(false)} > 取消 { setFeedback(null); cleanupHealthEventsMutation.mutate(); }} > {cleanupHealthEventsMutation.isPending ? '清理中...' : '确认清理'}
} > {cleanupHealthEventsMutation.isError ? ( ) : (

该操作会删除此中继节点在控制端记录的所有健康诊断事件历史。

这不会影响节点在后续运行中继续捕捉并上报新的故障。

)}
setIsAgentUpdateModalOpen(false)} title="中继代理升级" description="管理当前中继软件 (relay/agent) 版本。" footer={
{isCheckingAgentRelease && selectedReleaseChannel === 'stable' ? '检查中...' : '检查正式版'} {isCheckingAgentRelease && selectedReleaseChannel === 'preview' ? '检查中...' : '检查预览版'} {updateAgentMutation.isPending ? '下发中...' : selectedReleaseChannel === 'preview' ? '升级到预览版' : '升级到正式版'}
} >
{agentUpdateFeedback ? ( ) : null}

{node.version || 'unknown'}

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

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

当前中继软件版本

{selectedAgentRelease.current_version || 'unknown'}

最新发布版本

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

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