Files
caddy-proxy-manager/app/(dashboard)/settings/SettingsClient.tsx
fuomag9 9c60d11c2c feat: improve UI contrast, dark mode, dialog sizing, color coherence, and add table sorting
- Fix dialog scrollability (flex layout + max-h-[90dvh]) and increase L4 dialog to lg width
- Add styled enable card to L4 dialog matching proxy host pattern
- Unify section colors across proxy host and L4 dialogs (cyan=LB, emerald=DNS, violet=upstream DNS, rose=geo, amber=mTLS)
- Improve light mode contrast: muted-foreground oklch 0.552→0.502, remove opacity modifiers on secondary text
- Improve dark mode: boost muted-foreground to 0.85, increase border opacity 10%→16%, input 15%→20%
- Add bg-card to DataTable wrapper and bg-muted/40 to table headers for surface hierarchy
- Add semantic badge variants (success, warning, info, muted) and StatusChip dark mode fix
- Add server-side sortable columns to Proxy Hosts and L4 Proxy Hosts (name, upstream, status, protocol, listen)
- Add sortKey to DataTable Column type with clickable sort headers (ArrowUp/Down indicators, URL param driven)
- Fix E2E test selectors for shadcn UI (label associations, combobox roles, dropdown menus, mobile drawer)
- Add htmlFor/id to proxy host form fields and aria-labels to select triggers for accessibility
- Add sorting E2E tests for both proxy host pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:17:56 +01:00

859 lines
37 KiB
TypeScript

"use client";
import { useState } from "react";
import { useFormState } from "react-dom";
import {
Cloud, Globe, Globe2, KeyRound, Network, Pin, Activity,
ScrollText, Settings2, UserCheck, MapPin,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { StatusChip } from "@/components/ui/StatusChip";
import type {
GeneralSettings,
AuthentikSettings,
MetricsSettings,
LoggingSettings,
DnsSettings,
UpstreamDnsResolutionSettings,
GeoBlockSettings,
} from "@/lib/settings";
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
import {
updateCloudflareSettingsAction,
updateGeneralSettingsAction,
updateAuthentikSettingsAction,
updateMetricsSettingsAction,
updateLoggingSettingsAction,
updateDnsSettingsAction,
updateUpstreamDnsResolutionSettingsAction,
updateInstanceModeAction,
updateSlaveMasterTokenAction,
createSlaveInstanceAction,
deleteSlaveInstanceAction,
toggleSlaveInstanceAction,
syncSlaveInstancesAction,
updateGeoBlockSettingsAction,
} from "./actions";
import { ReactNode } from "react";
// ─── Alert helpers ────────────────────────────────────────────────────────────
function StatusAlert({ message, success }: { message: string; success: boolean }) {
return (
<Alert variant={success ? "default" : "destructive"}>
<AlertDescription>{message}</AlertDescription>
</Alert>
);
}
function InfoAlert({ children }: { children: ReactNode }) {
return (
<Alert className="border-blue-500/30 bg-blue-500/5 text-blue-700 dark:text-blue-400 [&>svg]:text-blue-500">
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
function WarnAlert({ children }: { children: ReactNode }) {
return (
<Alert className="border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400 [&>svg]:text-amber-500">
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
// ─── Section card ─────────────────────────────────────────────────────────────
type AccentConfig = { border: string; icon: string };
function SettingSection({
icon,
title,
description,
accent,
children,
}: {
icon: ReactNode;
title: string;
description?: string;
accent: AccentConfig;
children: ReactNode;
}) {
return (
<Card className={`border-l-2 ${accent.border}`}>
<CardContent className="flex flex-col gap-4 px-5 pt-5 pb-5">
<div className="flex items-start gap-3">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border ${accent.icon}`}>
{icon}
</div>
<div>
<h2 className="text-base font-semibold leading-tight">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
)}
</div>
</div>
{children}
</CardContent>
</Card>
);
}
// ─── Accents ──────────────────────────────────────────────────────────────────
const A: Record<string, AccentConfig> = {
sync: { border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500" },
general: { border: "border-l-zinc-400", icon: "border-zinc-500/30 bg-zinc-500/10 text-zinc-500" },
cloudflare: { border: "border-l-orange-500", icon: "border-orange-500/30 bg-orange-500/10 text-orange-500" },
dns: { border: "border-l-cyan-500", icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500" },
upstreamDns:{ border: "border-l-emerald-500", icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500" },
authentik: { border: "border-l-purple-500", icon: "border-purple-500/30 bg-purple-500/10 text-purple-500" },
metrics: { border: "border-l-rose-500", icon: "border-rose-500/30 bg-rose-500/10 text-rose-500" },
logging: { border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500" },
geoblock: { border: "border-l-teal-500", icon: "border-teal-500/30 bg-teal-500/10 text-teal-500" },
};
// ─── Props ────────────────────────────────────────────────────────────────────
type Props = {
general: GeneralSettings | null;
cloudflare: {
hasToken: boolean;
zoneId?: string;
accountId?: string;
};
authentik: AuthentikSettings | null;
metrics: MetricsSettings | null;
logging: LoggingSettings | null;
dns: DnsSettings | null;
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
globalGeoBlock?: GeoBlockSettings | null;
instanceSync: {
mode: "standalone" | "master" | "slave";
modeFromEnv: boolean;
tokenFromEnv: boolean;
overrides: {
general: boolean;
cloudflare: boolean;
authentik: boolean;
metrics: boolean;
logging: boolean;
dns: boolean;
upstreamDnsResolution: boolean;
};
slave: {
hasToken: boolean;
lastSyncAt: string | null;
lastSyncError: string | null;
} | null;
master: {
instances: Array<{
id: number;
name: string;
base_url: string;
enabled: boolean;
last_sync_at: string | null;
last_sync_error: string | null;
}>;
envInstances: Array<{
name: string;
url: string;
}>;
} | null;
};
};
// ─── Component ────────────────────────────────────────────────────────────────
export default function SettingsClient({
general,
cloudflare,
authentik,
metrics,
logging,
dns,
upstreamDnsResolution,
globalGeoBlock,
instanceSync
}: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null);
const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null);
const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
const [dnsState, dnsFormAction] = useFormState(updateDnsSettingsAction, null);
const [upstreamDnsResolutionState, upstreamDnsResolutionFormAction] = useFormState(
updateUpstreamDnsResolutionSettingsAction, null
);
const [instanceModeState, instanceModeFormAction] = useFormState(updateInstanceModeAction, null);
const [slaveTokenState, slaveTokenFormAction] = useFormState(updateSlaveMasterTokenAction, null);
const [slaveInstanceState, slaveInstanceFormAction] = useFormState(createSlaveInstanceAction, null);
const [syncState, syncFormAction] = useFormState(syncSlaveInstancesAction, null);
const [geoBlockState, geoBlockFormAction] = useFormState(updateGeoBlockSettingsAction, null);
const isSlave = instanceSync.mode === "slave";
const isMaster = instanceSync.mode === "master";
const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general);
const [cloudflareOverride, setCloudflareOverride] = useState(instanceSync.overrides.cloudflare);
const [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik);
const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
const [dnsOverride, setDnsOverride] = useState(instanceSync.overrides.dns);
const [upstreamDnsResolutionOverride, setUpstreamDnsResolutionOverride] = useState(
instanceSync.overrides.upstreamDnsResolution
);
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground">Configure organization-wide defaults and DNS automation.</p>
</div>
{/* ── Instance Sync ── */}
<SettingSection
icon={<Network className="h-4 w-4" />}
title="Instance Sync"
description="Choose whether this instance acts independently, pushes configuration to slave nodes, or pulls configuration from a master."
accent={A.sync}
>
<form action={instanceModeFormAction} className="flex flex-col gap-3">
{instanceSync.modeFromEnv && (
<InfoAlert>
Instance mode is configured via INSTANCE_MODE environment variable and cannot be changed at runtime.
</InfoAlert>
)}
{instanceModeState?.message && (
<StatusAlert message={instanceModeState.message} success={instanceModeState.success} />
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="instance-mode">Instance mode</Label>
<Select name="mode" defaultValue={instanceSync.mode} disabled={instanceSync.modeFromEnv}>
<SelectTrigger id="instance-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standalone">Standalone</SelectItem>
<SelectItem value="master">Master</SelectItem>
<SelectItem value="slave">Slave</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={instanceSync.modeFromEnv}>
Save instance mode
</Button>
</div>
</form>
{isSlave && (
<div className="flex flex-col gap-3 mt-1">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Master Connection</h3>
<form action={slaveTokenFormAction} className="flex flex-col gap-3">
{instanceSync.tokenFromEnv && (
<InfoAlert>
Sync token is configured via INSTANCE_SYNC_TOKEN environment variable and cannot be changed at runtime.
</InfoAlert>
)}
{slaveTokenState?.message && (
<StatusAlert message={slaveTokenState.message} success={slaveTokenState.success} />
)}
{instanceSync.slave?.hasToken && !instanceSync.tokenFromEnv && (
<InfoAlert>
A master sync token is configured. Leave the token field blank to keep it, or select &ldquo;Remove existing token&rdquo; to delete it.
</InfoAlert>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="masterToken">Master sync token</Label>
<Input
id="masterToken"
name="masterToken"
type="password"
autoComplete="new-password"
placeholder="Enter new token"
disabled={instanceSync.tokenFromEnv}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="clearToken"
name="clearToken"
disabled={!instanceSync.slave?.hasToken || instanceSync.tokenFromEnv}
/>
<Label htmlFor="clearToken">Remove existing token</Label>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={instanceSync.tokenFromEnv}>
Save master token
</Button>
</div>
</form>
{instanceSync.slave?.lastSyncError ? (
<WarnAlert>
{instanceSync.slave?.lastSyncAt
? `Last sync: ${instanceSync.slave.lastSyncAt} (${instanceSync.slave.lastSyncError})`
: "No sync payload has been received yet."}
</WarnAlert>
) : (
<InfoAlert>
{instanceSync.slave?.lastSyncAt
? `Last sync: ${instanceSync.slave.lastSyncAt}`
: "No sync payload has been received yet."}
</InfoAlert>
)}
</div>
)}
{isMaster && (
<div className="flex flex-col gap-3 mt-1">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Slave Instances</h3>
<form action={slaveInstanceFormAction} className="flex flex-col gap-3">
{slaveInstanceState?.message && (
<StatusAlert message={slaveInstanceState.message} success={slaveInstanceState.success} />
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="inst-name">Instance name</Label>
<Input id="inst-name" name="name" placeholder="Edge node EU-1" className="h-8 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="inst-base-url">Base URL</Label>
<Input id="inst-base-url" name="baseUrl" placeholder="https://slave-1.example.com" className="h-8 text-sm" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="inst-api-token">Slave API token</Label>
<Input id="inst-api-token" name="apiToken" type="password" autoComplete="new-password" className="h-8 text-sm" />
</div>
<div className="flex items-center justify-between gap-2">
<form action={syncFormAction}>
{syncState?.message && (
<StatusAlert message={syncState.message} success={syncState.success} />
)}
<Button type="submit" variant="outline" size="sm">Sync now</Button>
</form>
<Button type="submit">Add slave instance</Button>
</div>
</form>
{instanceSync.master?.instances.length === 0 && instanceSync.master?.envInstances.length === 0 && (
<InfoAlert>No slave instances configured yet.</InfoAlert>
)}
{instanceSync.master?.envInstances && instanceSync.master.envInstances.length > 0 && (
<>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mt-1">
Environment-configured (INSTANCE_SLAVES)
</p>
{instanceSync.master.envInstances.map((instance, index) => (
<div
key={`env-${index}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border bg-muted/20 px-4 py-3"
>
<div>
<p className="text-sm font-semibold">{instance.name}</p>
<p className="text-xs text-muted-foreground font-mono">{instance.url}</p>
</div>
<StatusChip status="active" label="ENV" />
</div>
))}
</>
)}
{instanceSync.master?.instances && instanceSync.master.instances.length > 0 && (
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mt-1">UI-configured instances</p>
)}
{instanceSync.master?.instances.map((instance) => (
<div
key={instance.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border px-4 py-3"
>
<div>
<p className="text-sm font-semibold">{instance.name}</p>
<p className="text-xs text-muted-foreground font-mono">{instance.base_url}</p>
<span className="text-xs text-muted-foreground">
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
</span>
{instance.last_sync_error && (
<span className="block text-xs text-destructive">{instance.last_sync_error}</span>
)}
</div>
<div className="flex gap-2">
<form action={toggleSlaveInstanceAction}>
<input type="hidden" name="instanceId" value={instance.id} />
<input type="hidden" name="enabled" value={instance.enabled ? "" : "on"} />
<Button type="submit" variant="outline" size="sm" className={instance.enabled ? "text-amber-600 border-amber-500/50" : "text-emerald-600 border-emerald-500/50"}>
{instance.enabled ? "Disable" : "Enable"}
</Button>
</form>
<form action={deleteSlaveInstanceAction}>
<input type="hidden" name="instanceId" value={instance.id} />
<Button type="submit" variant="outline" size="sm" className="text-destructive border-destructive/50">
Remove
</Button>
</form>
</div>
</div>
))}
</div>
)}
</SettingSection>
{/* ── General ── */}
<SettingSection
icon={<Settings2 className="h-4 w-4" />}
title="General"
accent={A.general}
>
<form action={generalFormAction} className="flex flex-col gap-3">
{generalState?.message && (
<StatusAlert message={generalState.message} success={generalState.success} />
)}
{isSlave && (
<div className="flex items-center gap-2">
<Checkbox
id="general-override"
name="overrideEnabled"
checked={generalOverride}
onCheckedChange={(v) => setGeneralOverride(!!v)}
/>
<Label htmlFor="general-override">Override master settings</Label>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="primaryDomain">Primary domain</Label>
<Input
id="primaryDomain"
name="primaryDomain"
defaultValue={general?.primaryDomain ?? "caddyproxymanager.com"}
required
disabled={isSlave && !generalOverride}
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="acmeEmail">ACME contact email</Label>
<Input
id="acmeEmail"
name="acmeEmail"
type="email"
defaultValue={general?.acmeEmail ?? ""}
disabled={isSlave && !generalOverride}
className="h-8 text-sm"
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" size="sm">Save general settings</Button>
</div>
</form>
</SettingSection>
{/* ── Cloudflare DNS ── */}
<SettingSection
icon={<Cloud className="h-4 w-4" />}
title="Cloudflare DNS"
description="Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates."
accent={A.cloudflare}
>
{cloudflare.hasToken && (
<InfoAlert>
A Cloudflare API token is already configured. Leave the token field blank to keep it, or select &ldquo;Remove existing token&rdquo; to delete it.
</InfoAlert>
)}
<form action={cloudflareFormAction} className="flex flex-col gap-3">
{cloudflareState?.message && (
<StatusAlert message={cloudflareState.message} success={cloudflareState.success} />
)}
{isSlave && (
<div className="flex items-center gap-2">
<Checkbox
id="cloudflare-override"
name="overrideEnabled"
checked={cloudflareOverride}
onCheckedChange={(v) => setCloudflareOverride(!!v)}
/>
<Label htmlFor="cloudflare-override">Override master settings</Label>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="cf-apiToken">API token</Label>
<Input
id="cf-apiToken"
name="apiToken"
type="password"
autoComplete="new-password"
placeholder="Enter new token"
disabled={isSlave && !cloudflareOverride}
className="h-8 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="cf-clearToken"
name="clearToken"
disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)}
/>
<Label htmlFor="cf-clearToken">Remove existing token</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cf-zoneId">Zone ID</Label>
<Input id="cf-zoneId" name="zoneId" defaultValue={cloudflare.zoneId ?? ""} disabled={isSlave && !cloudflareOverride} className="h-8 text-sm font-mono" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cf-accountId">Account ID</Label>
<Input id="cf-accountId" name="accountId" defaultValue={cloudflare.accountId ?? ""} disabled={isSlave && !cloudflareOverride} className="h-8 text-sm font-mono" />
</div>
</div>
<div className="flex justify-end">
<Button type="submit" size="sm">Save Cloudflare settings</Button>
</div>
</form>
</SettingSection>
{/* ── DNS Resolvers ── */}
<SettingSection
icon={<Globe className="h-4 w-4" />}
title="DNS Resolvers"
description="Configure custom DNS resolvers for ACME DNS-01 challenges. These resolvers will be used to verify DNS records during certificate issuance."
accent={A.dns}
>
<form action={dnsFormAction} className="flex flex-col gap-3">
{dnsState?.message && (
<StatusAlert message={dnsState.message} success={dnsState.success} />
)}
{isSlave && (
<div className="flex items-center gap-2">
<Checkbox
id="dns-override"
name="overrideEnabled"
checked={dnsOverride}
onCheckedChange={(v) => setDnsOverride(!!v)}
/>
<Label htmlFor="dns-override">Override master settings</Label>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="dns-enabled"
name="enabled"
defaultChecked={dns?.enabled ?? false}
disabled={isSlave && !dnsOverride}
/>
<Label htmlFor="dns-enabled">Enable custom DNS resolvers</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dns-resolvers" className="text-xs">Primary resolvers</Label>
<textarea
id="dns-resolvers"
name="resolvers"
placeholder={"1.1.1.1\n8.8.8.8"}
defaultValue={dns?.resolvers?.join("\n") ?? ""}
rows={2}
disabled={isSlave && !dnsOverride}
className="flex min-h-[56px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dns-fallbacks" className="text-xs">Fallback resolvers</Label>
<textarea
id="dns-fallbacks"
name="fallbacks"
placeholder={"8.8.4.4\n1.0.0.1"}
defaultValue={dns?.fallbacks?.join("\n") ?? ""}
rows={2}
disabled={isSlave && !dnsOverride}
className="flex min-h-[56px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dns-timeout" className="text-xs">Query timeout</Label>
<Input
id="dns-timeout"
name="timeout"
placeholder="5s"
defaultValue={dns?.timeout ?? ""}
disabled={isSlave && !dnsOverride}
className="h-8 text-sm w-32"
/>
<p className="text-xs text-muted-foreground">e.g. 5s, 10s</p>
</div>
<InfoAlert>
Custom DNS resolvers are useful when your DNS provider has slow propagation or when using split-horizon DNS.
Common public resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
</InfoAlert>
<div className="flex justify-end">
<Button type="submit" size="sm">Save DNS settings</Button>
</div>
</form>
</SettingSection>
{/* ── Upstream DNS Pinning ── */}
<SettingSection
icon={<Pin className="h-4 w-4" />}
title="Upstream DNS Pinning"
description="Optionally resolve upstream hostnames at config apply time and pin reverse proxy dials to IP addresses. Avoids runtime DNS churn and lets you force IPv6, IPv4, or both."
accent={A.upstreamDns}
>
<form action={upstreamDnsResolutionFormAction} className="flex flex-col gap-3">
{upstreamDnsResolutionState?.message && (
<StatusAlert message={upstreamDnsResolutionState.message} success={upstreamDnsResolutionState.success} />
)}
{isSlave && (
<div className="flex items-center gap-2">
<Checkbox
id="udns-override"
name="overrideEnabled"
checked={upstreamDnsResolutionOverride}
onCheckedChange={(v) => setUpstreamDnsResolutionOverride(!!v)}
/>
<Label htmlFor="udns-override">Override master settings</Label>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="udns-enabled"
name="enabled"
defaultChecked={upstreamDnsResolution?.enabled ?? false}
disabled={isSlave && !upstreamDnsResolutionOverride}
/>
<Label htmlFor="udns-enabled">Enable upstream DNS pinning during config apply</Label>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="udns-family">Address family preference</Label>
<Select
name="family"
defaultValue={upstreamDnsResolution?.family ?? "both"}
disabled={isSlave && !upstreamDnsResolutionOverride}
>
<SelectTrigger id="udns-family" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<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">Both resolves AAAA + A with IPv6 preferred ordering.</p>
</div>
<InfoAlert>
Host-level settings can override this default. Resolution happens at config save/reload time and resolved IPs are written into
Caddy&apos;s active config. If one handler has multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those
HTTPS upstreams to avoid SNI mismatch.
</InfoAlert>
<div className="flex justify-end">
<Button type="submit" size="sm">Save upstream DNS pinning settings</Button>
</div>
</form>
</SettingSection>
{/* ── Authentik Defaults ── */}
<SettingSection
icon={<UserCheck className="h-4 w-4" />}
title="Authentik Defaults"
description="Set default Authentik forward authentication values. These will be pre-filled when creating new proxy hosts but can be customized per host."
accent={A.authentik}
>
<form action={authentikFormAction} className="flex flex-col gap-3">
{authentikState?.message && (
<StatusAlert message={authentikState.message} success={authentikState.success} />
)}
{isSlave && (
<div className="flex items-center gap-2">
<Checkbox
id="authentik-override"
name="overrideEnabled"
checked={authentikOverride}
onCheckedChange={(v) => setAuthentikOverride(!!v)}
/>
<Label htmlFor="authentik-override">Override master settings</Label>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="outpostDomain">Outpost domain</Label>
<Input
id="outpostDomain"
name="outpostDomain"
placeholder="outpost.goauthentik.io"
defaultValue={authentik?.outpostDomain ?? ""}
required
disabled={isSlave && !authentikOverride}
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="outpostUpstream">Outpost upstream</Label>
<Input
id="outpostUpstream"
name="outpostUpstream"
placeholder="http://authentik-server:9000"
defaultValue={authentik?.outpostUpstream ?? ""}
required
disabled={isSlave && !authentikOverride}
className="h-8 text-sm font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="authEndpoint">Auth endpoint</Label>
<Input
id="authEndpoint"
name="authEndpoint"
placeholder="/outpost.goauthentik.io/auth/caddy"
defaultValue={authentik?.authEndpoint ?? ""}
disabled={isSlave && !authentikOverride}
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex justify-end">
<Button type="submit" size="sm">Save Authentik defaults</Button>
</div>
</form>
</SettingSection>
{/* ── Metrics & Monitoring ── */}
<SettingSection
icon={<Activity className="h-4 w-4" />}
title="Metrics & Monitoring"
description={`Enable Caddy metrics exposure for Prometheus, Grafana, or other observability tools. Metrics will be available at http://caddy:${metrics?.port ?? 9090}/metrics on a dedicated port.`}
accent={A.metrics}
>
<form action={metricsFormAction} className="flex flex-col gap-3">
{metricsState?.message && (
<StatusAlert message={metricsState.message} success={metricsState.success} />
)}
{isSlave && (
<div className="flex items-center gap-2">
<Checkbox
id="metrics-override"
name="overrideEnabled"
checked={metricsOverride}
onCheckedChange={(v) => setMetricsOverride(!!v)}
/>
<Label htmlFor="metrics-override">Override master settings</Label>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="metrics-enabled"
name="enabled"
defaultChecked={metrics?.enabled ?? false}
disabled={isSlave && !metricsOverride}
/>
<Label htmlFor="metrics-enabled">Enable metrics endpoint</Label>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="metrics-port">Metrics port</Label>
<Input
id="metrics-port"
name="port"
type="number"
defaultValue={metrics?.port ?? 9090}
disabled={isSlave && !metricsOverride}
className="h-8 text-sm w-32 font-mono"
/>
<p className="text-xs text-muted-foreground">Separate from admin API on port 2019.</p>
</div>
<InfoAlert>
After enabling metrics, configure your monitoring tool to scrape http://caddy-proxy-manager-caddy:{metrics?.port ?? 9090}/metrics from within the Docker network.
To expose metrics externally, add a port mapping like &ldquo;{metrics?.port ?? 9090}:{metrics?.port ?? 9090}&rdquo; in docker-compose.yml.
</InfoAlert>
<div className="flex justify-end">
<Button type="submit" size="sm">Save metrics settings</Button>
</div>
</form>
</SettingSection>
{/* ── Access Logging ── */}
<SettingSection
icon={<ScrollText className="h-4 w-4" />}
title="Access Logging"
description="Enable HTTP access logging to track all requests going through your proxy hosts. Logs are stored in the caddy-logs directory."
accent={A.logging}
>
<form action={loggingFormAction} className="flex flex-col gap-3">
{loggingState?.message && (
<StatusAlert message={loggingState.message} success={loggingState.success} />
)}
{isSlave && (
<div className="flex items-center gap-2">
<Checkbox
id="logging-override"
name="overrideEnabled"
checked={loggingOverride}
onCheckedChange={(v) => setLoggingOverride(!!v)}
/>
<Label htmlFor="logging-override">Override master settings</Label>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="logging-enabled"
name="enabled"
defaultChecked={logging?.enabled ?? false}
disabled={isSlave && !loggingOverride}
/>
<Label htmlFor="logging-enabled">Enable access logging</Label>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="logging-format">Log format</Label>
<Select
name="format"
defaultValue={logging?.format ?? "json"}
disabled={isSlave && !loggingOverride}
>
<SelectTrigger id="logging-format" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="console">Console (Common Log Format)</SelectItem>
</SelectContent>
</Select>
</div>
<InfoAlert>
Access logs are stored in the caddy-logs Docker volume.
View with: <code className="text-xs font-mono">docker exec caddy-proxy-manager-caddy tail -f /logs/access.log</code>
</InfoAlert>
<div className="flex justify-end">
<Button type="submit" size="sm">Save logging settings</Button>
</div>
</form>
</SettingSection>
{/* ── Global Geoblocking ── */}
<SettingSection
icon={<MapPin className="h-4 w-4" />}
title="Global Geoblocking"
description="Configure default geoblocking rules applied to all proxy hosts. Per-host rules can merge with or override these global defaults."
accent={A.geoblock}
>
<form action={geoBlockFormAction} className="flex flex-col gap-3">
{geoBlockState?.message && (
<StatusAlert message={geoBlockState.message} success={geoBlockState.success} />
)}
<GeoBlockFields
initialValues={{ geoblock: globalGeoBlock ?? null, geoblock_mode: "merge" }}
showModeSelector={false}
/>
<div className="flex justify-end">
<Button type="submit" size="sm">Save geoblocking settings</Button>
</div>
</form>
</SettingSection>
</div>
);
}