diff --git a/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx b/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx
index 028b7dda..31418c8d 100644
--- a/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx
+++ b/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx
@@ -4,7 +4,17 @@ import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
-import {ArrowLeft, FileText, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
+import {
+ Activity,
+ Cpu,
+ FileText,
+ Fingerprint,
+ Package,
+ RefreshCw,
+ RotateCcw,
+ Trash2,
+ Upload,
+} from 'lucide-react';
import {toast} from 'sonner';
import {
@@ -18,12 +28,17 @@ import {
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 {NodeDetailShell} from './node-detail-shell';
+import {
+ NodeErrorBanner,
+ NodeInfoRow,
+ NodeSectionCard,
+} from './node-detail-primitives';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
@@ -116,180 +131,189 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
]);
};
- return (
-
-
-
-
-
{node.name}
-
-
+ const headerActions = (
+ <>
+
+
+
+
+
+
+ >
+ );
-
-
-
-
-
-
-
-
-
+ const overviewTab = (
+
+ {node.last_error ?
: null}
-
-
-
- 节点 ID
- {node.node_id}
-
-
-
-
- Agent 版本
- {node.version || 'unknown'}
-
-
-
-
- 当前配置版本
- {node.current_version || '未应用'}
-
-
-
-
- 最近心跳
-
+
+
+
+
+
+
+
+
+
+
{isMeaningfulTime(node.last_seen_at)
- ? formatRelativeTime(node.last_seen_at)
+ ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
-
-
-
-
-
-
-
-
- 节点信息
- 边缘代理节点 (edge_node)
-
-
-
-
-
- 运行状态
-
-
-
- OpenResty 健康
-
-
-
- 最近应用
-
-
-
- IP 地址
-
+
+
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
-
+
+ {node.geo_name || '未配置'}
-
-
地图点位
-
{node.geo_name || '未配置'}
+
+
+
+
+
+ 应用记录
+
+
+ }
+ >
+
+ {node.current_version || '未应用'}
+
+
+
+
+ {isMeaningfulTime(node.latest_apply_at)
+ ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
+ : '暂无'}
+
+ {node.version || 'unknown'}
+ {node.ext_version || 'unknown'}
+ {node.auto_update_enabled ? '已启用' : '手动'}
-
- OpenResty 状态消息
-
- {node.openresty_message || '无'}
-
+
+
+
+ );
+
+ const manageTab = (
+
+
+
+
+ {node.node_id}
+
+
+ {node.access_token || '暂无'}
+
+ {formatDateTime(node.created_at)}
+ {formatDateTime(node.updated_at)}
+
+
+
+
+
+
+
+ OpenResty 状态消息
+
+
+ {node.openresty_message || '当前未上报额外错误。'}
+
-
- 创建时间
- {formatDateTime(node.created_at)}
-
-
- 更新时间
- {formatDateTime(node.updated_at)}
-
- {node.last_error ? (
-
- {node.last_error}
+ {node.restart_openresty_requested ? (
+
+
+ 已等待节点在下一次心跳后执行 OpenResty 重启。
) : null}
-
-
+
+
+
+ );
-
+
+ }
+ manage={manageTab}
+ defaultTab="overview"
/>
-
+ >
);
-}
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-detail-primitives.tsx b/Wavelet/frontend/app/(main)/nodes/components/node-detail-primitives.tsx
new file mode 100644
index 00000000..fe4152a1
--- /dev/null
+++ b/Wavelet/frontend/app/(main)/nodes/components/node-detail-primitives.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import type {ReactNode} from 'react';
+import type {LucideIcon} from 'lucide-react';
+
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
+import {cn} from '@/lib/utils';
+
+export function NodeInfoRow({
+ label,
+ children,
+ className,
+}: {
+ label: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+export function NodeKpiCard({
+ label,
+ value,
+ icon: Icon,
+}: {
+ label: string;
+ value: ReactNode;
+ icon?: LucideIcon;
+}) {
+ return (
+
+
+ {Icon ? : null}
+ {label}
+
+
{value}
+
+ );
+}
+
+export function NodeSectionCard({
+ title,
+ description,
+ action,
+ children,
+ className,
+}: {
+ title: string;
+ description?: string;
+ action?: React.ReactNode;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+
+ {title}
+ {description ? {description} : null}
+
+ {action}
+
+ {children}
+
+ );
+}
+
+export function NodeErrorBanner({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-detail-shell.tsx b/Wavelet/frontend/app/(main)/nodes/components/node-detail-shell.tsx
new file mode 100644
index 00000000..a712c8bf
--- /dev/null
+++ b/Wavelet/frontend/app/(main)/nodes/components/node-detail-shell.tsx
@@ -0,0 +1,183 @@
+'use client';
+
+import Link from 'next/link';
+import {usePathname, useRouter, useSearchParams} from 'next/navigation';
+import {useCallback, useMemo, type ReactNode} from 'react';
+import type {LucideIcon} from 'lucide-react';
+import {ArrowLeft} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
+import {cn} from '@/lib/utils';
+
+import {NodeKpiCard} from './node-detail-primitives';
+import {NodeStatusBadge} from './node-status-badge';
+import type {StatusTone} from './node-utils';
+
+export type NodeDetailTabId = 'overview' | 'dashboard' | 'manage';
+
+export type NodeDetailTabConfig = {
+ id: NodeDetailTabId;
+ label: string;
+ description: string;
+ icon?: LucideIcon;
+};
+
+export type NodeDetailKpi = {
+ label: string;
+ value: ReactNode;
+ icon?: LucideIcon;
+};
+
+const TAB_CONFIGS: NodeDetailTabConfig[] = [
+ {
+ id: 'overview',
+ label: '概览',
+ description: '快速查看节点身份、运行状态与关键指标。',
+ },
+ {
+ id: 'dashboard',
+ label: '数据看板',
+ description: '系统画像、资源快照、流量趋势与健康事件时间线。',
+ },
+ {
+ id: 'manage',
+ label: '配置与部署',
+ description: '版本同步、部署命令与节点运维配置。',
+ },
+];
+
+function isValidTab(value: string | null): value is NodeDetailTabId {
+ return value === 'overview' || value === 'dashboard' || value === 'manage';
+}
+
+export function NodeDetailShell({
+ title,
+ typeLabel,
+ typeTone = 'info',
+ statusBadges,
+ actions,
+ kpis,
+ overview,
+ dashboard,
+ manage,
+ defaultTab = 'overview',
+}: {
+ title: string;
+ typeLabel: string;
+ typeTone?: StatusTone;
+ statusBadges: Array<{ label: string; tone: StatusTone }>;
+ actions: ReactNode;
+ kpis: NodeDetailKpi[];
+ overview: ReactNode;
+ dashboard: ReactNode;
+ manage: ReactNode;
+ defaultTab?: NodeDetailTabId;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const activeTab = useMemo(() => {
+ const tab = searchParams.get('tab');
+ return isValidTab(tab) ? tab : defaultTab;
+ }, [defaultTab, searchParams]);
+
+ const setActiveTab = useCallback(
+ (tab: NodeDetailTabId) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set('tab', tab);
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
+ },
+ [pathname, router, searchParams],
+ );
+
+ const activeTabMeta = TAB_CONFIGS.find((tab) => tab.id === activeTab) ?? TAB_CONFIGS[0];
+
+ return (
+
+
+
+
+
+
+
+
+
+
{title}
+
+
+
+ {statusBadges.map((badge) => (
+
+ ))}
+
+
+
+
+
{actions}
+
+
+ {kpis.length > 0 ? (
+
+ {kpis.map((kpi) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
{
+ if (isValidTab(value)) {
+ setActiveTab(value);
+ }
+ }}
+ className="w-full gap-0"
+ >
+
+
+ {TAB_CONFIGS.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
+
{activeTabMeta.description}
+
+
+
+ {activeTab === 'overview' ? overview : null}
+
+
+ {activeTab === 'dashboard' ? dashboard : null}
+
+
+ {activeTab === 'manage' ? manage : null}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx b/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx
index d3b81d5b..094a6ef1 100644
--- a/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx
+++ b/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx
@@ -4,7 +4,18 @@ import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
-import {ArrowLeft, ExternalLink, FileText, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
+import {
+ Activity,
+ ExternalLink,
+ FileText,
+ Fingerprint,
+ Network,
+ RefreshCw,
+ RotateCcw,
+ Server,
+ Trash2,
+ Upload,
+} from 'lucide-react';
import {toast} from 'sonner';
import {
@@ -18,7 +29,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
-import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {formatDateTime} from '@/lib/utils';
@@ -27,6 +37,12 @@ import {NodeService} from '@/lib/services/openflare';
import {AgentUpdateDialog} from './agent-update-dialog';
import {InstallCommand} from './install-command';
+import {NodeDetailShell} from './node-detail-shell';
+import {
+ NodeErrorBanner,
+ NodeInfoRow,
+ NodeSectionCard,
+} from './node-detail-primitives';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
@@ -139,218 +155,196 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
? `http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`
: null;
- return (
-
-
-
-
-
{node.name}
-
-
+ const headerActions = (
+ <>
+
+
+
+
+
+ >
+ );
-
-
-
-
-
-
-
-
-
-
-
-
- 节点 ID
- {node.node_id}
-
-
-
-
- Relay 版本
- {node.version || 'unknown'}
-
-
-
-
- frps 核心版本
- {node.ext_version || 'unknown'}
-
-
-
-
- 最近心跳
-
- {isWSConnectedLastSeen(node.last_seen_at)
- ? 'WS 已连接'
- : isMeaningfulTime(node.last_seen_at)
- ? formatRelativeTime(node.last_seen_at)
- : '暂无'}
-
-
-
-
+ const overviewTab = (
+
+ {node.last_error ?
: null}
-
-
- 中继网络
- 隧道中继节点 (tunnel_relay / frps)
-
-
-
-
运行状态
+
+
+
-
-
- 中继健康
+
+
-
-
- 绑定控制端口
- {node.relay_bind_port || '—'}
-
-
- VHost HTTP 端口
- {node.relay_vhost_http_port || '—'}
-
-
- Agent 接入地址
- {node.relay_agent_access_addr || '—'}
-
-
- 活动连接 / 代理数
-
- {node.relay_frps_connections ?? '—'} / {node.relay_frps_proxy_count ?? '—'}
-
-
-
- IP 地址
-
- {node.ip || '—'}
- {node.ip_manual_override ? '(已锁定)' : ''}
-
-
-
- 最近应用
+
+
+ {isWSConnectedLastSeen(node.last_seen_at)
+ ? 'WS 已连接'
+ : isMeaningfulTime(node.last_seen_at)
+ ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
+ : '暂无'}
+
+
+ {node.relay_frps_connections ?? '—'} / {node.relay_frps_proxy_count ?? '—'}
+
+
+ {node.ip || '—'}
+ {node.ip_manual_override ? '(已锁定)' : ''}
+
+
+
+
+
+
+
+ 应用记录
+
+
+ }
+ >
+
+ {node.relay_bind_port || '—'}
+ {node.relay_vhost_http_port || '—'}
+
+ {node.relay_agent_access_addr || '—'}
+
+
-
- {node.last_error ? (
-
- {node.last_error}
-
- ) : null}
-
-
-
-
-
-
- FRPS WebUI
- 控制 frps 内置 Web 管理界面是否启用
-
-
-
-
-
-
-
-
- 默认监听绑定端口 + 500,例如 {node.relay_bind_port || 7000} →{' '}
- {(node.relay_bind_port || 7000) + 500}
-
-
-
webServerMutation.mutate(checked)}
- />
-
-
- {webUiUrl ? (
-
- 打开 FRPS WebUI
-
-
- ) : (
- WebUI 已禁用或未配置绑定端口。
- )}
-
-
-
- 创建时间
- {formatDateTime(node.created_at)}
-
-
- 更新时间
- {formatDateTime(node.updated_at)}
-
-
-
-
+
+ {node.version || 'unknown'}
+ {node.ext_version || 'unknown'}
+
+
+
+ );
+
+ const manageTab = (
+
+
+
+
+
+
+
+ 默认监听绑定端口 + 500,例如 {node.relay_bind_port || 7000} →{' '}
+ {(node.relay_bind_port || 7000) + 500}
+
+
+
webServerMutation.mutate(checked)}
+ />
+
+
+ {webUiUrl ? (
+
+ 打开 FRPS WebUI
+
+
+ ) : (
+
WebUI 已禁用或未配置绑定端口。
+ )}
+
+
-
+
+
+ {node.node_id}
+
+ {formatDateTime(node.created_at)}
+ {formatDateTime(node.updated_at)}
+
+
+
+ );
+
+ return (
+ <>
+
+ }
+ manage={manageTab}
+ defaultTab="overview"
/>
-
+ >
);
}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx b/Wavelet/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx
index e7d57bce..8f483ec9 100644
--- a/Wavelet/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx
+++ b/Wavelet/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx
@@ -4,7 +4,17 @@ import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
-import {ArrowLeft, FileText, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
+import {
+ Activity,
+ FileText,
+ Fingerprint,
+ KeyRound,
+ Package,
+ RefreshCw,
+ RotateCcw,
+ Trash2,
+ Upload,
+} from 'lucide-react';
import {toast} from 'sonner';
import {
@@ -18,13 +28,18 @@ import {
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 {InstallCommand} from './install-command';
+import {NodeDetailShell} from './node-detail-shell';
+import {
+ NodeErrorBanner,
+ NodeInfoRow,
+ NodeSectionCard,
+} from './node-detail-primitives';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
@@ -109,192 +124,104 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
]);
};
- return (
-
-
-
-
-
{node.name}
-
-
+ const headerActions = (
+ <>
+
+
+
+
+
+ >
+ );
-
-
-
-
-
-
-
-
-
-
-
-
- 节点 ID
- {node.node_id}
-
-
-
-
- openflared 版本
- {node.version || 'unknown'}
-
-
-
-
- 当前配置版本
- {node.current_version || '未应用'}
-
-
-
-
- 最近心跳
-
- {isWSConnectedLastSeen(node.last_seen_at)
- ? 'WS 已连接'
- : isMeaningfulTime(node.last_seen_at)
- ? formatRelativeTime(node.last_seen_at)
- : '暂无'}
-
-
-
-
+ const overviewTab = (
+
+ {node.last_error ?
: null}
-
-
- 隧道客户端
- 隧道客户端节点 (tunnel_client / openflared)
-
-
-
-
运行状态
+
+
+
-
-
- flared 状态
+
+
-
-
- Tunnel Token
-
- {node.access_token || '暂无'}
-
-
-
- IP 地址
-
- {node.ip || '—'}
- {node.ip_manual_override ? '(已锁定)' : ''}
-
-
-
- 最近应用
-
-
-
- 自动更新
- {node.auto_update_enabled ? '已启用' : '手动'}
-
-
- 创建时间
- {formatDateTime(node.created_at)}
-
-
- 更新时间
- {formatDateTime(node.updated_at)}
-
- {node.last_error ? (
-
- {node.last_error}
-
- ) : null}
-
-
+
+
+ {isWSConnectedLastSeen(node.last_seen_at)
+ ? 'WebSocket 长连接已建立'
+ : isMeaningfulTime(node.last_seen_at)
+ ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
+ : '暂无'}
+
+
+ {node.ip || '—'}
+ {node.ip_manual_override ? '(已锁定)' : ''}
+
+ {node.auto_update_enabled ? '已启用' : '手动'}
+
+
-
-
-
- 同步状态
- 配置追平与应用结果
-
-
-
-
-
-
最近应用结果
+ }
+ >
+
+ {node.current_version || '未应用'}
+
-
-
- 最近应用时间
-
- {isMeaningfulTime(node.latest_apply_at) && node.latest_apply_at
- ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
- : '暂无'}
-
-
+
+
+ {isMeaningfulTime(node.latest_apply_at)
+ ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
+ : '暂无'}
+
{node.latest_apply_checksum ? (
-
- 同步文件数
- {node.latest_support_file_count} 个
-
+
{node.latest_support_file_count} 个
) : null}
-
- 升级状态
+
-
-
- 最近心跳详情
-
- {isWSConnectedLastSeen(node.last_seen_at)
- ? 'WebSocket 长连接已建立'
- : isMeaningfulTime(node.last_seen_at)
- ? formatDateTime(node.last_seen_at)
- : '暂无'}
-
-
-
-
+
+
+
+
+ );
+
+ const manageTab = (
+
+
+
+
+ {node.access_token || '暂无'}
+
+
+ {node.node_id}
+
+ {formatDateTime(node.created_at)}
+ {formatDateTime(node.updated_at)}
+
+
+
+ );
-
+
+ }
+ manage={manageTab}
+ defaultTab="overview"
/>
-
+ >
);
}
\ No newline at end of file
diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index 146f417c..37195a2d 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -49,6 +49,7 @@ sidebar: false
- 前端 UI 打磨:节点/WAF/DNS/配置清理等 Dialog 补 RHF+Zod;代理规则详情与访问日志 Tab 补 error 态;`pnpm build:embed` 增至 47 静态页。
- 修复开发模式下 `/api/*` 尾斜杠引发 Next.js 308 与 Gin 301 循环重定向:前端 Service 与 `api-client` 请求拦截器规范化路径、强制浏览器同源代理、Next `skipTrailingSlashRedirect`、后端 `RegisterCollection` 双路径注册;Access Token 管理员权限校验对齐 `token_admin`。
- 补全 Wavelet 边缘节点详情数据看板:迁移运行诊断摘要、系统信息、实时资源、网络流量、24 小时请求/容量/网络/磁盘趋势、请求结构分布与健康事件时间线。
+- 节点详情页改为 Tabs 布局(概览 / 数据看板 / 配置与部署),统一 Edge/Relay/Tunnel 详情壳层并支持 `tab` URL 参数深链。
- OpenFlare 前端路由去除 `/openflare` 前缀,业务页面直接挂载于 `/`(如 `/nodes`、`/websites`);侧栏移除「首页」「我的文件」;旧路径 `/openflare/*`、`/home` 永久重定向至新路径。
## [v2.3.4] - 2026-06-17