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:
@@ -2,7 +2,7 @@ import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-
|
|||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import type { ServiceSummary, HealthStatus } from "@/types"
|
import type { ServiceSummary, HealthStatus } from "@/types"
|
||||||
import { useServiceAction } from "@/services/api/hooks"
|
import { useServiceAction } from "@/services/api/hooks"
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel, subdomainUrl } from "@/lib/labels"
|
||||||
import { HealthBadge } from "./HealthBadge"
|
import { HealthBadge } from "./HealthBadge"
|
||||||
import { StackBadge } from "./StackBadge"
|
import { StackBadge } from "./StackBadge"
|
||||||
|
|
||||||
@@ -58,13 +58,13 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
|
|||||||
{runnerLabel(service.runner)}
|
{runnerLabel(service.runner)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{service.proxy_path && (
|
{service.subdomain && (
|
||||||
<a
|
<a
|
||||||
href={service.proxy_path + "/"}
|
href={subdomainUrl(service.subdomain) ?? undefined}
|
||||||
className="flex items-center gap-1 text-[var(--primary)] hover:underline"
|
className="flex items-center gap-1 text-[var(--primary)] hover:underline"
|
||||||
>
|
>
|
||||||
<ExternalLink size={12} />
|
<ExternalLink size={12} />
|
||||||
{service.proxy_path}
|
{service.subdomain}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{service.port && (
|
{service.port && (
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ export function CreateDeploymentForm({
|
|||||||
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
|
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
|
||||||
const [port, setPort] = useState("")
|
const [port, setPort] = useState("")
|
||||||
const [health, setHealth] = useState("/health")
|
const [health, setHealth] = useState("/health")
|
||||||
const [path, setPath] = useState("")
|
const [expose, setExpose] = useState(true)
|
||||||
const [host, setHost] = useState("")
|
|
||||||
const [schedule, setSchedule] = useState("0 2 * * *")
|
const [schedule, setSchedule] = useState("0 2 * * *")
|
||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
@@ -75,14 +74,7 @@ export function CreateDeploymentForm({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (path || host) {
|
if (port && expose) base.proxy = { caddy: {} }
|
||||||
base.proxy = {
|
|
||||||
caddy: {
|
|
||||||
...(path ? { path_prefix: path.startsWith("/") ? path : `/${path}` } : {}),
|
|
||||||
...(host ? { host } : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +152,14 @@ export function CreateDeploymentForm({
|
|||||||
<>
|
<>
|
||||||
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
|
<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="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"}`} />
|
<Field label="Expose" hint="Route through the gateway at <name>.<gateway.domain>. Off: reachable only at host:port.">
|
||||||
<TextField label="Proxy host" value={host} onChange={setHost} mono placeholder="my-service.lan (optional)" />
|
<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 * * *" />
|
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
const run = obj(m.run)
|
const run = obj(m.run)
|
||||||
const internal = obj(obj(obj(m.expose).http).internal)
|
const internal = obj(obj(obj(m.expose).http).internal)
|
||||||
const httpExpose = obj(obj(m.expose).http)
|
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 [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = 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 [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
|
||||||
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
||||||
const [proxyPath, setProxyPath] = useState((caddy.path_prefix as string) ?? "")
|
// Exposed at <service-name>.<gateway.domain> when proxy.caddy is present + enabled.
|
||||||
const [proxyHost, setProxyHost] = useState((caddy.host as string) ?? "")
|
const [expose, setExpose] = useState(caddyRaw !== undefined && obj(caddyRaw).enable !== false)
|
||||||
|
|
||||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
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
|
delete config.expose
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proxyPath || proxyHost) {
|
if (expose) config.proxy = { caddy: {} }
|
||||||
config.proxy = {
|
else delete config.proxy
|
||||||
caddy: {
|
|
||||||
...(proxyPath ? { path_prefix: proxyPath } : {}),
|
|
||||||
...(proxyHost ? { host: proxyHost } : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
delete config.proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = merged()
|
const env = merged()
|
||||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
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"
|
placeholder="/health"
|
||||||
hint="HTTP path castle polls to report up/down."
|
hint="HTTP path castle polls to report up/down."
|
||||||
/>
|
/>
|
||||||
<TextField
|
<Field
|
||||||
label="Proxy path"
|
label="Expose"
|
||||||
value={proxyPath}
|
hint="Route this service through the gateway at <service-name>.<gateway.domain>. Unchecked: reachable only at its own host:port."
|
||||||
onChange={setProxyPath}
|
>
|
||||||
width="w-48"
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
mono
|
<input
|
||||||
placeholder="/my-service"
|
type="checkbox"
|
||||||
hint="Gateway prefix — reachable at gateway:9000<path>/ (reverse-proxied to the port)."
|
checked={expose}
|
||||||
/>
|
onChange={(e) => setExpose(e.target.checked)}
|
||||||
<TextField
|
/>
|
||||||
label="Proxy host"
|
<span className="font-mono text-[var(--muted)]">
|
||||||
value={proxyHost}
|
{expose ? `${service.id}.<gateway.domain>` : "off (host:port only)"}
|
||||||
onChange={setProxyHost}
|
</span>
|
||||||
mono
|
</label>
|
||||||
placeholder="my-service.lan"
|
</Field>
|
||||||
hint="Optional: route a whole hostname to this service instead of a path (lets a root-based app serve unchanged)."
|
|
||||||
/>
|
|
||||||
{envEditor}
|
{envEditor}
|
||||||
<FormFooter
|
<FormFooter
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
|||||||
@@ -47,3 +47,16 @@ export function behaviorLabel(behavior: string): string {
|
|||||||
export function stackLabel(stack: string): string {
|
export function stackLabel(stack: string): string {
|
||||||
return STACK_LABELS[stack] ?? stack
|
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(".")}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useParams, Link } from "react-router-dom"
|
import { useParams, Link } from "react-router-dom"
|
||||||
import { Server, ExternalLink, Terminal } from "lucide-react"
|
import { Server, ExternalLink, Terminal } from "lucide-react"
|
||||||
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
|
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 { HealthBadge } from "@/components/HealthBadge"
|
||||||
import { LogViewer } from "@/components/LogViewer"
|
import { LogViewer } from "@/components/LogViewer"
|
||||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||||
@@ -68,23 +68,17 @@ export function ServiceDetailPage() {
|
|||||||
<span className="font-mono break-all">{deployment.health_path}</span>
|
<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
|
<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"
|
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>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{deployment.proxy_host && (
|
|
||||||
<>
|
|
||||||
<span className="text-[var(--muted)]">Host</span>
|
|
||||||
<span className="font-mono break-all">{deployment.proxy_host}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{deployment.runner && (
|
{deployment.runner && (
|
||||||
<>
|
<>
|
||||||
<span className="text-[var(--muted)]">Runs</span>
|
<span className="text-[var(--muted)]">Runs</span>
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ export interface ServiceSummary {
|
|||||||
run_target: string | null
|
run_target: string | null
|
||||||
port: number | null
|
port: number | null
|
||||||
health_path: string | null
|
health_path: string | null
|
||||||
proxy_path: string | null
|
subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null
|
||||||
proxy_host: string | null
|
|
||||||
managed: boolean
|
managed: boolean
|
||||||
systemd: SystemdInfo | null
|
systemd: SystemdInfo | null
|
||||||
program: string | null
|
program: string | null
|
||||||
@@ -80,7 +79,7 @@ export interface DeploymentSummary {
|
|||||||
runner: string | null
|
runner: string | null
|
||||||
port: number | null
|
port: number | null
|
||||||
health_path: string | null
|
health_path: string | null
|
||||||
proxy_path: string | null
|
subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null
|
||||||
managed: boolean
|
managed: boolean
|
||||||
systemd: SystemdInfo | null
|
systemd: SystemdInfo | null
|
||||||
version: string | null
|
version: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user