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 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-28 14:49:56 +01:00
parent 447dbcedde
commit e26d7a2c3f
3 changed files with 327 additions and 47 deletions

View File

@@ -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<LocationRule[]>(initialData);
const [rules, setRules] = useState<RuleState[]>(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 (
<div>
<p className="text-sm font-semibold mb-2">Location Rules</p>
<input type="hidden" name="location_rules_json" value={JSON.stringify(rules)} />
<input type="hidden" name="location_rules_json" value={toJson(rules)} />
{rules.length > 0 && (
<div className="mb-2 flex flex-col gap-3">
<div className="mb-2 flex flex-col gap-4">
{rules.map((rule, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_40px] gap-2 items-start">
<div>
{i === 0 && (
<div key={i} className="rounded-md border p-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-xs font-medium text-muted-foreground px-1 mb-1 block">Path Pattern</span>
)}
<Input
size={1}
placeholder="/ws/*"
value={rule.path}
onChange={(e) => updatePath(i, e.target.value)}
className="h-8 text-sm"
/>
<Input
size={1}
placeholder="/ws/*"
value={rule.path}
onChange={(e) => updatePath(i, e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="self-end">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => removeRule(i)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div>
{i === 0 && (
<span className="text-xs font-medium text-muted-foreground px-1 mb-1 block">Upstreams</span>
)}
<Textarea
placeholder={"ws-backend:8080\nws-backend2:8080"}
value={rule.upstreams.join("\n")}
onChange={(e) => updateUpstreams(i, e.target.value)}
className="text-sm min-h-[32px]"
rows={Math.max(1, rule.upstreams.length)}
/>
</div>
<div className={i === 0 ? "mt-5" : ""}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeRule(i)}
>
<Trash2 className="h-4 w-4" />
</Button>
<span className="text-xs font-medium text-muted-foreground px-1 mb-1 block">Upstreams</span>
<div className="flex flex-col gap-2">
{rule.upstreams.map((up, j) => (
<div key={j} className="flex items-center gap-2">
<Select value={up.protocol} onValueChange={(val) => updateUpstreamProtocol(i, j, val)}>
<SelectTrigger className="w-28 h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http://">http://</SelectItem>
<SelectItem value="https://">https://</SelectItem>
</SelectContent>
</Select>
<Input
value={up.address}
onChange={(e) => updateUpstreamAddress(i, j, e.target.value)}
placeholder="10.0.0.5:8080"
className="flex-1 h-8 text-sm"
/>
<span title={rule.upstreams.length === 1 ? "At least one upstream required" : "Remove upstream"}>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => removeUpstream(i, j)}
disabled={rule.upstreams.length === 1}
>
<MinusCircle className="h-4 w-4" />
</Button>
</span>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => addUpstream(i)} className="self-start">
<Plus className="h-4 w-4 mr-1" />
Add Upstream
</Button>
</div>
</div>
</div>
))}