From f1badac257c37f0bfea980de17cb71abb6cb1c81 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 19:08:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor(wavelet):=20=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E6=94=B9=E4=B8=BA=20Tabs=20?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一 Edge/Relay/Tunnel 详情壳层,拆分概览、数据看板与配置部署, 支持 tab URL 参数深链并优化信息层次与可读性。 --- .../nodes/components/edge-node-detail.tsx | 356 ++++++++-------- .../components/node-detail-primitives.tsx | 79 ++++ .../nodes/components/node-detail-shell.tsx | 183 +++++++++ .../nodes/components/relay-node-detail.tsx | 386 +++++++++--------- .../nodes/components/tunnel-node-detail.tsx | 335 +++++++-------- docs/changelog/index.md | 1 + 6 files changed, 794 insertions(+), 546 deletions(-) create mode 100644 Wavelet/frontend/app/(main)/nodes/components/node-detail-primitives.tsx create mode 100644 Wavelet/frontend/app/(main)/nodes/components/node-detail-shell.tsx 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 ( +
+ {label} +
{children}
+
+ ); +} + +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 07196bf5..e982503e 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -47,6 +47,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