feat: rewrite proxy-host feature components with shadcn
Replace all MUI imports (Stack, Box, Typography, TextField, Switch, Checkbox, Collapse, Accordion, Chip, etc.) with shadcn/ui + Tailwind equivalents across all 13 proxy host component files. Lucide icons replace MUI icons throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
|
||||
import { Box, Checkbox, Collapse, FormControlLabel, Stack, Switch, TextField, Typography } from "@mui/material";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { AuthentikSettings } from "@/src/lib/settings";
|
||||
import { ProxyHost } from "@/src/lib/models/proxy-hosts";
|
||||
import { AuthentikSettings } from "@/lib/settings";
|
||||
import { ProxyHost } from "@/lib/models/proxy-hosts";
|
||||
|
||||
const AUTHENTIK_DEFAULT_HEADERS = [
|
||||
"X-Authentik-Username",
|
||||
@@ -35,26 +38,28 @@ function HiddenCheckboxField({
|
||||
helperText?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<div>
|
||||
<input type="hidden" name={`${name}_present`} value="1" />
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name={name}
|
||||
defaultChecked={defaultChecked}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">{label}</Typography>}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className={cn("flex items-start gap-2", disabled && "opacity-50")}>
|
||||
<Checkbox
|
||||
id={`checkbox-${name}`}
|
||||
name={name}
|
||||
defaultChecked={defaultChecked}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`checkbox-${name}`}
|
||||
className={cn("text-sm cursor-pointer", disabled && "cursor-not-allowed")}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
{helperText && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", ml: 4, mt: -0.5 }}>
|
||||
<p className="text-xs text-muted-foreground ml-6 -mt-0.5">
|
||||
{helperText}
|
||||
</Typography>
|
||||
</p>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,90 +82,86 @@ export function AuthentikFields({
|
||||
const setHostHeaderDefault = initial?.setOutpostHostHeader ?? true;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
bgcolor: "rgba(99, 102, 241, 0.05)",
|
||||
p: 2.5
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-primary bg-primary/5 p-5">
|
||||
<input type="hidden" name="authentik_present" value="1" />
|
||||
<input type="hidden" name="authentik_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Authentik Forward Auth
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Proxy authentication via Authentik outpost
|
||||
</Typography>
|
||||
</Box>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Authentik Forward Auth</p>
|
||||
<p className="text-sm text-muted-foreground">Proxy authentication via Authentik outpost</p>
|
||||
</div>
|
||||
<Switch
|
||||
name="authentik_enabled"
|
||||
checked={enabled}
|
||||
onChange={(_, checked) => setEnabled(checked)}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Collapse in={enabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="authentik_outpost_domain"
|
||||
label="Outpost Domain"
|
||||
placeholder="outpost.goauthentik.io"
|
||||
defaultValue={initial?.outpostDomain ?? defaults?.outpostDomain ?? ""}
|
||||
required={enabled}
|
||||
disabled={!enabled}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
name="authentik_outpost_upstream"
|
||||
label="Outpost Upstream URL"
|
||||
placeholder="https://outpost.internal:9000"
|
||||
defaultValue={initial?.outpostUpstream ?? defaults?.outpostUpstream ?? ""}
|
||||
required={enabled}
|
||||
disabled={!enabled}
|
||||
fullWidth
|
||||
/>
|
||||
{/* ... other fields ... */}
|
||||
<TextField
|
||||
name="authentik_auth_endpoint"
|
||||
label="Auth Endpoint (Optional)"
|
||||
placeholder="/outpost.goauthentik.io/auth/caddy"
|
||||
defaultValue={initial?.authEndpoint ?? defaults?.authEndpoint ?? ""}
|
||||
disabled={!enabled}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
name="authentik_copy_headers"
|
||||
label="Headers to Copy"
|
||||
defaultValue={copyHeadersValue}
|
||||
disabled={!enabled}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
name="authentik_trusted_proxies"
|
||||
label="Trusted Proxies"
|
||||
defaultValue={trustedProxiesValue}
|
||||
disabled={!enabled}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
name="authentik_protected_paths"
|
||||
label="Protected Paths (Optional)"
|
||||
placeholder="/secret/*, /admin/*"
|
||||
helperText="Leave empty to protect entire domain. Specify paths to protect specific routes only."
|
||||
defaultValue={initial?.protectedPaths?.join(", ") ?? ""}
|
||||
disabled={!enabled}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
enabled ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Outpost Domain</label>
|
||||
<Input
|
||||
name="authentik_outpost_domain"
|
||||
placeholder="outpost.goauthentik.io"
|
||||
defaultValue={initial?.outpostDomain ?? defaults?.outpostDomain ?? ""}
|
||||
required={enabled}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Outpost Upstream URL</label>
|
||||
<Input
|
||||
name="authentik_outpost_upstream"
|
||||
placeholder="https://outpost.internal:9000"
|
||||
defaultValue={initial?.outpostUpstream ?? defaults?.outpostUpstream ?? ""}
|
||||
required={enabled}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Auth Endpoint (Optional)</label>
|
||||
<Input
|
||||
name="authentik_auth_endpoint"
|
||||
placeholder="/outpost.goauthentik.io/auth/caddy"
|
||||
defaultValue={initial?.authEndpoint ?? defaults?.authEndpoint ?? ""}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Headers to Copy</label>
|
||||
<Textarea
|
||||
name="authentik_copy_headers"
|
||||
defaultValue={copyHeadersValue}
|
||||
disabled={!enabled}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Trusted Proxies</label>
|
||||
<Input
|
||||
name="authentik_trusted_proxies"
|
||||
defaultValue={trustedProxiesValue}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Protected Paths (Optional)</label>
|
||||
<Textarea
|
||||
name="authentik_protected_paths"
|
||||
placeholder="/secret/*, /admin/*"
|
||||
defaultValue={initial?.protectedPaths?.join(", ") ?? ""}
|
||||
disabled={!enabled}
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Leave empty to protect entire domain. Specify paths to protect specific routes only.
|
||||
</p>
|
||||
</div>
|
||||
<HiddenCheckboxField
|
||||
name="authentik_set_host_header"
|
||||
defaultChecked={setHostHeaderDefault}
|
||||
@@ -168,9 +169,9 @@ export function AuthentikFields({
|
||||
disabled={!enabled}
|
||||
helperText="Recommended: Keep enabled. Only disable if using IP-based outpost access or troubleshooting routing issues."
|
||||
/>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Box, Collapse, Stack, Switch, TextField, Typography, Alert } from "@mui/material";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { ProxyHost } from "@/src/lib/models/proxy-hosts";
|
||||
import { ProxyHost } from "@/lib/models/proxy-hosts";
|
||||
|
||||
export function DnsResolverFields({
|
||||
dnsResolver
|
||||
@@ -11,75 +15,71 @@ export function DnsResolverFields({
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "warning.main",
|
||||
bgcolor: "rgba(237, 108, 2, 0.05)",
|
||||
p: 2.5
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-yellow-500/60 bg-yellow-500/5 p-5">
|
||||
<input type="hidden" name="dns_present" value="1" />
|
||||
<input type="hidden" name="dns_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Custom DNS Resolvers
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Custom DNS Resolvers</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure per-host DNS resolution for upstream discovery and health checks
|
||||
</Typography>
|
||||
</Box>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
name="dns_enabled"
|
||||
checked={enabled}
|
||||
onChange={(_, checked) => setEnabled(checked)}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Collapse in={enabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2.5}>
|
||||
<TextField
|
||||
name="dns_resolvers"
|
||||
label="DNS Resolvers"
|
||||
placeholder={"1.1.1.1\n8.8.8.8"}
|
||||
defaultValue={initial?.resolvers?.join("\n") ?? ""}
|
||||
helperText="One resolver per line (e.g., 1.1.1.1, 8.8.8.8). Used for dynamic upstream DNS resolution."
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="dns_fallbacks"
|
||||
label="Fallback DNS Resolvers (Optional)"
|
||||
placeholder={"8.8.4.4\n1.0.0.1"}
|
||||
defaultValue={initial?.fallbacks?.join("\n") ?? ""}
|
||||
helperText="Fallback resolvers if primary fails. One per line."
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="dns_timeout"
|
||||
label="DNS Query Timeout"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.timeout ?? ""}
|
||||
helperText="Timeout for DNS queries (e.g., 5s, 10s)"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<Alert severity="info">
|
||||
Per-host DNS resolvers override global settings for this specific proxy host.
|
||||
Useful for upstream services that require specific DNS resolution (e.g., internal DNS, service discovery).
|
||||
Common resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
enabled ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">DNS Resolvers</label>
|
||||
<Textarea
|
||||
name="dns_resolvers"
|
||||
placeholder={"1.1.1.1\n8.8.8.8"}
|
||||
defaultValue={initial?.resolvers?.join("\n") ?? ""}
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
One resolver per line (e.g., 1.1.1.1, 8.8.8.8). Used for dynamic upstream DNS resolution.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Fallback DNS Resolvers (Optional)</label>
|
||||
<Textarea
|
||||
name="dns_fallbacks"
|
||||
placeholder={"8.8.4.4\n1.0.0.1"}
|
||||
defaultValue={initial?.fallbacks?.join("\n") ?? ""}
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Fallback resolvers if primary fails. One per line.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">DNS Query Timeout</label>
|
||||
<Input
|
||||
name="dns_timeout"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.timeout ?? ""}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Timeout for DNS queries (e.g., 5s, 10s)</p>
|
||||
</div>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Per-host DNS resolvers override global settings for this specific proxy host.
|
||||
Useful for upstream services that require specific DNS resolution (e.g., internal DNS, service discovery).
|
||||
Common resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
|
||||
import { Alert, Box, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useFormState } from "react-dom";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
@@ -7,12 +9,12 @@ import {
|
||||
deleteProxyHostAction,
|
||||
updateProxyHostAction
|
||||
} from "@/app/(dashboard)/proxy-hosts/actions";
|
||||
import { INITIAL_ACTION_STATE } from "@/src/lib/actions";
|
||||
import { AccessList } from "@/src/lib/models/access-lists";
|
||||
import { Certificate } from "@/src/lib/models/certificates";
|
||||
import { ProxyHost } from "@/src/lib/models/proxy-hosts";
|
||||
import { AuthentikSettings } from "@/src/lib/settings";
|
||||
import { AppDialog } from "@/src/components/ui/AppDialog";
|
||||
import { INITIAL_ACTION_STATE } from "@/lib/actions";
|
||||
import { AccessList } from "@/lib/models/access-lists";
|
||||
import { Certificate } from "@/lib/models/certificates";
|
||||
import { ProxyHost } from "@/lib/models/proxy-hosts";
|
||||
import { AuthentikSettings } from "@/lib/settings";
|
||||
import { AppDialog } from "@/components/ui/AppDialog";
|
||||
import { AuthentikFields } from "./AuthentikFields";
|
||||
import { DnsResolverFields } from "./DnsResolverFields";
|
||||
import { LoadBalancerFields } from "./LoadBalancerFields";
|
||||
@@ -24,7 +26,7 @@ import { WafFields } from "./WafFields";
|
||||
import { MtlsFields } from "./MtlsConfig";
|
||||
import { RedirectsFields } from "./RedirectsFields";
|
||||
import { RewriteFields } from "./RewriteFields";
|
||||
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
|
||||
import type { CaCertificate } from "@/lib/models/ca-certificates";
|
||||
|
||||
export function CreateHostDialog({
|
||||
open,
|
||||
@@ -59,14 +61,13 @@ export function CreateHostDialog({
|
||||
maxWidth="md"
|
||||
submitLabel="Create"
|
||||
onSubmit={() => {
|
||||
// Trigger generic form submit
|
||||
(document.getElementById("create-host-form") as HTMLFormElement)?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
<Stack component="form" id="create-host-form" action={formAction} spacing={2.5}>
|
||||
<form id="create-host-form" action={formAction} className="flex flex-col gap-5">
|
||||
{state.status !== "idle" && state.message && (
|
||||
<Alert severity={state.status === "error" ? "error" : "success"}>
|
||||
{state.message}
|
||||
<Alert variant={state.status === "error" ? "destructive" : "default"}>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SettingsToggles
|
||||
@@ -74,64 +75,85 @@ export function CreateHostDialog({
|
||||
skipHttpsValidation={initialData?.skip_https_hostname_validation}
|
||||
enabled={true}
|
||||
/>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="My Service"
|
||||
defaultValue={initialData ? `${initialData.name} (Copy)` : ""}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
name="domains"
|
||||
label="Domains"
|
||||
placeholder="app.example.com"
|
||||
defaultValue={initialData?.domains.join("\n") ?? ""}
|
||||
helperText="One per line or comma-separated. Wildcards like *.example.com are supported."
|
||||
multiline
|
||||
minRows={2}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Name</label>
|
||||
<Input
|
||||
name="name"
|
||||
placeholder="My Service"
|
||||
defaultValue={initialData ? `${initialData.name} (Copy)` : ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Domains</label>
|
||||
<Textarea
|
||||
name="domains"
|
||||
placeholder="app.example.com"
|
||||
defaultValue={initialData?.domains.join("\n") ?? ""}
|
||||
required
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
One per line or comma-separated. Wildcards like *.example.com are supported.
|
||||
</p>
|
||||
</div>
|
||||
<UpstreamInput defaultUpstreams={initialData?.upstreams} />
|
||||
<TextField select name="certificate_id" label="Certificate" defaultValue={initialData?.certificate_id ?? ""} fullWidth>
|
||||
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
|
||||
{certificates.map((cert) => (
|
||||
<MenuItem key={cert.id} value={cert.id}>
|
||||
{cert.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField select name="access_list_id" label="Access List" defaultValue={initialData?.access_list_id ?? ""} fullWidth>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{accessLists.map((list) => (
|
||||
<MenuItem key={list.id} value={list.id}>
|
||||
{list.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Certificate</label>
|
||||
<Select name="certificate_id" defaultValue={String(initialData?.certificate_id ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Managed by Caddy (Auto)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Managed by Caddy (Auto)</SelectItem>
|
||||
{certificates.map((cert) => (
|
||||
<SelectItem key={cert.id} value={String(cert.id)}>
|
||||
{cert.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Access List</label>
|
||||
<Select name="access_list_id" defaultValue={String(initialData?.access_list_id ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{accessLists.map((list) => (
|
||||
<SelectItem key={list.id} value={String(list.id)}>
|
||||
{list.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<RedirectsFields initialData={initialData?.redirects} />
|
||||
<RewriteFields initialData={initialData?.rewrite} />
|
||||
<TextField
|
||||
name="custom_pre_handlers_json"
|
||||
label="Custom Pre-Handlers (JSON)"
|
||||
placeholder='[{"handler": "headers", ...}]'
|
||||
defaultValue={initialData?.custom_pre_handlers_json ?? ""}
|
||||
helperText="Optional JSON array of Caddy handlers"
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
name="custom_reverse_proxy_json"
|
||||
label="Custom Reverse Proxy (JSON)"
|
||||
placeholder='{"headers": {"request": {...}}}'
|
||||
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
|
||||
helperText="Deep-merge into reverse_proxy handler (only applies in proxy mode)"
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
|
||||
<Textarea
|
||||
name="custom_pre_handlers_json"
|
||||
placeholder='[{"handler": "headers", ...}]'
|
||||
defaultValue={initialData?.custom_pre_handlers_json ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Custom Reverse Proxy (JSON)</label>
|
||||
<Textarea
|
||||
name="custom_reverse_proxy_json"
|
||||
placeholder='{"headers": {"request": {...}}}'
|
||||
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Deep-merge into reverse_proxy handler (only applies in proxy mode)
|
||||
</p>
|
||||
</div>
|
||||
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
|
||||
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
|
||||
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
|
||||
@@ -139,7 +161,7 @@ export function CreateHostDialog({
|
||||
<GeoBlockFields />
|
||||
<WafFields value={initialData?.waf} />
|
||||
<MtlsFields value={initialData?.mtls} caCertificates={caCertificates} />
|
||||
</Stack>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
@@ -178,10 +200,10 @@ export function EditHostDialog({
|
||||
(document.getElementById("edit-host-form") as HTMLFormElement)?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
<Stack component="form" id="edit-host-form" action={formAction} spacing={2.5}>
|
||||
<form id="edit-host-form" action={formAction} className="flex flex-col gap-5">
|
||||
{state.status !== "idle" && state.message && (
|
||||
<Alert severity={state.status === "error" ? "error" : "success"}>
|
||||
{state.message}
|
||||
<Alert variant={state.status === "error" ? "destructive" : "default"}>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SettingsToggles
|
||||
@@ -189,66 +211,89 @@ export function EditHostDialog({
|
||||
skipHttpsValidation={host.skip_https_hostname_validation}
|
||||
enabled={host.enabled}
|
||||
/>
|
||||
<TextField name="name" label="Name" defaultValue={host.name} required fullWidth />
|
||||
<TextField
|
||||
name="domains"
|
||||
label="Domains"
|
||||
defaultValue={host.domains.join("\n")}
|
||||
helperText="One per line or comma-separated. Wildcards like *.example.com are supported."
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Name</label>
|
||||
<Input name="name" defaultValue={host.name} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Domains</label>
|
||||
<Textarea
|
||||
name="domains"
|
||||
defaultValue={host.domains.join("\n")}
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
One per line or comma-separated. Wildcards like *.example.com are supported.
|
||||
</p>
|
||||
</div>
|
||||
<UpstreamInput defaultUpstreams={host.upstreams} />
|
||||
<TextField select name="certificate_id" label="Certificate" defaultValue={host.certificate_id ?? ""} fullWidth>
|
||||
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
|
||||
{certificates.map((cert) => (
|
||||
<MenuItem key={cert.id} value={cert.id}>
|
||||
{cert.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField select name="access_list_id" label="Access List" defaultValue={host.access_list_id ?? ""} fullWidth>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{accessLists.map((list) => (
|
||||
<MenuItem key={list.id} value={list.id}>
|
||||
{list.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Certificate</label>
|
||||
<Select name="certificate_id" defaultValue={String(host.certificate_id ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Managed by Caddy (Auto)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Managed by Caddy (Auto)</SelectItem>
|
||||
{certificates.map((cert) => (
|
||||
<SelectItem key={cert.id} value={String(cert.id)}>
|
||||
{cert.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Access List</label>
|
||||
<Select name="access_list_id" defaultValue={String(host.access_list_id ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{accessLists.map((list) => (
|
||||
<SelectItem key={list.id} value={String(list.id)}>
|
||||
{list.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<RedirectsFields initialData={host.redirects} />
|
||||
<RewriteFields initialData={host.rewrite} />
|
||||
<TextField
|
||||
name="custom_pre_handlers_json"
|
||||
label="Custom Pre-Handlers (JSON)"
|
||||
defaultValue={host.custom_pre_handlers_json ?? ""}
|
||||
helperText="Optional JSON array of Caddy handlers"
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
name="custom_reverse_proxy_json"
|
||||
label="Custom Reverse Proxy (JSON)"
|
||||
defaultValue={host.custom_reverse_proxy_json ?? ""}
|
||||
helperText="Deep-merge into reverse_proxy handler (only applies in proxy mode)"
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
|
||||
<Textarea
|
||||
name="custom_pre_handlers_json"
|
||||
defaultValue={host.custom_pre_handlers_json ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Custom Reverse Proxy (JSON)</label>
|
||||
<Textarea
|
||||
name="custom_reverse_proxy_json"
|
||||
defaultValue={host.custom_reverse_proxy_json ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Deep-merge into reverse_proxy handler (only applies in proxy mode)
|
||||
</p>
|
||||
</div>
|
||||
<AuthentikFields authentik={host.authentik} />
|
||||
<LoadBalancerFields loadBalancer={host.load_balancer} />
|
||||
<DnsResolverFields dnsResolver={host.dns_resolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstream_dns_resolution} />
|
||||
<GeoBlockFields
|
||||
initialValues={{
|
||||
geoblock: host.geoblock,
|
||||
geoblock_mode: host.geoblock_mode,
|
||||
}}
|
||||
initialValues={{
|
||||
geoblock: host.geoblock,
|
||||
geoblock_mode: host.geoblock_mode,
|
||||
}}
|
||||
/>
|
||||
<WafFields value={host.waf} />
|
||||
<MtlsFields value={host.mtls} caCertificates={caCertificates} />
|
||||
</Stack>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
@@ -281,30 +326,26 @@ export function DeleteHostDialog({
|
||||
(document.getElementById("delete-host-form") as HTMLFormElement)?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
<Stack component="form" id="delete-host-form" action={formAction} spacing={2}>
|
||||
<form id="delete-host-form" action={formAction} className="flex flex-col gap-4">
|
||||
{state.status !== "idle" && state.message && (
|
||||
<Alert severity={state.status === "error" ? "error" : "success"}>
|
||||
{state.message}
|
||||
<Alert variant={state.status === "error" ? "destructive" : "default"}>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
<p className="text-sm">
|
||||
Are you sure you want to delete the proxy host <strong>{host.name}</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will remove the configuration for:
|
||||
</Typography>
|
||||
<Box sx={{ pl: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• Domains: {host.domains.join(", ")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• Upstreams: {host.upstreams.join(", ")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="error.main" fontWeight={500}>
|
||||
</p>
|
||||
<div className="pl-4">
|
||||
<p className="text-sm text-muted-foreground">• Domains: {host.domains.join(", ")}</p>
|
||||
<p className="text-sm text-muted-foreground">• Upstreams: {host.upstreams.join(", ")}</p>
|
||||
</div>
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
This action cannot be undone.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</p>
|
||||
</form>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
import { Box, Collapse, FormControlLabel, Stack, Switch, TextField, Typography, MenuItem } from "@mui/material";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { ProxyHost, LoadBalancingPolicy } from "@/src/lib/models/proxy-hosts";
|
||||
import { ProxyHost, LoadBalancingPolicy } from "@/lib/models/proxy-hosts";
|
||||
|
||||
const LOAD_BALANCING_POLICIES = [
|
||||
{ value: "random", label: "Random", description: "Random selection (default)" },
|
||||
@@ -29,311 +31,245 @@ export function LoadBalancerFields({
|
||||
const showCookieFields = policy === "cookie";
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "info.main",
|
||||
bgcolor: "rgba(2, 136, 209, 0.05)",
|
||||
p: 2.5
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-5">
|
||||
<input type="hidden" name="lb_present" value="1" />
|
||||
<input type="hidden" name="lb_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Load Balancer
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Load Balancer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure load balancing and health checks for multiple upstreams
|
||||
</Typography>
|
||||
</Box>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
name="lb_enabled"
|
||||
checked={enabled}
|
||||
onChange={(_, checked) => setEnabled(checked)}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Collapse in={enabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2.5}>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
enabled ? "max-h-[3000px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Policy Selection */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Selection Policy
|
||||
</Typography>
|
||||
<TextField
|
||||
select
|
||||
name="lb_policy"
|
||||
label="Load Balancing Policy"
|
||||
value={policy}
|
||||
onChange={(e) => setPolicy(e.target.value as LoadBalancingPolicy)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{LOAD_BALANCING_POLICIES.map((p) => (
|
||||
<MenuItem key={p.value} value={p.value}>
|
||||
{p.label} - {p.description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2">Selection Policy</p>
|
||||
<input type="hidden" name="lb_policy" value={policy} />
|
||||
<Select value={policy} onValueChange={(v) => setPolicy(v as LoadBalancingPolicy)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select policy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOAD_BALANCING_POLICIES.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label} - {p.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Header-based policy fields */}
|
||||
<Collapse in={showHeaderField} timeout="auto" unmountOnExit>
|
||||
<TextField
|
||||
name="lb_policy_header_field"
|
||||
label="Header Field Name"
|
||||
placeholder="X-Custom-Header"
|
||||
defaultValue={initial?.policyHeaderField ?? ""}
|
||||
helperText="The request header to hash for upstream selection"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Collapse>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
showHeaderField ? "max-h-[200px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Header Field Name</label>
|
||||
<Input
|
||||
name="lb_policy_header_field"
|
||||
placeholder="X-Custom-Header"
|
||||
defaultValue={initial?.policyHeaderField ?? ""}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">The request header to hash for upstream selection</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cookie-based policy fields */}
|
||||
<Collapse in={showCookieFields} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="lb_policy_cookie_name"
|
||||
label="Cookie Name"
|
||||
placeholder="server_id"
|
||||
defaultValue={initial?.policyCookieName ?? ""}
|
||||
helperText="Name of the cookie for sticky sessions"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_policy_cookie_secret"
|
||||
label="Cookie Secret (Optional)"
|
||||
placeholder="your-secret-key"
|
||||
defaultValue={initial?.policyCookieSecret ?? ""}
|
||||
helperText="Secret key for HMAC cookie signing"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
showCookieFields ? "max-h-[300px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Cookie Name</label>
|
||||
<Input
|
||||
name="lb_policy_cookie_name"
|
||||
placeholder="server_id"
|
||||
defaultValue={initial?.policyCookieName ?? ""}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Name of the cookie for sticky sessions</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Cookie Secret (Optional)</label>
|
||||
<Input
|
||||
name="lb_policy_cookie_secret"
|
||||
placeholder="your-secret-key"
|
||||
defaultValue={initial?.policyCookieSecret ?? ""}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Secret key for HMAC cookie signing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Retry Settings */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Retry Settings
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_try_duration"
|
||||
label="Try Duration"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.tryDuration ?? ""}
|
||||
helperText="How long to try upstreams"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_try_interval"
|
||||
label="Try Interval"
|
||||
placeholder="250ms"
|
||||
defaultValue={initial?.tryInterval ?? ""}
|
||||
helperText="Wait between attempts"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_retries"
|
||||
label="Max Retries"
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
defaultValue={initial?.retries ?? ""}
|
||||
helperText="Maximum retry attempts"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2">Retry Settings</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Try Duration</label>
|
||||
<Input
|
||||
name="lb_try_duration"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.tryDuration ?? ""}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">How long to try upstreams</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Try Interval</label>
|
||||
<Input
|
||||
name="lb_try_interval"
|
||||
placeholder="250ms"
|
||||
defaultValue={initial?.tryInterval ?? ""}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Wait between attempts</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Max Retries</label>
|
||||
<Input
|
||||
name="lb_retries"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={initial?.retries ?? ""}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Maximum retry attempts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Health Checks */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<input type="hidden" name="lb_active_health_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name="lb_active_health_enabled"
|
||||
checked={activeHealthEnabled}
|
||||
onChange={(_, checked) => setActiveHealthEnabled(checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Active Health Checks</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Periodically probe upstreams to check health
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
name="lb_active_health_enabled"
|
||||
checked={activeHealthEnabled}
|
||||
onCheckedChange={setActiveHealthEnabled}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Active Health Checks</p>
|
||||
<span className="text-xs text-muted-foreground">Periodically probe upstreams to check health</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapse in={activeHealthEnabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_active_health_uri"
|
||||
label="Health Check URI"
|
||||
placeholder="/health"
|
||||
defaultValue={initial?.activeHealthCheck?.uri ?? ""}
|
||||
helperText="Path to probe for health"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_active_health_port"
|
||||
label="Health Check Port"
|
||||
type="number"
|
||||
inputProps={{ min: 1, max: 65535 }}
|
||||
defaultValue={initial?.activeHealthCheck?.port ?? ""}
|
||||
helperText="Override upstream port"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_active_health_interval"
|
||||
label="Check Interval"
|
||||
placeholder="30s"
|
||||
defaultValue={initial?.activeHealthCheck?.interval ?? ""}
|
||||
helperText="How often to check"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_active_health_timeout"
|
||||
label="Check Timeout"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.activeHealthCheck?.timeout ?? ""}
|
||||
helperText="Timeout for health probe"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_active_health_status"
|
||||
label="Expected Status Code"
|
||||
type="number"
|
||||
inputProps={{ min: 100, max: 599 }}
|
||||
defaultValue={initial?.activeHealthCheck?.status ?? ""}
|
||||
helperText="Expected HTTP status"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_active_health_body"
|
||||
label="Expected Body"
|
||||
placeholder="OK"
|
||||
defaultValue={initial?.activeHealthCheck?.body ?? ""}
|
||||
helperText="Expected response body"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
activeHealthEnabled ? "max-h-[500px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Health Check URI</label>
|
||||
<Input name="lb_active_health_uri" placeholder="/health" defaultValue={initial?.activeHealthCheck?.uri ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Path to probe for health</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Health Check Port</label>
|
||||
<Input name="lb_active_health_port" type="number" min={1} max={65535} defaultValue={initial?.activeHealthCheck?.port ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Override upstream port</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Check Interval</label>
|
||||
<Input name="lb_active_health_interval" placeholder="30s" defaultValue={initial?.activeHealthCheck?.interval ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">How often to check</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Check Timeout</label>
|
||||
<Input name="lb_active_health_timeout" placeholder="5s" defaultValue={initial?.activeHealthCheck?.timeout ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Timeout for health probe</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Expected Status Code</label>
|
||||
<Input name="lb_active_health_status" type="number" min={100} max={599} defaultValue={initial?.activeHealthCheck?.status ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Expected HTTP status</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Expected Body</label>
|
||||
<Input name="lb_active_health_body" placeholder="OK" defaultValue={initial?.activeHealthCheck?.body ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Expected response body</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passive Health Checks */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<input type="hidden" name="lb_passive_health_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name="lb_passive_health_enabled"
|
||||
checked={passiveHealthEnabled}
|
||||
onChange={(_, checked) => setPassiveHealthEnabled(checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Passive Health Checks</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Mark upstreams unhealthy based on response failures
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
name="lb_passive_health_enabled"
|
||||
checked={passiveHealthEnabled}
|
||||
onCheckedChange={setPassiveHealthEnabled}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Passive Health Checks</p>
|
||||
<span className="text-xs text-muted-foreground">Mark upstreams unhealthy based on response failures</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapse in={passiveHealthEnabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_passive_health_fail_duration"
|
||||
label="Fail Duration"
|
||||
placeholder="30s"
|
||||
defaultValue={initial?.passiveHealthCheck?.failDuration ?? ""}
|
||||
helperText="How long to remember failures"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_passive_health_max_fails"
|
||||
label="Max Failures"
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
defaultValue={initial?.passiveHealthCheck?.maxFails ?? ""}
|
||||
helperText="Failures before marking unhealthy"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_passive_health_unhealthy_status"
|
||||
label="Unhealthy Status Codes"
|
||||
placeholder="500, 502, 503"
|
||||
defaultValue={initial?.passiveHealthCheck?.unhealthyStatus?.join(", ") ?? ""}
|
||||
helperText="Comma-separated status codes"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_passive_health_unhealthy_latency"
|
||||
label="Unhealthy Latency"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.passiveHealthCheck?.unhealthyLatency ?? ""}
|
||||
helperText="Latency threshold for unhealthy"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
passiveHealthEnabled ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Fail Duration</label>
|
||||
<Input name="lb_passive_health_fail_duration" placeholder="30s" defaultValue={initial?.passiveHealthCheck?.failDuration ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">How long to remember failures</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Max Failures</label>
|
||||
<Input name="lb_passive_health_max_fails" type="number" min={0} defaultValue={initial?.passiveHealthCheck?.maxFails ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Failures before marking unhealthy</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Unhealthy Status Codes</label>
|
||||
<Input name="lb_passive_health_unhealthy_status" placeholder="500, 502, 503" defaultValue={initial?.passiveHealthCheck?.unhealthyStatus?.join(", ") ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Comma-separated status codes</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Unhealthy Latency</label>
|
||||
<Input name="lb_passive_health_unhealthy_latency" placeholder="5s" defaultValue={initial?.passiveHealthCheck?.unhealthyLatency ?? ""} className="h-8 text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Latency threshold for unhealthy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
Stack,
|
||||
Switch,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import LockPersonIcon from "@mui/icons-material/LockPerson";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
|
||||
import type { MtlsConfig } from "@/src/lib/models/proxy-hosts";
|
||||
import type { CaCertificate } from "@/lib/models/ca-certificates";
|
||||
import type { MtlsConfig } from "@/lib/models/proxy-hosts";
|
||||
|
||||
type Props = {
|
||||
value?: MtlsConfig | null;
|
||||
@@ -31,16 +25,7 @@ export function MtlsFields({ value, caCertificates }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "info.main",
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "rgba(2,136,209,0.06)" : "rgba(2,136,209,0.04)",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-4">
|
||||
<input type="hidden" name="mtls_present" value="1" />
|
||||
<input type="hidden" name="mtls_enabled" value={enabled ? "true" : "false"} />
|
||||
{enabled && selectedIds.map(id => (
|
||||
@@ -48,79 +33,60 @@ export function MtlsFields({ value, caCertificates }: Props) {
|
||||
))}
|
||||
|
||||
{/* Header */}
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
|
||||
<Stack direction="row" alignItems="flex-start" spacing={1.5} flex={1} minWidth={0}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: "info.main",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<LockPersonIcon sx={{ fontSize: 18, color: "#fff" }} />
|
||||
</Box>
|
||||
<Box minWidth={0}>
|
||||
<Typography variant="subtitle1" fontWeight={700} lineHeight={1.3}>
|
||||
Mutual TLS (mTLS)
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mt={0.25}>
|
||||
<div className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="flex flex-row items-start gap-3 flex-1 min-w-0">
|
||||
<div className="mt-0.5 w-8 h-8 rounded-xl bg-blue-500 flex items-center justify-center shrink-0">
|
||||
<LockKeyhole className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold leading-snug">Mutual TLS (mTLS)</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Require clients to present a certificate signed by a trusted CA
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(_, checked) => setEnabled(checked)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
onCheckedChange={setEnabled}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Collapse in={enabled} timeout="auto" unmountOnExit>
|
||||
<Box mt={2}>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
enabled ? "max-h-[1000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<Alert className="mb-4">
|
||||
<AlertDescription>
|
||||
mTLS requires TLS to be configured on this host (certificate must be set).
|
||||
</Alert>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
fontWeight={600}
|
||||
sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}
|
||||
>
|
||||
Trusted Client CA Certificates
|
||||
</Typography>
|
||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Trusted Client CA Certificates
|
||||
</span>
|
||||
|
||||
{caCertificates.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" mt={1}>
|
||||
No CA certificates configured. Add them on the Certificates page.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack mt={0.5}>
|
||||
{caCertificates.map(ca => (
|
||||
<FormControlLabel
|
||||
key={ca.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(ca.id)}
|
||||
onChange={() => toggleId(ca.id)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">{ca.name}</Typography>
|
||||
}
|
||||
{caCertificates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
No CA certificates configured. Add them on the Certificates page.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col mt-1">
|
||||
{caCertificates.map(ca => (
|
||||
<div key={ca.id} className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
id={`ca-cert-${ca.id}`}
|
||||
checked={selectedIds.includes(ca.id)}
|
||||
onCheckedChange={() => toggleId(ca.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
<label htmlFor={`ca-cert-${ca.id}`} className="text-sm cursor-pointer">
|
||||
{ca.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box, Button, IconButton, MenuItem, Select,
|
||||
Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import type { RedirectRule } from "@/src/lib/models/proxy-hosts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type { RedirectRule } from "@/lib/models/proxy-hosts";
|
||||
|
||||
type Props = { initialData?: RedirectRule[] };
|
||||
|
||||
@@ -23,66 +21,65 @@ export function RedirectsFields({ initialData = [] }: Props) {
|
||||
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, [key]: value } : rule)));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Redirects
|
||||
</Typography>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2">Redirects</p>
|
||||
<input type="hidden" name="redirects_json" value={JSON.stringify(rules)} />
|
||||
{rules.length > 0 && (
|
||||
<Table size="small" sx={{ mb: 1 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>From Path</TableCell>
|
||||
<TableCell>To URL / Path</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<div className="mb-2">
|
||||
<div className="grid grid-cols-[1fr_1fr_90px_40px] gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground px-1">From Path</span>
|
||||
<span className="text-xs font-medium text-muted-foreground px-1">To URL / Path</span>
|
||||
<span className="text-xs font-medium text-muted-foreground px-1">Status</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rules.map((rule, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="/.well-known/carddav"
|
||||
value={rule.from}
|
||||
onChange={(e) => updateRule(i, "from", e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="/remote.php/dav/"
|
||||
value={rule.to}
|
||||
onChange={(e) => updateRule(i, "to", e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: 90 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={rule.status}
|
||||
onChange={(e) => updateRule(i, "status", Number(e.target.value))}
|
||||
>
|
||||
<div key={i} className="grid grid-cols-[1fr_1fr_90px_40px] gap-2 items-center">
|
||||
<Input
|
||||
size={1}
|
||||
placeholder="/.well-known/carddav"
|
||||
value={rule.from}
|
||||
onChange={(e) => updateRule(i, "from", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Input
|
||||
size={1}
|
||||
placeholder="/remote.php/dav/"
|
||||
value={rule.to}
|
||||
onChange={(e) => updateRule(i, "to", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Select
|
||||
value={String(rule.status)}
|
||||
onValueChange={(v) => updateRule(i, "status", Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[301, 302, 307, 308].map((s) => (
|
||||
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||
<SelectItem key={s} value={String(s)}>{s}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: 40 }}>
|
||||
<IconButton size="small" onClick={() => removeRule(i)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => removeRule(i)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addRule}>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addRule}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Redirect
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { TextField } from "@mui/material";
|
||||
import type { RewriteConfig } from "@/src/lib/models/proxy-hosts";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { RewriteConfig } from "@/lib/models/proxy-hosts";
|
||||
|
||||
type Props = { initialData?: RewriteConfig | null };
|
||||
|
||||
export function RewriteFields({ initialData }: Props) {
|
||||
return (
|
||||
<TextField
|
||||
name="rewrite_path_prefix"
|
||||
label="Path Prefix Rewrite"
|
||||
placeholder="/recipes"
|
||||
defaultValue={initialData?.path_prefix ?? ""}
|
||||
helperText="Prepend this prefix to every request before proxying (e.g. /recipes → /recipes/original/path)"
|
||||
fullWidth
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Path Prefix Rewrite</label>
|
||||
<Input
|
||||
name="rewrite_path_prefix"
|
||||
placeholder="/recipes"
|
||||
defaultValue={initialData?.path_prefix ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Prepend this prefix to every request before proxying (e.g. /recipes → /recipes/original/path)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
import { Box, Stack, Switch, Typography } from "@mui/material";
|
||||
import type { SwitchProps } from "@mui/material";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
type ToggleSetting = {
|
||||
@@ -8,7 +7,6 @@ type ToggleSetting = {
|
||||
label: string;
|
||||
description: string;
|
||||
defaultChecked: boolean;
|
||||
color?: SwitchProps["color"];
|
||||
};
|
||||
|
||||
type SettingsTogglesProps = {
|
||||
@@ -28,12 +26,8 @@ export function SettingsToggles({
|
||||
enabled: enabled
|
||||
});
|
||||
|
||||
const handleChange = (name: keyof typeof values) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValues(prev => ({ ...prev, [name]: event.target.checked }));
|
||||
};
|
||||
|
||||
const handleEnabledChange = (_: React.ChangeEvent<HTMLInputElement>, checked: boolean) => {
|
||||
setValues(prev => ({ ...prev, enabled: checked }));
|
||||
const handleChange = (name: keyof typeof values) => (checked: boolean) => {
|
||||
setValues(prev => ({ ...prev, [name]: checked }));
|
||||
};
|
||||
|
||||
const settings: ToggleSetting[] = [
|
||||
@@ -42,98 +36,67 @@ export function SettingsToggles({
|
||||
label: "HSTS Subdomains",
|
||||
description: "Include subdomains in the Strict-Transport-Security header",
|
||||
defaultChecked: values.hsts_subdomains,
|
||||
color: "primary"
|
||||
},
|
||||
{
|
||||
name: "skip_https_hostname_validation",
|
||||
label: "Skip HTTPS Validation",
|
||||
description: "Skip SSL certificate hostname verification for backend connections",
|
||||
defaultChecked: values.skip_https_hostname_validation,
|
||||
color: "primary"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<input type="hidden" name="enabled_present" value="1" />
|
||||
<input type="hidden" name="enabled" value={values.enabled ? "on" : ""} />
|
||||
|
||||
{/* Main Enable Switch */}
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: values.enabled ? "primary.main" : "divider",
|
||||
bgcolor: values.enabled ? "rgba(99, 102, 241, 0.04)" : "background.paper",
|
||||
transition: "all 0.2s ease"
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600} color={values.enabled ? "primary.main" : "text.primary"}>
|
||||
<div className={cn(
|
||||
"flex flex-row items-center justify-between p-4 rounded-lg border transition-all duration-200",
|
||||
values.enabled
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-background"
|
||||
)}>
|
||||
<div>
|
||||
<p className={cn("text-sm font-semibold", values.enabled ? "text-primary" : "text-foreground")}>
|
||||
{values.enabled ? "Proxy Host Enabled" : "Proxy Host Paused"}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{values.enabled
|
||||
? "This host is active and routing traffic"
|
||||
: "This host is disabled and will not respond to requests"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={values.enabled}
|
||||
onChange={handleEnabledChange}
|
||||
color="primary"
|
||||
onCheckedChange={handleChange("enabled")}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5, borderBottom: "1px solid", borderColor: "divider", bgcolor: "rgba(255,255,255,0.02)" }}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Advanced Options
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack divider={<Box sx={{ borderBottom: "1px solid", borderColor: "divider" }} />}>
|
||||
<div className="rounded-lg border border-border bg-background overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border bg-white/5 dark:bg-white/2">
|
||||
<p className="text-sm font-semibold">Advanced Options</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{settings.map((setting) => (
|
||||
<Box key={setting.name}>
|
||||
<div key={setting.name}>
|
||||
<input type="hidden" name={`${setting.name}_present`} value="1" />
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ px: 2, py: 1.5 }}
|
||||
>
|
||||
<Box sx={{ pr: 2 }}>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{setting.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{setting.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<p className="text-sm font-medium">{setting.label}</p>
|
||||
<span className="text-xs text-muted-foreground">{setting.description}</span>
|
||||
</div>
|
||||
<Switch
|
||||
name={setting.name}
|
||||
checked={values[setting.name]}
|
||||
onChange={handleChange(setting.name)}
|
||||
size="small"
|
||||
color={setting.color}
|
||||
onCheckedChange={handleChange(setting.name)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { Alert, Box, Collapse, IconButton, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ProxyHost } from "@/src/lib/models/proxy-hosts";
|
||||
import type { ProxyHost } from "@/lib/models/proxy-hosts";
|
||||
|
||||
type ResolutionMode = "inherit" | "enabled" | "disabled";
|
||||
type FamilyMode = "inherit" | "ipv6" | "ipv4" | "both";
|
||||
@@ -27,80 +30,84 @@ export function UpstreamDnsResolutionFields({
|
||||
const mode = toResolutionMode(upstreamDnsResolution?.enabled);
|
||||
const family = toFamilyMode(upstreamDnsResolution?.family);
|
||||
const [expanded, setExpanded] = useState(mode !== "inherit" || family !== "inherit");
|
||||
const summary = mode === "inherit" && family === "inherit"
|
||||
const [currentMode, setCurrentMode] = useState<ResolutionMode>(mode);
|
||||
const [currentFamily, setCurrentFamily] = useState<FamilyMode>(family);
|
||||
|
||||
const summary = currentMode === "inherit" && currentFamily === "inherit"
|
||||
? "Using global upstream DNS pinning defaults"
|
||||
: `Override: ${mode === "inherit" ? "inherit mode" : mode}, ${family === "inherit" ? "inherit family" : family}`;
|
||||
: `Override: ${currentMode === "inherit" ? "inherit mode" : currentMode}, ${currentFamily === "inherit" ? "inherit family" : currentFamily}`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "info.main",
|
||||
bgcolor: "rgba(2, 136, 209, 0.06)",
|
||||
p: 2.5
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-blue-500/60 bg-blue-500/5 p-5">
|
||||
<input type="hidden" name="upstream_dns_resolution_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Upstream DNS Pinning
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Upstream DNS Pinning</p>
|
||||
<p className="text-sm text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={expanded ? "Collapse upstream DNS pinning options" : "Expand upstream DNS pinning options"}
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
sx={{
|
||||
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s ease"
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<ChevronDown className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
expanded && "rotate-180"
|
||||
)} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit={false}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="upstream_dns_resolution_mode"
|
||||
label="Resolution Mode"
|
||||
select
|
||||
defaultValue={mode}
|
||||
helperText="Inherit uses the global setting. Enabled/Disabled overrides per host."
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="inherit">Inherit Global</MenuItem>
|
||||
<MenuItem value="enabled">Enabled</MenuItem>
|
||||
<MenuItem value="disabled">Disabled</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
name="upstream_dns_resolution_family"
|
||||
label="Address Family Preference"
|
||||
select
|
||||
defaultValue={family}
|
||||
helperText="Both resolves AAAA + A with IPv6 preferred ordering."
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="inherit">Inherit Global</MenuItem>
|
||||
<MenuItem value="both">Both (Prefer IPv6)</MenuItem>
|
||||
<MenuItem value="ipv6">IPv6 only</MenuItem>
|
||||
<MenuItem value="ipv4">IPv4 only</MenuItem>
|
||||
</TextField>
|
||||
<Alert severity="info">
|
||||
When enabled, hostname upstreams are resolved during config apply and written to Caddy as concrete IP dials. If this handler has
|
||||
multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those HTTPS upstreams to avoid SNI mismatch.
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
expanded ? "max-h-[600px] opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Resolution Mode</label>
|
||||
<input type="hidden" name="upstream_dns_resolution_mode" value={currentMode} />
|
||||
<Select value={currentMode} onValueChange={(v) => setCurrentMode(v as ResolutionMode)}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit Global</SelectItem>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Inherit uses the global setting. Enabled/Disabled overrides per host.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Address Family Preference</label>
|
||||
<input type="hidden" name="upstream_dns_resolution_family" value={currentFamily} />
|
||||
<Select value={currentFamily} onValueChange={(v) => setCurrentFamily(v as FamilyMode)}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit Global</SelectItem>
|
||||
<SelectItem value="both">Both (Prefer IPv6)</SelectItem>
|
||||
<SelectItem value="ipv6">IPv6 only</SelectItem>
|
||||
<SelectItem value="ipv4">IPv4 only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">Both resolves AAAA + A with IPv6 preferred ordering.</p>
|
||||
</div>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
When enabled, hostname upstreams are resolved during config apply and written to Caddy as concrete IP dials. If this handler has
|
||||
multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those HTTPS upstreams to avoid SNI mismatch.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Box, Button, IconButton, Stack, TextField, Tooltip, Typography, Autocomplete } from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import RemoveCircleIcon from "@mui/icons-material/RemoveCircle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { MinusCircle, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const PROTOCOL_OPTIONS = ["http://", "https://"];
|
||||
|
||||
type UpstreamEntry = {
|
||||
protocol: string;
|
||||
address: string;
|
||||
@@ -33,7 +32,7 @@ export function UpstreamInput({
|
||||
|
||||
const [entries, setEntries] = useState<UpstreamEntry[]>(initialEntries);
|
||||
|
||||
const handleProtocolChange = (index: number, newProtocol: string | null) => {
|
||||
const handleProtocolChange = (index: number, newProtocol: string) => {
|
||||
const updated = [...entries];
|
||||
updated[index].protocol = newProtocol || "http://";
|
||||
setEntries(updated);
|
||||
@@ -69,69 +68,56 @@ export function UpstreamInput({
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<div>
|
||||
<input type="hidden" name={name} value={serializedValue} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Upstreams
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<p className="text-sm text-muted-foreground mb-1">Upstreams</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{entries.map((entry, index) => (
|
||||
<Stack key={index} direction="row" spacing={1} alignItems="flex-start">
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={PROTOCOL_OPTIONS}
|
||||
value={entry.protocol}
|
||||
onChange={(_, newValue) => handleProtocolChange(index, newValue)}
|
||||
onInputChange={(_, newInputValue) => {
|
||||
if (newInputValue) {
|
||||
handleProtocolChange(index, newInputValue);
|
||||
}
|
||||
}}
|
||||
disableClearable
|
||||
sx={{ width: 140 }}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
placeholder="http://"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<Select value={entry.protocol} onValueChange={(val) => handleProtocolChange(index, val)}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http://">http://</SelectItem>
|
||||
<SelectItem value="https://">https://</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={entry.address}
|
||||
onChange={(e) => handleAddressChange(index, e.target.value)}
|
||||
placeholder="10.0.0.5:8080"
|
||||
size="small"
|
||||
fullWidth
|
||||
className="flex-1"
|
||||
required={index === 0}
|
||||
/>
|
||||
<Tooltip title={entries.length === 1 ? "At least one upstream required" : "Remove upstream"}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRemove(index)}
|
||||
disabled={entries.length === 1}
|
||||
color="error"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<RemoveCircleIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<span title={entries.length === 1 ? "At least one upstream required" : "Remove upstream"}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(index)}
|
||||
disabled={entries.length === 1}
|
||||
className="text-destructive hover:text-destructive mt-0.5"
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
size="small"
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
className="self-start"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Upstream
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground mt-0.5 block">
|
||||
Backend servers to proxy requests to
|
||||
</Typography>
|
||||
</Box>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import GppBadIcon from "@mui/icons-material/GppBad";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, ClipboardCopy, ShieldOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { type WafHostConfig } from "@/src/lib/models/proxy-hosts";
|
||||
import { type WafHostConfig } from "@/lib/models/proxy-hosts";
|
||||
import { WafRuleExclusions } from "./WafRuleExclusions";
|
||||
|
||||
type WafMode = "merge" | "override";
|
||||
@@ -45,16 +36,7 @@ export function WafFields({ value, showModeSelector = true }: Props) {
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "error.main",
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "rgba(211,47,47,0.06)" : "rgba(211,47,47,0.04)",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border border-destructive bg-destructive/5 p-4">
|
||||
<input type="hidden" name="waf_present" value="1" />
|
||||
<input type="hidden" name="waf_enabled" value={enabled ? "on" : ""} />
|
||||
<input type="hidden" name="waf_mode" value={wafMode} />
|
||||
@@ -63,211 +45,158 @@ export function WafFields({ value, showModeSelector = true }: Props) {
|
||||
<input type="hidden" name="waf_custom_directives" value={customDirectives} />
|
||||
|
||||
{/* Header */}
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
|
||||
<Stack direction="row" alignItems="flex-start" spacing={1.5} flex={1} minWidth={0}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: "error.main",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<GppBadIcon sx={{ fontSize: 18, color: "#fff" }} />
|
||||
</Box>
|
||||
<Box minWidth={0}>
|
||||
<Typography variant="subtitle1" fontWeight={700} lineHeight={1.3}>
|
||||
Web Application Firewall
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mt={0.25}>
|
||||
<div className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="flex flex-row items-start gap-3 flex-1 min-w-0">
|
||||
<div className="mt-0.5 w-8 h-8 rounded-xl bg-destructive flex items-center justify-center shrink-0">
|
||||
<ShieldOff className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold leading-snug">Web Application Firewall</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Inspect and block malicious requests via Coraza / OWASP CRS
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(_, checked) => setEnabled(checked)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
onCheckedChange={setEnabled}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
<Collapse in={enabled} timeout="auto" unmountOnExit>
|
||||
<Box mt={2}>
|
||||
{/* Override mode selector */}
|
||||
{showModeSelector && (
|
||||
<>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{(["merge", "override"] as WafMode[]).map((v) => (
|
||||
<Box
|
||||
key={v}
|
||||
onClick={() => setWafMode(v)}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 0.75,
|
||||
px: 1.5,
|
||||
borderRadius: 1.5,
|
||||
border: "1.5px solid",
|
||||
borderColor: wafMode === v ? "error.main" : "divider",
|
||||
bgcolor:
|
||||
wafMode === v
|
||||
? (theme) =>
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(211,47,47,0.12)"
|
||||
: "rgba(211,47,47,0.08)"
|
||||
: "transparent",
|
||||
cursor: "pointer",
|
||||
textAlign: "center",
|
||||
transition: "all 0.15s ease",
|
||||
userSelect: "none",
|
||||
"&:hover": {
|
||||
borderColor: wafMode === v ? "error.main" : "text.disabled",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={wafMode === v ? 600 : 400}
|
||||
color={wafMode === v ? "error.main" : "text.secondary"}
|
||||
sx={{ transition: "all 0.15s ease" }}
|
||||
>
|
||||
{v === "merge" ? "Merge with global" : "Override global"}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider sx={{ mt: 2, mb: 2 }} />
|
||||
</>
|
||||
)}
|
||||
{!showModeSelector && <Divider sx={{ mb: 2 }} />}
|
||||
|
||||
{/* Engine mode */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
fontWeight={600}
|
||||
sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}
|
||||
>
|
||||
Engine Mode
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} mt={0.75}>
|
||||
{(["inherit", "Off", "On"] as EngineMode[]).map((v) => (
|
||||
<Box
|
||||
key={v}
|
||||
onClick={() => setEngineMode(v)}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 0.75,
|
||||
px: 1,
|
||||
borderRadius: 1.5,
|
||||
border: "1.5px solid",
|
||||
borderColor: engineMode === v ? "error.main" : "divider",
|
||||
bgcolor:
|
||||
engineMode === v
|
||||
? (theme) =>
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(211,47,47,0.12)"
|
||||
: "rgba(211,47,47,0.08)"
|
||||
: "transparent",
|
||||
cursor: "pointer",
|
||||
textAlign: "center",
|
||||
transition: "all 0.15s ease",
|
||||
userSelect: "none",
|
||||
"&:hover": {
|
||||
borderColor: engineMode === v ? "error.main" : "text.disabled",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={engineMode === v ? 600 : 400}
|
||||
color={engineMode === v ? "error.main" : "text.secondary"}
|
||||
sx={{ transition: "all 0.15s ease", fontSize: "0.8rem" }}
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
enabled ? "max-h-[2000px] opacity-100 mt-4" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
{/* Override mode selector */}
|
||||
{showModeSelector && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{(["merge", "override"] as WafMode[]).map((v) => (
|
||||
<div
|
||||
key={v}
|
||||
onClick={() => setWafMode(v)}
|
||||
className={cn(
|
||||
"flex-1 py-2 px-3 rounded-xl border-[1.5px] cursor-pointer text-center transition-all duration-150 select-none",
|
||||
wafMode === v
|
||||
? "border-destructive bg-destructive/10"
|
||||
: "border-border hover:border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{v === "inherit" ? "Global default" : v}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<p className={cn(
|
||||
"text-sm transition-all duration-150",
|
||||
wafMode === v ? "font-semibold text-destructive" : "font-normal text-muted-foreground"
|
||||
)}>
|
||||
{v === "merge" ? "Merge with global" : "Override global"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border mt-4 mb-4" />
|
||||
</>
|
||||
)}
|
||||
{!showModeSelector && <div className="border-t border-border mb-4" />}
|
||||
|
||||
<Divider sx={{ mt: 2, mb: 1.5 }} />
|
||||
|
||||
{/* OWASP CRS */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={loadCrs}
|
||||
onChange={(_, checked) => setLoadCrs(checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
Load OWASP Core Rule Set
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Covers SQLi, XSS, LFI, RCE and hundreds of other attack patterns
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Excluded rule IDs */}
|
||||
<Box mt={1.5}>
|
||||
<WafRuleExclusions value={value?.excluded_rule_ids} />
|
||||
</Box>
|
||||
|
||||
{/* Custom directives */}
|
||||
<Box mt={1.5}>
|
||||
<TextField
|
||||
label="Custom SecLang Directives"
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
value={customDirectives}
|
||||
onChange={(e) => setCustomDirectives(e.target.value)}
|
||||
placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`}
|
||||
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
||||
helperText="ModSecurity SecLang syntax. Appended after OWASP CRS if enabled."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Quick Templates */}
|
||||
<Box mt={0.5}>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ExpandMoreIcon sx={{ transform: showTemplates ? "rotate(180deg)" : "none", transition: "transform 0.2s" }} />}
|
||||
onClick={() => setShowTemplates((v) => !v)}
|
||||
sx={{ color: "text.secondary", textTransform: "none", px: 0 }}
|
||||
{/* Engine mode */}
|
||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Engine Mode
|
||||
</span>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
{(["inherit", "Off", "On"] as EngineMode[]).map((v) => (
|
||||
<div
|
||||
key={v}
|
||||
onClick={() => setEngineMode(v)}
|
||||
className={cn(
|
||||
"flex-1 py-2 px-2 rounded-xl border-[1.5px] cursor-pointer text-center transition-all duration-150 select-none",
|
||||
engineMode === v
|
||||
? "border-destructive bg-destructive/10"
|
||||
: "border-border hover:border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
Quick Templates
|
||||
</Button>
|
||||
<Collapse in={showTemplates}>
|
||||
<Stack spacing={0.75} mt={0.75}>
|
||||
{QUICK_TEMPLATES.map((t) => (
|
||||
<Button
|
||||
key={t.label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopyIcon fontSize="inherit" />}
|
||||
onClick={() => setCustomDirectives((prev) => prev ? `${prev}\n${t.snippet}` : t.snippet)}
|
||||
sx={{ justifyContent: "flex-start", textTransform: "none", fontFamily: "monospace", fontSize: "0.72rem" }}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
<p className={cn(
|
||||
"text-[0.8rem] transition-all duration-150",
|
||||
engineMode === v ? "font-semibold text-destructive" : "font-normal text-muted-foreground"
|
||||
)}>
|
||||
{v === "inherit" ? "Global default" : v}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mt-4 mb-3" />
|
||||
|
||||
{/* OWASP CRS */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="waf-load-crs"
|
||||
checked={loadCrs}
|
||||
onCheckedChange={(checked) => setLoadCrs(!!checked)}
|
||||
/>
|
||||
<label htmlFor="waf-load-crs" className="cursor-pointer">
|
||||
<p className="text-sm font-medium">Load OWASP Core Rule Set</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Covers SQLi, XSS, LFI, RCE and hundreds of other attack patterns
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Excluded rule IDs */}
|
||||
<div className="mt-4">
|
||||
<WafRuleExclusions value={value?.excluded_rule_ids} />
|
||||
</div>
|
||||
|
||||
{/* Custom directives */}
|
||||
<div className="mt-4">
|
||||
<Textarea
|
||||
placeholder={`SecRule REQUEST_URI "@contains /secret" "id:9001,deny,status:403,log,msg:'Blocked path'"`}
|
||||
value={customDirectives}
|
||||
onChange={(e) => setCustomDirectives(e.target.value)}
|
||||
className="font-mono text-xs min-h-[80px]"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Custom SecLang Directives — ModSecurity SecLang syntax. Appended after OWASP CRS if enabled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Templates */}
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowTemplates((v) => !v)}
|
||||
className="text-muted-foreground px-0 text-sm"
|
||||
>
|
||||
Quick Templates
|
||||
<ChevronDown className={cn(
|
||||
"h-4 w-4 ml-1 transition-transform duration-200",
|
||||
showTemplates && "rotate-180"
|
||||
)} />
|
||||
</Button>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
showTemplates ? "max-h-[500px] opacity-100 mt-2" : "max-h-0 opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{QUICK_TEMPLATES.map((t) => (
|
||||
<Button
|
||||
key={t.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setCustomDirectives((prev) => prev ? `${prev}\n${t.snippet}` : t.snippet)}
|
||||
className="justify-start font-mono text-[0.72rem]"
|
||||
>
|
||||
<ClipboardCopy className="h-3 w-3 mr-1 shrink-0" />
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
@@ -25,39 +27,45 @@ export function WafRuleExclusions({ value }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<div>
|
||||
<input type="hidden" name="waf_excluded_rule_ids" value={JSON.stringify(ids)} />
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wide">
|
||||
Excluded Rule IDs
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={0.75}>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground block mb-2">
|
||||
Rules listed here are disabled via <code>SecRuleRemoveById</code>
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" gap={0.75} mb={ids.length ? 1 : 0}>
|
||||
{ids.map((id) => (
|
||||
<Chip
|
||||
key={id}
|
||||
label={id}
|
||||
size="small"
|
||||
onDelete={() => removeId(id)}
|
||||
sx={{ fontFamily: "monospace", fontSize: "0.75rem" }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ maxWidth: 260 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Rule ID"
|
||||
</span>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{ids.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="gap-1 pr-1 font-mono text-xs">
|
||||
{id}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeId(id)}
|
||||
className="rounded-full hover:bg-destructive/20 p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 max-w-[260px]">
|
||||
<Input
|
||||
size={1}
|
||||
placeholder="Rule ID"
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addId(); } }}
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
sx={{ flex: 1 }}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="flex-1 h-8 text-sm"
|
||||
/>
|
||||
<IconButton size="small" onClick={addId} color="primary">
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Button type="button" size="icon" variant="ghost" onClick={addId} className="h-8 w-8">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user