Files
OpenFlare/frontend/app/(main)/proxy-routes/detail/components/proxy-section.tsx
T
ryan d615d85a26 refactor: remove unused remarks and add quick domain create
Drop remark fields from Zone, Zone domains, proxy routes, WAF rule
groups and IP groups across models, APIs, UI and DB columns (keep
certificate/origin remarks). Add quick-create domain input for short
labels, @ apex and full FQDNs when binding domains.
2026-07-12 16:16:56 +08:00

421 lines
16 KiB
TypeScript

'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useQuery} from '@tanstack/react-query';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import type {ProxyRouteItem} from '@/lib/services/openflare';
import {NodeService, PagesService} from '@/lib/services/openflare';
import {
customHeadersToText,
parseCustomHeadersText,
parseOriginUrl,
parseOriginUrls,
validateOriginHost,
} from '../../components/helpers';
import {proxyRouteFormIds} from '../helpers';
import {useRouteSectionSave} from '../hooks/use-route-section-save';
import {SectionShell} from './section-shell';
const reverseProxySchema = z
.object({
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
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(),
pages_project_id: z.string().optional(),
custom_headers_text: z.string(),
})
.superRefine((value, context) => {
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.upstream_type === 'tunnel') {
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)',
});
}
} else if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
const originHostError = validateOriginHost(value.origin_host);
if (originHostError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_host'],
message: originHostError,
});
}
const { error: headerError } = parseCustomHeadersText(value.custom_headers_text);
if (headerError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers_text'],
message: headerError,
});
}
});
type ReverseProxyValues = z.infer<typeof reverseProxySchema>;
interface ProxySectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
onSavingChange?: (saving: boolean) => void;
}
export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySectionProps) {
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
const tunnelsQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const pagesProjectsQuery = useQuery({
queryKey: ['openflare', 'pages-projects'],
queryFn: () => PagesService.listProjects(),
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const form = useForm<ReverseProxyValues>({
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_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
},
});
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_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol:
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
});
}, [form, route]);
const upstreamType = form.watch('upstream_type');
return (
<SectionShell
title="反向代理"
description="配置请求回源上游的策略与地址。"
formId={proxyRouteFormIds.proxy}
saving={saving}
>
<Form {...form}>
<form
id={proxyRouteFormIds.proxy}
className="space-y-5"
onSubmit={form.handleSubmit(async (values) => {
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 if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
originScheme = values.tunnel_target_protocol as 'http' | 'https';
originAddress = values.tunnel_target_addr || '';
} else {
originUrl = 'http://127.0.0.1';
originScheme = 'http';
originAddress = '127.0.0.1';
originPort = '80';
}
const { headers } = parseCustomHeadersText(values.custom_headers_text);
await save(
{
origin_id: null,
origin_url: originUrl,
origin_scheme: originScheme,
origin_address: originAddress,
origin_port: originPort,
origin_uri: originUri,
origin_host: values.origin_host.trim(),
upstreams,
custom_headers: headers,
upstream_type: values.upstream_type,
tunnel_node_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 : '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
},
'反向代理设置已保存',
);
})}
>
<FormField
control={form.control}
name="upstream_type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>回源方式</FormLabel>
<div className="flex flex-wrap gap-4">
{(
[
['direct', '直连上游'],
['tunnel', '内网穿透 (Tunnel)'],
['pages', 'Pages 静态站点'],
] as const
).map(([value, label]) => (
<label key={value} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="radio"
value={value}
checked={field.value === value}
onChange={() => field.onChange(value)}
className="size-4 accent-primary"
/>
<Label className="font-normal">{label}</Label>
</label>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{upstreamType === 'direct' ? (
<FormField
control={form.control}
name="origin_urls_text"
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormControl>
<Textarea
className="min-h-40 font-mono text-xs"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...field}
/>
</FormControl>
<FormDescription>
每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{upstreamType === 'tunnel' ? (
<div className="space-y-4 rounded-lg border border-dashed bg-muted/30 p-4">
<FormField
control={form.control}
name="tunnel_id"
render={({ field }) => (
<FormItem>
<FormLabel>选择内网穿透隧道</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
{tunnelClients.map((tunnel) => (
<SelectItem key={tunnel.id} value={String(tunnel.id)}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>将请求转发到该隧道连接的客户端节点。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tunnel_target_protocol"
render={({ field }) => (
<FormItem>
<FormLabel>内网服务协议</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tunnel_target_addr"
render={({ field }) => (
<FormItem>
<FormLabel>内网服务地址</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1:8080" {...field} />
</FormControl>
<FormDescription>例如: 127.0.0.1:8080 或 192.168.1.10:80</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{upstreamType === 'pages' ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-4">
<FormField
control={form.control}
name="pages_project_id"
render={({ field }) => (
<FormItem>
<FormLabel>选择 Pages 项目</FormLabel>
<Select value={field.value || 'none'} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择..." />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">请选择...</SelectItem>
{pagesProjects.map((project) => (
<SelectItem key={project.id} value={String(project.id)}>
{project.name} ({project.slug})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>仅显示已启用且已有激活部署的 Pages 项目。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
<FormField
control={form.control}
name="origin_host"
render={({ field }) => (
<FormItem>
<FormLabel>Origin Host Header</FormLabel>
<FormControl>
<Input placeholder="origin.example.internal" {...field} />
</FormControl>
<FormDescription>留空时默认透传访问域名 $host。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_headers_text"
render={({ field }) => (
<FormItem>
<FormLabel>自定义请求头</FormLabel>
<FormControl>
<Textarea
className="min-h-32 font-mono text-xs"
placeholder={'X-Trace-Id: $request_id\nX-Site: marketing'}
{...field}
/>
</FormControl>
<FormDescription>每行一条,格式为 Key: Value。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
);
}