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