feat(nodes): 调整节点详情 Tab 并新增边缘部署命令

将数据看板并入概览,运行状态与配置合并为状态与部署;
边缘节点支持自动填充 Server URL 与 Agent Token 的 Docker 部署卡片。
This commit is contained in:
ryan
2026-07-19 15:01:38 +08:00
parent 7366832e12
commit c00ead9aa0
7 changed files with 81 additions and 55 deletions
+4 -4
View File
@@ -11,11 +11,12 @@ sidebar: false
## 重大变更
> [!IMPORTANT]
>
>
> 3.1.2 版本更新了 CLickHouse 部署配置。
>
>
> 3.0.0 版本为 Wavelet 平台迁移与架构重构版本,涉及数据库表结构、环境变量以及前后端底层架构的重大变更。请务必在升级前备份数据库,并且更新到 V2.3.4。
> 目前已知的兼容性问题:
>
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
> - 性能调优参数重置, 升级后请重新配置
@@ -28,6 +29,7 @@ sidebar: false
### 改进
- 节点详情页 Tab 调整为「概览」与「状态与部署」:原数据看板并入概览;运行状态与配置信息并入状态与部署;边缘节点新增可自动填充 Server URL 与 Agent Token 的 Docker 部署命令卡片。
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
- WAF 规则编辑器支持一键格式化布局,按流程层次自动整理节点位置。
@@ -90,7 +92,6 @@ sidebar: false
- 移除了 WAF 旧版固定名单与人机验证配置;升级后请在发布前使用新的可视化规则重新编排防护策略。
## [v3.2.0] - 2026-07-12
### 新增
@@ -177,7 +178,6 @@ sidebar: false
- 网站管理拆分为域名、路由、静态托管、WAF 和缓存等独立能力,更适合维护复杂站点配置。
- Tunnel 节点统一纳入节点管理,配置发布和运行状态查看更加一致。
## [v2.3.4] - 2026-06-17
### 变更
@@ -37,6 +37,7 @@ import type {
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,
@@ -199,7 +200,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
</>
);
const overviewTab = (
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
@@ -277,11 +278,9 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className='space-y-6'>
<InstallCommand node={node} variant='edge' />
<NodeSectionCard
title='节点标识'
description='用于 Agent 接入与鉴别的核心字段'
@@ -361,8 +360,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
overview={
<NodeObservability
nodeId={node.id}
node={node}
@@ -18,6 +18,7 @@ import { Label } from '@/components/ui/label';
import { type NodeItem, StatusService } from '@/lib/services/openflare';
import {
buildEdgeDockerInstallCommand,
buildRelayDockerInstallCommand,
buildRelayInstallCommand,
buildTunnelDockerInstallCommand,
@@ -25,7 +26,7 @@ import {
getServerUrl,
} from './node-utils';
type InstallVariant = 'relay' | 'tunnel';
type InstallVariant = 'edge' | 'relay' | 'tunnel';
const variantMeta: Record<
InstallVariant,
@@ -33,10 +34,16 @@ const variantMeta: Record<
title: string;
description: string;
tokenLabel: string;
scriptLabel: string;
scriptLabel?: string;
dockerLabel: string;
}
> = {
edge: {
title: '边缘节点部署',
description: '使用 Agent Token 将边缘节点接入控制端。',
tokenLabel: 'Agent Token',
dockerLabel: 'Docker 容器部署',
},
relay: {
title: '中继部署命令',
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
@@ -79,7 +86,7 @@ export function InstallCommand({
const normalizedServerUrl = getServerUrl(serverUrl);
const scriptCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token) {
if (!normalizedServerUrl || !node.access_token || variant === 'edge') {
return '';
}
return variant === 'relay'
@@ -91,6 +98,13 @@ export function InstallCommand({
if (!normalizedServerUrl || !node.access_token) {
return '';
}
if (variant === 'edge') {
return buildEdgeDockerInstallCommand(
normalizedServerUrl,
node.access_token,
serverVersion,
);
}
return variant === 'relay'
? buildRelayDockerInstallCommand(
normalizedServerUrl,
@@ -190,12 +204,14 @@ export function InstallCommand({
</p>
) : (
<>
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
{scriptCommand}
</pre>
</div>
{scriptCommand && meta.scriptLabel ? (
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
{scriptCommand}
</pre>
</div>
) : null}
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.dockerLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
@@ -14,7 +14,7 @@ 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 NodeDetailTabId = 'overview' | 'manage';
export type NodeDetailTabConfig = {
id: NodeDetailTabId;
@@ -33,18 +33,20 @@ const TAB_CONFIGS: NodeDetailTabConfig[] = [
id: 'overview',
label: '概览',
},
{
id: 'dashboard',
label: '数据看板',
},
{
id: 'manage',
label: '配置与部署',
label: '状态与部署',
},
];
function isValidTab(value: string | null): value is NodeDetailTabId {
return value === 'overview' || value === 'dashboard' || value === 'manage';
function resolveTab(value: string | null): NodeDetailTabId | null {
if (value === 'overview' || value === 'dashboard') {
return 'overview';
}
if (value === 'manage') {
return 'manage';
}
return null;
}
export function NodeDetailShell({
@@ -55,7 +57,6 @@ export function NodeDetailShell({
actions,
kpis,
overview,
dashboard,
manage,
defaultTab = 'overview',
}: {
@@ -66,7 +67,6 @@ export function NodeDetailShell({
actions: ReactNode;
kpis: NodeDetailKpi[];
overview: ReactNode;
dashboard: ReactNode;
manage: ReactNode;
defaultTab?: NodeDetailTabId;
}) {
@@ -76,7 +76,7 @@ export function NodeDetailShell({
const activeTab = useMemo(() => {
const tab = searchParams.get('tab');
return isValidTab(tab) ? tab : defaultTab;
return resolveTab(tab) ?? defaultTab;
}, [defaultTab, searchParams]);
const setActiveTab = useCallback(
@@ -88,6 +88,16 @@ export function NodeDetailShell({
[pathname, router, searchParams],
);
const handleTabChange = useCallback(
(value: string) => {
const tab = resolveTab(value);
if (tab) {
setActiveTab(tab);
}
},
[setActiveTab],
);
return (
<div className='py-6 px-1 space-y-6'>
<section className='overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30'>
@@ -144,11 +154,7 @@ export function NodeDetailShell({
<Tabs
value={activeTab}
onValueChange={(value) => {
if (isValidTab(value)) {
setActiveTab(value);
}
}}
onValueChange={handleTabChange}
className='w-full gap-0'
>
<div className='space-y-3 pb-1'>
@@ -174,9 +180,6 @@ export function NodeDetailShell({
<TabsContent value='overview' className='mt-6 outline-none'>
{activeTab === 'overview' ? overview : null}
</TabsContent>
<TabsContent value='dashboard' className='mt-6 outline-none'>
{activeTab === 'dashboard' ? dashboard : null}
</TabsContent>
<TabsContent value='manage' className='mt-6 outline-none'>
{activeTab === 'manage' ? manage : null}
</TabsContent>
@@ -138,6 +138,25 @@ export function getImageTag(version?: string): string {
return 'latest';
}
export function buildEdgeDockerInstallCommand(
serverUrl: string,
agentToken: string,
version?: string,
) {
const tag = getImageTag(version);
const image = `ghcr.io/rain-kl/openflare-agent:${tag}`;
return [
`docker pull ${image}`,
`docker rm -f openflare-agent 2>/dev/null || true`,
`docker run -d --name openflare-agent --restart unless-stopped \\`,
` -p 80:80 -p 443:443/tcp -p 443:443/udp \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
` ${image}`,
].join('\n');
}
export function buildRelayInstallCommand(
serverUrl: string,
discoveryToken: string,
@@ -278,7 +278,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</>
);
const overviewTab = (
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
@@ -359,11 +359,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className='space-y-6'>
<NodeSectionCard
title='FRPS WebUI'
description='控制 frps 内置 Web 管理界面是否启用及其监听端口'
@@ -514,8 +510,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
overview={
<NodeObservability
nodeId={node.id}
variant='compact'
@@ -183,7 +183,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
</>
);
const overviewTab = (
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
@@ -271,11 +271,9 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className='space-y-6'>
<InstallCommand node={node} variant='tunnel' />
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'>
<div className='divide-y'>
<NodeInfoRow label='Tunnel Token'>
@@ -294,8 +292,6 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
</NodeInfoRow>
</div>
</NodeSectionCard>
<InstallCommand node={node} variant='tunnel' />
</div>
);
@@ -338,8 +334,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
overview={
<NodeObservability
nodeId={node.id}
variant='compact'