From e26d7a2c3f148df4b2b042ff87b7e717661024a3 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:49:56 +0100 Subject: [PATCH] feat: improve LocationRulesFields UI and add unit tests for buildLocationReverseProxy - Replace textarea with per-upstream rows (protocol dropdown + address input), matching the existing UpstreamInput component pattern - Export buildLocationReverseProxy for testing - Add 14 unit tests covering: dial formatting, HTTPS/TLS transport, host header preservation, path sanitization, IPv6, mixed upstreams Co-Authored-By: Claude Sonnet 4.6 --- .../proxy-hosts/LocationRulesFields.tsx | 182 ++++++++++++----- src/lib/caddy.ts | 8 +- tests/unit/caddy-location-rules.test.ts | 184 ++++++++++++++++++ 3 files changed, 327 insertions(+), 47 deletions(-) create mode 100644 tests/unit/caddy-location-rules.test.ts diff --git a/src/components/proxy-hosts/LocationRulesFields.tsx b/src/components/proxy-hosts/LocationRulesFields.tsx index f03ab62a..c7db0454 100644 --- a/src/components/proxy-hosts/LocationRulesFields.tsx +++ b/src/components/proxy-hosts/LocationRulesFields.tsx @@ -2,17 +2,52 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Trash2, Plus } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Trash2, Plus, MinusCircle } from "lucide-react"; import type { LocationRule } from "@/lib/models/proxy-hosts"; +type UpstreamEntry = { protocol: string; address: string }; + +function parseUpstream(upstream: string): UpstreamEntry { + if (upstream.startsWith("https://")) return { protocol: "https://", address: upstream.slice(8) }; + if (upstream.startsWith("http://")) return { protocol: "http://", address: upstream.slice(7) }; + return { protocol: "http://", address: upstream }; +} + +function serializeUpstream(entry: UpstreamEntry): string { + return `${entry.protocol}${entry.address.trim()}`; +} + +type RuleState = { path: string; upstreams: UpstreamEntry[] }; + +function toState(rules: LocationRule[]): RuleState[] { + return rules.map((r) => ({ + path: r.path, + upstreams: r.upstreams.length > 0 ? r.upstreams.map(parseUpstream) : [{ protocol: "http://", address: "" }], + })); +} + +function toJson(rules: RuleState[]): string { + return JSON.stringify( + rules + .filter((r) => r.path.trim()) + .map((r) => ({ + path: r.path.trim(), + upstreams: r.upstreams + .filter((u) => u.address.trim()) + .map(serializeUpstream), + })) + .filter((r) => r.upstreams.length > 0) + ); +} + type Props = { initialData?: LocationRule[] }; export function LocationRulesFields({ initialData = [] }: Props) { - const [rules, setRules] = useState(initialData); + const [rules, setRules] = useState(toState(initialData)); const addRule = () => - setRules((r) => [...r, { path: "", upstreams: [] }]); + setRules((r) => [...r, { path: "", upstreams: [{ protocol: "http://", address: "" }] }]); const removeRule = (i: number) => setRules((r) => r.filter((_, idx) => idx !== i)); @@ -20,63 +55,122 @@ export function LocationRulesFields({ initialData = [] }: Props) { const updatePath = (i: number, value: string) => setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, path: value } : rule))); - const updateUpstreams = (i: number, value: string) => + const addUpstream = (ruleIdx: number) => setRules((r) => r.map((rule, idx) => - idx === i + idx === ruleIdx + ? { ...rule, upstreams: [...rule.upstreams, { protocol: "http://", address: "" }] } + : rule + ) + ); + + const removeUpstream = (ruleIdx: number, upIdx: number) => + setRules((r) => + r.map((rule, idx) => + idx === ruleIdx && rule.upstreams.length > 1 + ? { ...rule, upstreams: rule.upstreams.filter((_, i) => i !== upIdx) } + : rule + ) + ); + + const updateUpstreamProtocol = (ruleIdx: number, upIdx: number, protocol: string) => + setRules((r) => + r.map((rule, idx) => + idx === ruleIdx ? { ...rule, - upstreams: value - .split("\n") - .map((u) => u.trim()) - .filter(Boolean), + upstreams: rule.upstreams.map((u, i) => (i === upIdx ? { ...u, protocol } : u)), } : rule ) ); + const updateUpstreamAddress = (ruleIdx: number, upIdx: number, address: string) => + setRules((r) => + r.map((rule, idx) => { + if (idx !== ruleIdx) return rule; + return { + ...rule, + upstreams: rule.upstreams.map((u, i) => { + if (i !== upIdx) return u; + if (address.startsWith("https://")) return { protocol: "https://", address: address.slice(8) }; + if (address.startsWith("http://")) return { protocol: "http://", address: address.slice(7) }; + return { ...u, address }; + }), + }; + }) + ); + return (

Location Rules

- + {rules.length > 0 && ( -
+
{rules.map((rule, i) => ( -
-
- {i === 0 && ( +
+
+
Path Pattern - )} - updatePath(i, e.target.value)} - className="h-8 text-sm" - /> + updatePath(i, e.target.value)} + className="h-8 text-sm" + /> +
+
+ +
- {i === 0 && ( - Upstreams - )} -