[优化] 更新节点类型支持,添加隧道客户端,重构相关路由和配置

This commit is contained in:
ryan
2026-06-01 14:01:05 +08:00
parent a092935623
commit 6fa31cafc7
30 changed files with 317 additions and 1337 deletions
@@ -1,11 +0,0 @@
import type { Metadata } from 'next';
import { TunnelsPage } from '@/features/tunnels/components/tunnels-page';
export const metadata: Metadata = {
title: '内网穿透 - OpenFlare',
};
export default function Page() {
return <TunnelsPage />;
}
@@ -746,39 +746,77 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
: '当前没有活动中的健康事件'
}
/>
<SummaryStat
label="当前窗口请求"
value={
trafficSummary
? trafficSummary.request_count.toLocaleString('zh-CN')
: '—'
}
hint={
trafficSummary
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
}
/>
<SummaryStat
label="容量压力"
value={
healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'
}
hint={
latestMetricSnapshot
? `CPU ${formatPercent(latestMetricSnapshot.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
: '当前没有资源快照'
}
/>
<SummaryStat
label="来源信号"
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
: '当前没有来源分布数据'
}
/>
{node.node_type === 'tunnel_relay' ? (
<>
<SummaryStat
label="隧道总数"
value={
observability?.relay_dashboard
? `${observability.relay_dashboard.total_proxies}`
: '—'
}
hint={
observability?.relay_dashboard
? `${observability.relay_dashboard.online_proxies} 个在线,${observability.relay_dashboard.offline_proxies} 个离线`
: '暂无隧道统计'
}
/>
<SummaryStat
label="当前连接数"
value={
observability?.relay_dashboard
? `${observability.relay_dashboard.total_connections}`
: '—'
}
hint="隧道网络承载的活动连接总数"
/>
<SummaryStat
label="活动客户端"
value={
observability?.relay_dashboard
? `${observability.relay_dashboard.client_counts}`
: '—'
}
hint="已连接并活跃的客户端数量"
/>
</>
) : (
<>
<SummaryStat
label="当前窗口请求"
value={
trafficSummary
? trafficSummary.request_count.toLocaleString('zh-CN')
: '—'
}
hint={
trafficSummary
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
}
/>
<SummaryStat
label="容量压力"
value={
healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'
}
hint={
latestMetricSnapshot
? `CPU ${formatPercent(latestMetricSnapshot.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
: '当前没有资源快照'
}
/>
<SummaryStat
label="来源信号"
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
: '当前没有来源分布数据'
}
/>
</>
)}
</div>
<div className="grid gap-6 xl:grid-cols-3">
@@ -940,7 +978,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
)}
</AppCard>
<AppCard title="网络流量">
{node.node_type === 'edge_node' ? (
<AppCard title="网络流量">
{observabilityQuery.isLoading ? (
<LoadingState />
) : observabilityQuery.isError ? (
@@ -1054,9 +1093,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
/>
)}
</AppCard>
) : null}
</div>
<div className="grid gap-6 xl:grid-cols-2">
{node.node_type === 'edge_node' ? (
<>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
title="24 小时请求趋势"
description="按小时聚合该节点的请求量和错误量。"
@@ -1202,12 +1244,64 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
},
]}
/>
</AppCard>
</div>
</AppCard>
</div>
</>
) : null}
{node.node_type === 'tunnel_relay' ? (
<div className="grid gap-6 xl:grid-cols-1">
<AppCard title="隧道运行状态" description="当前中继节点上活跃的代理通道。">
{observability?.relay_dashboard?.proxies ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-[var(--foreground-secondary)]">
<thead className="border-b border-[var(--border-default)]">
<tr>
<th className="px-4 py-3 font-medium">代理名称</th>
<th className="px-4 py-3 font-medium">类型</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">客户端地址</th>
<th className="px-4 py-3 font-medium">最近连接</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{observability.relay_dashboard.proxies.map((proxy) => (
<tr key={proxy.name}>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{proxy.name}
</td>
<td className="px-4 py-3">{proxy.type}</td>
<td className="px-4 py-3">
<StatusBadge
label={proxy.status === 'online' ? '在线' : '离线'}
variant={proxy.status === 'online' ? 'success' : 'info'}
/>
</td>
<td className="px-4 py-3">{proxy.client_addr}</td>
<td className="px-4 py-3">
{isMeaningfulTime(proxy.last_start_time)
? formatRelativeTime(proxy.last_start_time)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState
title="暂无通道信息"
description="当前节点未上报映射通道状态,或尚未有客户端连接。"
/>
)}
</AppCard>
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<AppCard
title="请求结构分布"
{node.node_type === 'edge_node' ? (
<AppCard
title="请求结构分布"
description="聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。"
>
<div className="mb-6 grid gap-4 md:grid-cols-3">
@@ -1273,6 +1367,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</div>
</div>
</AppCard>
) : null}
<AppCard
title="健康事件时间线"
@@ -1519,8 +1614,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
)}
</AppCard>
<AppCard
title="OpenResty 健康与控制"
{node.node_type === 'edge_node' ? (
<AppCard
title="OpenResty 健康与控制"
description="OpenResty 当前健康状态。"
action={
<div className="flex flex-wrap gap-3">
@@ -1569,6 +1665,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</div>
</div>
</AppCard>
) : null}
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard
@@ -37,7 +37,7 @@ type RegionOption = {
const nodeEditorSchema = z
.object({
type: z.enum(['edge_node', 'tunnel_relay']),
type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z
.string()
.trim()
@@ -48,6 +48,7 @@ const nodeEditorSchema = z
auto_update_enabled: z.boolean(),
relay_bind_port: z.string().trim(),
relay_client_access_addr: z.string().trim(),
relay_agent_access_addr: z.string().trim(),
relay_client_proxy_url: z.string().trim(),
geo_manual_override: z.boolean(),
geo_region: z.string(),
@@ -129,6 +130,7 @@ const defaultValues: NodeEditorValues = {
auto_update_enabled: false,
relay_bind_port: '7000',
relay_client_access_addr: '',
relay_agent_access_addr: '',
relay_client_proxy_url: '',
geo_manual_override: false,
geo_region: '',
@@ -218,6 +220,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
auto_update_enabled: node.auto_update_enabled ?? false,
relay_bind_port: String(node.relay_bind_port ?? 7000),
relay_client_access_addr: node.relay_client_access_addr ?? '',
relay_agent_access_addr: node.relay_agent_access_addr ?? '',
relay_client_proxy_url: node.relay_client_proxy_url ?? '',
geo_manual_override: node.geo_manual_override ?? false,
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
@@ -242,6 +245,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
auto_update_enabled: values.auto_update_enabled,
relay_bind_port: values.type === 'tunnel_relay' ? Number(values.relay_bind_port) : undefined,
relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined,
relay_agent_access_addr: values.type === 'tunnel_relay' ? values.relay_agent_access_addr.trim() : undefined,
relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined,
};
@@ -353,6 +357,7 @@ export function NodeEditorModal({
<ResourceSelect {...form.register('type')} disabled={!!node}>
<option value="edge_node">边缘节点 (Edge Node)</option>
<option value="tunnel_relay">隧道中继节点 (Tunnel Relay)</option>
<option value="tunnel_client">隧道客户端 (Tunnel Client)</option>
</ResourceSelect>
</ResourceField>
@@ -386,6 +391,14 @@ export function NodeEditorModal({
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
</ResourceField>
<ResourceField
label="边缘节点接入地址 (Edge Node Access Addr)"
hint="可选,用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
error={form.formState.errors.relay_agent_access_addr?.message}
>
<ResourceInput placeholder="例如: 10.0.0.1:7000" {...form.register('relay_agent_access_addr')} />
</ResourceField>
<ResourceField
label="代理地址 (Proxy URL)"
hint="可选,下发给客户端。当客户端连接中继需要经过 HTTP 代理时使用。"
@@ -296,7 +296,7 @@ export function NodesPage() {
<th className="px-3 py-3 font-medium">节点</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">
Agent / OpenResty
Version
</th>
<th className="px-3 py-3 font-medium">运行健康</th>
<th className="px-3 py-3 font-medium">当前版本</th>
@@ -318,6 +318,10 @@ export function NodesPage() {
<span className="rounded-full border border-blue-500/20 bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Relay
</span>
) : node.node_type === 'tunnel_client' ? (
<span className="rounded-full border border-purple-500/20 bg-purple-50 px-2 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
Client
</span>
) : (
<span className="rounded-full border border-gray-500/20 bg-gray-50 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
Edge
@@ -339,9 +343,8 @@ export function NodesPage() {
variant={getNodeStatusVariant(node.status)}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.agent_version || 'unknown'} /{' '}
{node.nginx_version || 'unknown'}
<td className="px-3 py-4 text-sm text-[var(--foreground-secondary)]">
{node.agent_version || 'unknown'}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
@@ -350,6 +353,8 @@ export function NodesPage() {
label={getRelayStatusLabel(node.relay_status)}
variant={getRelayStatusVariant(node.relay_status)}
/>
) : node.node_type === 'tunnel_client' ? (
<span className="text-sm text-[var(--foreground-secondary)]">-</span>
) : (
<StatusBadge
label={getOpenrestyStatusLabel(
+24 -2
View File
@@ -3,12 +3,13 @@ import type { ReleaseChannel } from '@/features/update/types';
export interface NodeItem {
id: number;
node_id: string;
node_type: 'edge_node' | 'tunnel_relay';
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
name: string;
ip: string;
ip_manual_override: boolean;
relay_bind_port: number;
relay_client_access_addr: string;
relay_agent_access_addr: string;
relay_client_proxy_url: string;
relay_auth_token: string;
relay_status: string;
@@ -46,12 +47,13 @@ export interface NodeBootstrapToken {
}
export interface NodeMutationPayload {
node_type: 'edge_node' | 'tunnel_relay';
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
name: string;
ip: string;
ip_manual_override: boolean;
relay_bind_port?: number;
relay_client_access_addr?: string;
relay_agent_access_addr?: string;
relay_client_proxy_url?: string;
auto_update_enabled: boolean;
geo_name: string;
@@ -207,6 +209,25 @@ export interface NodeHealthEvent {
resolved_at?: string | null;
}
export interface RelayProxyStat {
name: string;
type: string;
status: 'online' | 'offline';
client_version: string;
last_start_time: string;
last_close_time: string;
client_addr: string;
}
export interface RelayDashboardSnapshot {
total_proxies: number;
online_proxies: number;
offline_proxies: number;
proxies: RelayProxyStat[];
total_connections: number;
client_counts: number;
}
export interface NodeObservability {
node_id: string;
profile: NodeSystemProfile | null;
@@ -215,4 +236,5 @@ export interface NodeObservability {
health_events: NodeHealthEvent[];
analytics: NodeObservabilityAnalytics;
trends: NodeObservabilityTrends;
relay_dashboard?: RelayDashboardSnapshot | null;
}
@@ -23,7 +23,7 @@ import {
getWAFSiteRuleGroups,
replaceWAFSiteRuleGroups,
} from '@/features/waf/api/waf';
import { getTunnels } from '@/features/tunnels/api/tunnels';
import { getNodes } from '@/features/nodes/api/nodes';
import {
buildDomainRowsFromRoute,
DomainListInput,
@@ -549,9 +549,11 @@ function ReverseProxySection({
onSave: SaveHandler;
}) {
const tunnelsQuery = useQuery({
queryKey: ['tunnels'],
queryFn: getTunnels,
queryKey: ['nodes'],
queryFn: getNodes,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
const form = useForm<ReverseProxyValues>({
resolver: zodResolver(reverseProxySchema),
@@ -689,7 +691,7 @@ function ReverseProxySection({
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{(tunnelsQuery.data ?? []).map((tunnel) => (
{(tunnelClients).map((tunnel) => (
<option key={tunnel.id} value={tunnel.id}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
</option>
@@ -20,7 +20,7 @@ import {
parseOriginUrls,
validateDomains,
} from '@/features/proxy-routes/helpers';
import { getTunnels } from '@/features/tunnels/api/tunnels';
import { getNodes } from '@/features/nodes/api/nodes';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import {
@@ -173,10 +173,11 @@ export function ProxyRouteCreateDrawer({
enabled: open,
});
const tunnelsQuery = useQuery({
queryKey: ['tunnels'],
queryFn: getTunnels,
queryKey: ['nodes'],
queryFn: getNodes,
enabled: open,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
const combinedDomainSuggestions = useMemo(
() => [
@@ -387,7 +388,7 @@ export function ProxyRouteCreateDrawer({
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{(tunnelsQuery.data ?? []).map((tunnel) => (
{(tunnelClients).map((tunnel) => (
<option key={tunnel.id} value={tunnel.id}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
</option>
@@ -1,33 +0,0 @@
import { apiRequest } from '@/lib/api/client';
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
export function getTunnels() {
return apiRequest<TunnelItem[]>('/tunnels/');
}
export function createTunnel(payload: TunnelMutationPayload) {
return apiRequest<TunnelItem>('/tunnels/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateTunnel(id: number, payload: TunnelMutationPayload) {
return apiRequest<TunnelItem>(`/tunnels/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteTunnel(id: number) {
return apiRequest<void>(`/tunnels/${id}/delete`, {
method: 'POST',
});
}
export function rotateTunnelToken(id: number) {
return apiRequest<TunnelItem>(`/tunnels/${id}/rotate-token`, {
method: 'POST',
});
}
@@ -1,103 +0,0 @@
'use client';
import { AppModal } from '@/components/ui/app-modal';
import type { TunnelItem } from '@/features/tunnels/types';
import {
CodeBlock,
PrimaryButton,
} from '@/features/shared/components/resource-primitives';
import { useState, useEffect } from 'react';
import { getServerUrl } from '@/features/nodes/utils';
export function TunnelDeploymentModal({
isOpen,
tunnel,
onClose,
}: {
isOpen: boolean;
tunnel?: TunnelItem | null;
onClose: () => void;
}) {
const [serverUrl, setServerUrl] = useState('');
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
if (!tunnel) {
return null;
}
const normalizedServerUrl = getServerUrl(serverUrl);
const image = 'ghcr.io/rain-kl/openflare-flared:latest';
const dockerRunCmd = [
`docker pull ${image}`,
`docker rm -f openflare-flared 2>/dev/null || true`,
`docker run -d --name openflare-flared --net host --restart unless-stopped \\`,
` -v /var/run/docker.sock:/var/run/docker.sock \\`,
` -v openflare_flared_data:/app/data \\`,
` -e OPENFLARE_SERVER_URL=${normalizedServerUrl} \\`,
` -e OPENFLARE_TUNNEL_TOKEN=${tunnel.tunnel_token} \\`,
` ${image}`,
].join('\n');
const dockerComposeYaml = `version: '3.8'
services:
openflare-flared:
image: ${image}
container_name: openflare-flared
network_mode: "host"
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- openflare_flared_data:/app/data
environment:
- OPENFLARE_SERVER_URL=${normalizedServerUrl}
- OPENFLARE_TUNNEL_TOKEN=${tunnel.tunnel_token}
volumes:
openflare_flared_data:
`;
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="隧道客户端部署"
description="请在需要穿透的内网服务器上部署 Flared 客户端。"
footer={
<div className="flex justify-end gap-3">
<PrimaryButton type="button" onClick={onClose}>
关闭
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
方式一:Docker Run
</p>
<CodeBlock className="whitespace-pre-wrap">{dockerRunCmd}</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
方式二:Docker Compose (docker-compose.yml)
</p>
<CodeBlock className="whitespace-pre-wrap">{dockerComposeYaml}</CodeBlock>
</div>
<div>
<p className="text-sm font-medium text-[var(--foreground-primary)]">
隧道 Token (仅供高级用法)
</p>
<p className="mt-1 break-all text-xs text-[var(--foreground-secondary)]">
{tunnel.tunnel_token}
</p>
</div>
</div>
</AppModal>
);
}
@@ -1,132 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppModal } from '@/components/ui/app-modal';
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const tunnelEditorSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入隧道名称')
.max(128, '隧道名称不能超过 128 个字符'),
remark: z.string().trim().max(255, '备注不能超过 255 个字符').optional().default(''),
});
type TunnelEditorValues = z.infer<typeof tunnelEditorSchema>;
const defaultValues: TunnelEditorValues = {
name: '',
remark: '',
};
function buildFormValues(tunnel?: Partial<TunnelItem> | null): TunnelEditorValues {
if (!tunnel) {
return defaultValues;
}
return {
name: tunnel.name ?? '',
remark: tunnel.remark ?? '',
};
}
export function TunnelEditorModal({
isOpen,
tunnel,
isSubmitting,
title,
description,
submitLabel,
onClose,
onSubmit,
}: {
isOpen: boolean;
tunnel?: Partial<TunnelItem> | null;
isSubmitting: boolean;
title: string;
description: string;
submitLabel: string;
onClose: () => void;
onSubmit: (payload: TunnelMutationPayload) => void;
}) {
const form = useForm<TunnelEditorValues>({
resolver: zodResolver(tunnelEditorSchema),
defaultValues,
});
useEffect(() => {
if (!isOpen) {
return;
}
form.reset(buildFormValues(tunnel));
}, [form, isOpen, tunnel]);
const handleSubmit = form.handleSubmit((values) => {
onSubmit({
name: values.name.trim(),
remark: values.remark.trim(),
});
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={onClose}
disabled={isSubmitting}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="tunnel-editor-form"
disabled={isSubmitting}
>
{isSubmitting ? '保存中...' : submitLabel}
</PrimaryButton>
</div>
}
>
<form id="tunnel-editor-form" className="space-y-5" onSubmit={handleSubmit}>
<ResourceField
label="隧道名称"
hint="示例:home-nas-tunnel"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="home-nas-tunnel"
{...form.register('name')}
/>
</ResourceField>
<ResourceField
label="备注 (可选)"
hint="用于描述该隧道的用途。"
error={form.formState.errors.remark?.message}
>
<ResourceInput
placeholder="家庭内网穿透"
{...form.register('remark')}
/>
</ResourceField>
</form>
</AppModal>
);
}
@@ -1,320 +0,0 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { 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 { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createTunnel,
deleteTunnel,
getTunnels,
updateTunnel,
rotateTunnelToken,
} from '@/features/tunnels/api/tunnels';
import { TunnelEditorModal } from '@/features/tunnels/components/tunnel-editor-modal';
import { TunnelDeploymentModal } from '@/features/tunnels/components/tunnel-deployment-modal';
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
import { PrimaryButton } from '@/features/shared/components/resource-primitives';
import { formatRelativeTime } from '@/lib/utils/date';
import { isMeaningfulTime } from '@/features/nodes/utils';
const tunnelsQueryKey = ['tunnels'];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function TunnelsPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingTunnel, setEditingTunnel] = useState<TunnelItem | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [deploymentTunnel, setDeploymentTunnel] = useState<TunnelItem | null>(null);
const [isDeploymentOpen, setIsDeploymentOpen] = useState(false);
const tunnelsQuery = useQuery({
queryKey: tunnelsQueryKey,
queryFn: getTunnels,
refetchInterval: 5000,
});
const saveMutation = useMutation({
mutationFn: async (payload: TunnelMutationPayload) => {
return editingTunnel
? updateTunnel(editingTunnel.id, payload)
: createTunnel(payload);
},
onSuccess: async () => {
setFeedback({
tone: 'success',
message: editingTunnel ? '隧道已更新。' : '隧道已创建。',
});
setEditingTunnel(null);
setIsEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteTunnel,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '隧道已删除。' });
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const rotateTokenMutation = useMutation({
mutationFn: rotateTunnelToken,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '隧道 Token 已轮转,客户端需要重新部署连接。' });
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const tunnels = useMemo(() => tunnelsQuery.data ?? [], [tunnelsQuery.data]);
const handleResetEditor = () => {
setFeedback(null);
setEditingTunnel(null);
setIsEditorOpen(false);
};
const handleCreate = () => {
setFeedback(null);
setEditingTunnel(null);
setIsEditorOpen(true);
};
const handleEdit = (tunnel: TunnelItem) => {
setFeedback(null);
setEditingTunnel(tunnel);
setIsEditorOpen(true);
};
const handleDeployment = (tunnel: TunnelItem) => {
setFeedback(null);
setDeploymentTunnel(tunnel);
setIsDeploymentOpen(true);
};
const handleDelete = (tunnelId: number, tunnelName: string) => {
if (
!window.confirm(
`确认删除隧道“${tunnelName}”吗?删除后该隧道的连接将立即中断。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate(tunnelId);
};
const handleRotateToken = (tunnelId: number, tunnelName: string) => {
if (
!window.confirm(
`确认重置隧道“${tunnelName}”的 Token 吗?已连接的客户端将被断开,并且需要使用新的 Token 重新部署。`,
)
) {
return;
}
setFeedback(null);
rotateTokenMutation.mutate(tunnelId);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="内网穿透隧道"
description="管理你的内网穿透隧道。"
action={
<PrimaryButton type="button" onClick={handleCreate}>
新建 Tunnel
</PrimaryButton>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard
title="隧道列表"
description="列表每 5 秒自动刷新一次。"
>
{tunnelsQuery.isLoading ? (
<LoadingState />
) : tunnelsQuery.isError ? (
<ErrorState
title="隧道列表加载失败"
description={getErrorMessage(tunnelsQuery.error)}
/>
) : tunnels.length === 0 ? (
<EmptyState
title="当前还没有隧道"
description="点击右上角的“新建 Tunnel”按钮创建你的第一个隧道。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">名称 / 隧道ID</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">版本 / 校验</th>
<th className="px-3 py-3 font-medium">已连接 Relay</th>
<th className="px-3 py-3 font-medium text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{tunnels.map((tunnel) => (
<tr key={tunnel.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-1">
<p className="font-medium text-[var(--foreground-primary)]">
{tunnel.name}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
{tunnel.tunnel_id.slice(0, 8)}...
</p>
{tunnel.remark ? (
<p className="text-xs text-[var(--foreground-muted)] line-clamp-1 max-w-xs">
{tunnel.remark}
</p>
) : null}
</div>
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={tunnel.status === 'online' ? '在线' : '离线'}
variant={tunnel.status === 'online' ? 'success' : 'danger'}
/>
{tunnel.status === 'online' && isMeaningfulTime(tunnel.last_seen_at) ? (
<p className="text-xs text-[var(--foreground-secondary)]">
活跃:{formatRelativeTime(tunnel.last_seen_at)}
</p>
) : null}
</div>
</td>
<td className="px-3 py-4">
<div className="space-y-1 text-[var(--foreground-secondary)]">
<p>
版本:{tunnel.current_version || '未知'}
</p>
<p className="text-xs" title={tunnel.current_checksum}>
{tunnel.current_checksum
? `签名:${tunnel.current_checksum.slice(0, 12)}...`
: '签名:未知'}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{tunnel.connected_relays && tunnel.connected_relays.length > 0 ? (
<div className="space-y-1">
{tunnel.connected_relays.map((relay, index) => (
<p key={index} className="text-xs">
{relay}
</p>
))}
</div>
) : (
<p className="text-xs">暂无</p>
)}
</td>
<td className="px-3 py-4 text-right">
<div className="flex flex-col items-end gap-2">
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={() => handleDeployment(tunnel)}
className="text-sm font-medium text-blue-600 transition hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
部署命令
</button>
<button
type="button"
onClick={() => handleEdit(tunnel)}
className="text-sm font-medium text-[var(--foreground-primary)] transition hover:text-blue-600 dark:hover:text-blue-500"
>
编辑
</button>
<button
type="button"
onClick={() => handleDelete(tunnel.id, tunnel.name)}
className="text-sm font-medium text-red-600 transition hover:text-red-700 dark:text-red-500 dark:hover:text-red-400"
>
删除
</button>
</div>
<button
type="button"
onClick={() => handleRotateToken(tunnel.id, tunnel.name)}
className="text-xs font-medium text-amber-600 transition hover:text-amber-700 dark:text-amber-500 dark:hover:text-amber-400"
>
重置 Token
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<TunnelEditorModal
isOpen={isEditorOpen}
tunnel={editingTunnel}
isSubmitting={saveMutation.isPending}
title={editingTunnel ? '编辑隧道' : '新建隧道'}
description={
editingTunnel
? '修改此隧道的名称和备注。'
: '创建一个内网穿透隧道,稍后你将获得用于客户端连接的 Token 和部署命令。'
}
submitLabel={editingTunnel ? '保存修改' : '确认创建'}
onClose={handleResetEditor}
onSubmit={(payload) => {
setFeedback(null);
saveMutation.mutate(payload);
}}
/>
<TunnelDeploymentModal
isOpen={isDeploymentOpen}
tunnel={deploymentTunnel}
onClose={() => setIsDeploymentOpen(false)}
/>
</>
);
}
@@ -1,22 +0,0 @@
export interface TunnelItem {
id: number;
tunnel_id: string;
name: string;
tunnel_token: string;
status: 'online' | 'offline';
client_version: string;
frp_version: string;
last_seen_at?: string | null;
last_error: string;
current_version: string;
current_checksum: string;
connected_relays: string[];
remark: string;
created_at: string;
updated_at: string;
}
export interface TunnelMutationPayload {
name: string;
remark: string;
}