Dashboard UI: subdomain checkbox, drop proxy path/host

Follow the backend to the subdomain-only model:
- Service editor + create form: replace the Proxy path / Proxy host text fields
  with a single "Expose" checkbox (writes proxy: { caddy: {} } or removes proxy).
- ServiceSummary/DeploymentSummary type: proxy_path/proxy_host → subdomain.
- ServiceCard / ServiceDetail: show the subdomain, linking to
  <subdomain>.<domain> via a new subdomainUrl() helper (domain derived from the
  dashboard's own host; null on a bare host / off mode).
This commit is contained in:
2026-06-30 20:55:38 -07:00
parent 43ef1cc588
commit 15dc8ef6f7
6 changed files with 54 additions and 60 deletions

View File

@@ -2,7 +2,7 @@ import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-
import { Link } from "react-router-dom"
import type { ServiceSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { runnerLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge"
@@ -58,13 +58,13 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
{runnerLabel(service.runner)}
</span>
)}
{service.proxy_path && (
{service.subdomain && (
<a
href={service.proxy_path + "/"}
href={subdomainUrl(service.subdomain) ?? undefined}
className="flex items-center gap-1 text-[var(--primary)] hover:underline"
>
<ExternalLink size={12} />
{service.proxy_path}
{service.subdomain}
</a>
)}
{service.port && (

View File

@@ -39,8 +39,7 @@ export function CreateDeploymentForm({
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
const [port, setPort] = useState("")
const [health, setHealth] = useState("/health")
const [path, setPath] = useState("")
const [host, setHost] = useState("")
const [expose, setExpose] = useState(true)
const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState("")
@@ -75,14 +74,7 @@ export function CreateDeploymentForm({
},
}
}
if (path || host) {
base.proxy = {
caddy: {
...(path ? { path_prefix: path.startsWith("/") ? path : `/${path}` } : {}),
...(host ? { host } : {}),
},
}
}
if (port && expose) base.proxy = { caddy: {} }
return base
}
@@ -160,8 +152,14 @@ export function CreateDeploymentForm({
<>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono />
<TextField label="Proxy path" value={path} onChange={setPath} width="w-48" mono placeholder={`/${name || "name"}`} />
<TextField label="Proxy host" value={host} onChange={setHost} mono placeholder="my-service.lan (optional)" />
<Field label="Expose" hint="Route through the gateway at <name>.<gateway.domain>. Off: reachable only at host:port.">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={expose} onChange={(e) => setExpose(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">
{expose ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"}
</span>
</label>
</Field>
</>
) : (
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />

View File

@@ -18,7 +18,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const run = obj(m.run)
const internal = obj(obj(obj(m.expose).http).internal)
const httpExpose = obj(obj(m.expose).http)
const caddy = obj(obj(m.proxy).caddy)
const caddyRaw = obj(m.proxy).caddy
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
@@ -29,8 +29,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
)
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
const [proxyPath, setProxyPath] = useState((caddy.path_prefix as string) ?? "")
const [proxyHost, setProxyHost] = useState((caddy.host as string) ?? "")
// Exposed at <service-name>.<gateway.domain> when proxy.caddy is present + enabled.
const [expose, setExpose] = useState(caddyRaw !== undefined && obj(caddyRaw).enable !== false)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
@@ -62,16 +62,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
delete config.expose
}
if (proxyPath || proxyHost) {
config.proxy = {
caddy: {
...(proxyPath ? { path_prefix: proxyPath } : {}),
...(proxyHost ? { host: proxyHost } : {}),
},
}
} else {
delete config.proxy
}
if (expose) config.proxy = { caddy: {} }
else delete config.proxy
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
@@ -117,23 +109,21 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
placeholder="/health"
hint="HTTP path castle polls to report up/down."
/>
<TextField
label="Proxy path"
value={proxyPath}
onChange={setProxyPath}
width="w-48"
mono
placeholder="/my-service"
hint="Gateway prefix — reachable at gateway:9000<path>/ (reverse-proxied to the port)."
/>
<TextField
label="Proxy host"
value={proxyHost}
onChange={setProxyHost}
mono
placeholder="my-service.lan"
hint="Optional: route a whole hostname to this service instead of a path (lets a root-based app serve unchanged)."
/>
<Field
label="Expose"
hint="Route this service through the gateway at <service-name>.<gateway.domain>. Unchecked: reachable only at its own host:port."
>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={expose}
onChange={(e) => setExpose(e.target.checked)}
/>
<span className="font-mono text-[var(--muted)]">
{expose ? `${service.id}.<gateway.domain>` : "off (host:port only)"}
</span>
</label>
</Field>
{envEditor}
<FormFooter
saving={saving}

View File

@@ -47,3 +47,16 @@ export function behaviorLabel(behavior: string): string {
export function stackLabel(stack: string): string {
return STACK_LABELS[stack] ?? stack
}
/**
* Full URL for a service exposed at <subdomain>.<gateway.domain>. The domain is
* derived from the dashboard's own host (it is served at castle-app.<domain>), so
* this returns null when the dashboard is on a bare host (off mode, no subdomains).
*/
export function subdomainUrl(subdomain: string): string | null {
if (typeof window === "undefined") return null
const { protocol, hostname } = window.location
const labels = hostname.split(".")
if (labels.length <= 2) return null
return `${protocol}//${subdomain}.${labels.slice(1).join(".")}`
}

View File

@@ -1,7 +1,7 @@
import { useParams, Link } from "react-router-dom"
import { Server, ExternalLink, Terminal } from "lucide-react"
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { runnerLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "@/components/HealthBadge"
import { LogViewer } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader"
@@ -68,23 +68,17 @@ export function ServiceDetailPage() {
<span className="font-mono break-all">{deployment.health_path}</span>
</>
)}
{deployment.proxy_path && (
{deployment.subdomain && (
<>
<span className="text-[var(--muted)]">Proxy</span>
<span className="text-[var(--muted)]">Subdomain</span>
<a
href={deployment.proxy_path + "/"}
href={subdomainUrl(deployment.subdomain) ?? undefined}
className="flex items-center gap-1 min-w-0 break-all text-[var(--primary)] hover:underline font-mono"
>
<ExternalLink size={12} className="shrink-0" />{deployment.proxy_path}
<ExternalLink size={12} className="shrink-0" />{deployment.subdomain}
</a>
</>
)}
{deployment.proxy_host && (
<>
<span className="text-[var(--muted)]">Host</span>
<span className="font-mono break-all">{deployment.proxy_host}</span>
</>
)}
{deployment.runner && (
<>
<span className="text-[var(--muted)]">Runs</span>

View File

@@ -12,8 +12,7 @@ export interface ServiceSummary {
run_target: string | null
port: number | null
health_path: string | null
proxy_path: string | null
proxy_host: string | null
subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null
managed: boolean
systemd: SystemdInfo | null
program: string | null
@@ -80,7 +79,7 @@ export interface DeploymentSummary {
runner: string | null
port: number | null
health_path: string | null
proxy_path: string | null
subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null
managed: boolean
systemd: SystemdInfo | null
version: string | null