-
- {node.name}
-
+
+
+ {node.name}
+
+ {node.type === 'tunnel_relay' ? (
+
+ Relay
+
+ ) : (
+
+ Edge
+
+ )}
+
IP:{node.ip || 'null'}
{node.ip_manual_override ? '(已锁定)' : ''}
@@ -328,14 +341,21 @@ export function NodesPage() {
|
-
+ {node.type === 'tunnel_relay' ? (
+
+ ) : (
+
+ )}
|
diff --git a/openflare_server/web/features/nodes/types.ts b/openflare_server/web/features/nodes/types.ts
index 7396f3d4..96c750bd 100644
--- a/openflare_server/web/features/nodes/types.ts
+++ b/openflare_server/web/features/nodes/types.ts
@@ -3,9 +3,15 @@ import type { ReleaseChannel } from '@/features/update/types';
export interface NodeItem {
id: number;
node_id: string;
+ type: 'edge_node' | 'tunnel_relay';
name: string;
ip: string;
ip_manual_override: boolean;
+ relay_bind_port: number;
+ relay_client_access_addr: string;
+ relay_client_proxy_url: string;
+ relay_auth_token: string;
+ relay_status: string;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
@@ -40,9 +46,13 @@ export interface NodeBootstrapToken {
}
export interface NodeMutationPayload {
+ type: 'edge_node' | 'tunnel_relay';
name: string;
ip: string;
ip_manual_override: boolean;
+ relay_bind_port?: number;
+ relay_client_access_addr?: string;
+ relay_client_proxy_url?: string;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;
diff --git a/openflare_server/web/features/nodes/utils.ts b/openflare_server/web/features/nodes/utils.ts
index 6533eb90..b0672969 100644
--- a/openflare_server/web/features/nodes/utils.ts
+++ b/openflare_server/web/features/nodes/utils.ts
@@ -196,3 +196,50 @@ export function buildNodeDockerInstallCommand(
` ${image}`,
].join('\n');
}
+
+export function getRelayStatusVariant(status: string | null | undefined) {
+ if (status === 'healthy') {
+ return 'success';
+ }
+ if (status === 'unhealthy') {
+ return 'danger';
+ }
+ return 'warning';
+}
+
+export function getRelayStatusLabel(status: string | null | undefined) {
+ if (status === 'healthy') {
+ return '健康';
+ }
+ if (status === 'unhealthy') {
+ return '异常';
+ }
+ return '未知';
+}
+
+const relayInstallerScriptUrl =
+ 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh';
+
+export function buildRelayInstallCommand(serverUrl: string, discoveryToken: string) {
+ return [
+ `curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
+ ` --server-url ${serverUrl} \\`,
+ ` --discovery-token ${discoveryToken}`,
+ ].join('\n');
+}
+
+export function buildRelayDockerInstallCommand(
+ serverUrl: string,
+ discoveryToken: string,
+) {
+ const image = 'ghcr.io/rain-kl/openflare-relay:latest';
+
+ return [
+ `docker pull ${image}`,
+ `docker rm -f openflare-relay 2>/dev/null || true`,
+ `docker run -d --name openflare-relay --net host --restart unless-stopped \\`,
+ ` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
+ ` -e OPENFLARE_DISCOVERY_TOKEN=${discoveryToken} \\`,
+ ` ${image}`,
+ ].join('\n');
+}
diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx
index 7c4b3731..72522af3 100644
--- a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx
+++ b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx
@@ -23,6 +23,7 @@ import {
getWAFSiteRuleGroups,
replaceWAFSiteRuleGroups,
} from '@/features/waf/api/waf';
+import { getTunnels } from '@/features/tunnels/api/tunnels';
import {
buildDomainRowsFromRoute,
DomainListInput,
@@ -156,19 +157,48 @@ const rateLimitSchema = z
const reverseProxySchema = z
.object({
- origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
+ upstream_type: z.enum(['direct', 'tunnel']),
+ origin_urls_text: z.string().trim(),
origin_host: z.string(),
+ tunnel_id: z.string().optional(),
+ tunnel_target_addr: z.string().trim().optional(),
+ tunnel_target_protocol: z.enum(['http', 'https']).optional(),
custom_headers_text: z.string(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
- const { error } = parseOriginUrls(value.origin_urls_text);
- if (error) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['origin_urls_text'],
- message: error,
- });
+ if (value.upstream_type === 'direct') {
+ if (!value.origin_urls_text.trim()) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['origin_urls_text'],
+ message: '请至少填写一个上游地址',
+ });
+ } else {
+ const { error } = parseOriginUrls(value.origin_urls_text);
+ if (error) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['origin_urls_text'],
+ message: error,
+ });
+ }
+ }
+ } else {
+ if (!value.tunnel_id) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['tunnel_id'],
+ message: '请选择内网穿透隧道',
+ });
+ }
+ if (!value.tunnel_target_addr) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['tunnel_target_addr'],
+ message: '请填写内网服务地址 (如 127.0.0.1:8080)',
+ });
+ }
}
const originHostError = validateOriginHost(value.origin_host);
@@ -518,11 +548,20 @@ function ReverseProxySection({
saving: boolean;
onSave: SaveHandler;
}) {
+ const tunnelsQuery = useQuery({
+ queryKey: ['tunnels'],
+ queryFn: getTunnels,
+ });
+
const form = useForm({
resolver: zodResolver(reverseProxySchema),
defaultValues: {
+ upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
+ tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
+ tunnel_target_addr: route.tunnel_target_addr || '',
+ tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
},
@@ -530,8 +569,12 @@ function ReverseProxySection({
useEffect(() => {
form.reset({
+ upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
+ tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
+ tunnel_target_addr: route.tunnel_target_addr || '',
+ tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
});
@@ -539,8 +582,8 @@ function ReverseProxySection({
return (
@@ -548,8 +591,28 @@ function ReverseProxySection({
id="proxy-route-proxy-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => {
- const { urls } = parseOriginUrls(values.origin_urls_text);
- const primaryOrigin = parseOriginUrl(urls[0]);
+ let originUrl = '';
+ let originScheme: 'http' | 'https' = 'http';
+ let originAddress = '';
+ let originPort = '';
+ let originUri = '';
+ let upstreams: string[] = [];
+
+ if (values.upstream_type === 'direct') {
+ const { urls } = parseOriginUrls(values.origin_urls_text);
+ const primaryOrigin = parseOriginUrl(urls[0]);
+ originUrl = urls[0];
+ originScheme = primaryOrigin.scheme;
+ originAddress = primaryOrigin.address;
+ originPort = primaryOrigin.port;
+ originUri = primaryOrigin.uri;
+ upstreams = urls.slice(1);
+ } else {
+ originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
+ originScheme = values.tunnel_target_protocol as 'http' | 'https';
+ originAddress = values.tunnel_target_addr || '';
+ }
+
const { headers } = parseCustomHeadersText(
values.custom_headers_text,
);
@@ -557,34 +620,109 @@ function ReverseProxySection({
onSave(
buildPayloadFromRoute(route, {
origin_id: null,
- origin_url: urls[0],
- origin_scheme: primaryOrigin.scheme,
- origin_address: primaryOrigin.address,
- origin_port: primaryOrigin.port,
- origin_uri: primaryOrigin.uri,
+ origin_url: originUrl,
+ origin_scheme: originScheme,
+ origin_address: originAddress,
+ origin_port: originPort,
+ origin_uri: originUri,
origin_host: values.origin_host.trim(),
- upstreams: urls.slice(1),
+ upstreams: upstreams,
custom_headers: headers,
remark: values.remark.trim(),
+ upstream_type: values.upstream_type,
+ tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
+ tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
+ tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
}),
{ message: '反向代理设置已保存。' },
);
})}
>
-
-
-
+
+
+ {form.watch('upstream_type') === 'direct' ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
;
const defaultValues: CreateWebsiteFormValues = {
site_name: '',
domain_rows: [{ domain: '', certificateId: '' }],
+ upstream_type: 'direct',
origin_urls_text: '',
+ tunnel_id: '',
+ tunnel_target_addr: '',
+ tunnel_target_protocol: 'http',
enabled: true,
redirect_http: false,
remark: '',
@@ -138,6 +172,11 @@ export function ProxyRouteCreateDrawer({
queryFn: getTlsCertificates,
enabled: open,
});
+ const tunnelsQuery = useQuery({
+ queryKey: ['tunnels'],
+ queryFn: getTunnels,
+ enabled: open,
+ });
const combinedDomainSuggestions = useMemo(
() => [
@@ -159,29 +198,40 @@ export function ProxyRouteCreateDrawer({
const selectedCertIDs = normalizeSelectedCertificateIDs(
values.domain_rows,
);
- const { urls } = parseOriginUrls(values.origin_urls_text);
- const primaryOrigin = parseOriginUrl(urls[0]);
+
+ let originUrl = '';
+ const originHost = '';
+ let upstreams: string[] = [];
+
+ if (values.upstream_type === 'direct') {
+ const { urls } = parseOriginUrls(values.origin_urls_text);
+ const primaryOrigin = parseOriginUrl(urls[0]);
+ originUrl = buildOriginUrl(
+ primaryOrigin.scheme,
+ primaryOrigin.address,
+ primaryOrigin.port,
+ primaryOrigin.uri,
+ );
+ upstreams = urls.slice(1);
+ } else {
+ originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
+ }
return createProxyRoute({
site_name: values.site_name.trim() || domains[0],
domain: domains[0],
domains,
origin_id: null,
- origin_url: buildOriginUrl(
- primaryOrigin.scheme,
- primaryOrigin.address,
- primaryOrigin.port,
- primaryOrigin.uri,
- ),
- origin_scheme: primaryOrigin.scheme,
- origin_address: primaryOrigin.address,
- origin_port: primaryOrigin.port,
- origin_uri: primaryOrigin.uri,
- origin_host: '',
- upstreams: urls.slice(1),
+ origin_url: originUrl,
+ origin_scheme: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).scheme : 'http',
+ origin_address: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).address : values.tunnel_target_addr || '',
+ origin_port: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).port : '80',
+ origin_uri: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).uri : '',
+ origin_host: originHost,
+ upstreams,
enabled: values.enabled,
enable_https: selectedCertIDs.length > 0,
- cert_id: selectedCertIDs[0] ?? null,
+ cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null,
cert_ids: selectedCertIDs,
domain_cert_ids: domainCertIDs,
redirect_http:
@@ -196,9 +246,11 @@ export function ProxyRouteCreateDrawer({
pow_enabled: false,
pow_config: '{}',
basic_auth_enabled: false,
- basic_auth_username: '',
- basic_auth_password: '',
remark: values.remark.trim(),
+ upstream_type: values.upstream_type,
+ tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
+ tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
+ tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
});
},
onSuccess: (route) => {
@@ -285,19 +337,90 @@ export function ProxyRouteCreateDrawer({
}
/>
-
-
-
+
+
+ {form.watch('upstream_type') === 'direct' ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
('/tunnels/');
+}
+
+export function createTunnel(payload: TunnelMutationPayload) {
+ return apiRequest('/tunnels/', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+}
+
+export function updateTunnel(id: number, payload: TunnelMutationPayload) {
+ return apiRequest(`/tunnels/${id}/update`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+}
+
+export function deleteTunnel(id: number) {
+ return apiRequest(`/tunnels/${id}/delete`, {
+ method: 'POST',
+ });
+}
+
+export function rotateTunnelToken(id: number) {
+ return apiRequest(`/tunnels/${id}/rotate-token`, {
+ method: 'POST',
+ });
+}
diff --git a/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx b/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx
new file mode 100644
index 00000000..72c0fa8a
--- /dev/null
+++ b/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx
@@ -0,0 +1,103 @@
+'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 (
+
+
+ 关闭
+
+
+ }
+ >
+
+
+
+ 方式一:Docker Run
+
+ {dockerRunCmd}
+
+
+
+ 方式二:Docker Compose (docker-compose.yml)
+
+ {dockerComposeYaml}
+
+
+
+ 隧道 Token (仅供高级用法)
+
+
+ {tunnel.tunnel_token}
+
+
+
+
+ );
+}
diff --git a/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx b/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx
new file mode 100644
index 00000000..802bf3a5
--- /dev/null
+++ b/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx
@@ -0,0 +1,132 @@
+'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;
+
+const defaultValues: TunnelEditorValues = {
+ name: '',
+ remark: '',
+};
+
+function buildFormValues(tunnel?: Partial | 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 | null;
+ isSubmitting: boolean;
+ title: string;
+ description: string;
+ submitLabel: string;
+ onClose: () => void;
+ onSubmit: (payload: TunnelMutationPayload) => void;
+}) {
+ const form = useForm({
+ 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 (
+
+
+ 取消
+
+
+ {isSubmitting ? '保存中...' : submitLabel}
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/openflare_server/web/features/tunnels/components/tunnels-page.tsx b/openflare_server/web/features/tunnels/components/tunnels-page.tsx
new file mode 100644
index 00000000..e7e453b0
--- /dev/null
+++ b/openflare_server/web/features/tunnels/components/tunnels-page.tsx
@@ -0,0 +1,317 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMemo, useState } from 'react';
+import Link from 'next/link';
+
+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(null);
+
+ const [editingTunnel, setEditingTunnel] = useState(null);
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
+
+ const [deploymentTunnel, setDeploymentTunnel] = useState(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 (
+ <>
+
+
+ 新建 Tunnel
+
+ }
+ />
+
+ {feedback ? (
+
+ ) : null}
+
+
+ {tunnelsQuery.isLoading ? (
+
+ ) : tunnelsQuery.isError ? (
+
+ ) : tunnels.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 名称 / 隧道ID |
+ 状态 |
+ 版本 / 校验 |
+ 已连接 Relay |
+ 操作 |
+
+
+
+ {tunnels.map((tunnel) => (
+
+
+
+
+ {tunnel.name}
+
+
+ {tunnel.tunnel_id.slice(0, 8)}...
+
+ {tunnel.remark ? (
+
+ {tunnel.remark}
+
+ ) : null}
+
+ |
+
+
+
+ {tunnel.status === 'online' && isMeaningfulTime(tunnel.last_seen_at) ? (
+
+ 活跃:{formatRelativeTime(tunnel.last_seen_at)}
+
+ ) : null}
+
+ |
+
+
+
+ 版本:{tunnel.current_version || '未知'}
+
+
+ {tunnel.current_checksum
+ ? `签名:${tunnel.current_checksum.slice(0, 12)}...`
+ : '签名:未知'}
+
+
+ |
+
+ {tunnel.connected_relays && tunnel.connected_relays.length > 0 ? (
+
+ {tunnel.connected_relays.map((relay, index) => (
+
+ {relay}
+
+ ))}
+
+ ) : (
+ 暂无
+ )}
+ |
+
+
+
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ {
+ setFeedback(null);
+ saveMutation.mutate(payload);
+ }}
+ />
+
+ setIsDeploymentOpen(false)}
+ />
+ >
+ );
+}
diff --git a/openflare_server/web/features/tunnels/types.ts b/openflare_server/web/features/tunnels/types.ts
new file mode 100644
index 00000000..6c265065
--- /dev/null
+++ b/openflare_server/web/features/tunnels/types.ts
@@ -0,0 +1,22 @@
+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;
+}
|