Dashboard: editable gateway settings + public-exposure panel

Surfaces the DNS/Cloudflare/public state that previously required editing
castle.yaml + curl:

- API: GET /gateway now returns domain, public_domain, tunnel_id,
  tunnel_connected, and a public_url per public route; new PUT /gateway/config
  edits tls/domain/public_domain/tunnel_id in castle.yaml (deploy to apply).
  save_config now persists public_domain/tunnel_id (previously dropped).
- UI: a GatewaySettings row in the Gateway panel shows TLS / domain / public
  (tunnel up/down + public_domain) with inline edit; each public route gets a
  green "public" badge linking to its <name>.<public_domain> URL.
This commit is contained in:
2026-07-01 08:29:36 -07:00
parent fc83ce40c1
commit e9b197756b
7 changed files with 205 additions and 1 deletions

View File

@@ -1,10 +1,11 @@
import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText, ExternalLink } from "lucide-react"
import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react"
import type { GatewayInfo, HealthStatus } from "@/types"
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
import { subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { GatewaySettings } from "./GatewaySettings"
interface GatewayPanelProps {
gateway: GatewayInfo
@@ -58,6 +59,9 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</div>
</div>
{/* Routing + public-exposure settings (editable) */}
<GatewaySettings gateway={gateway} />
{/* Route table — every gateway route, of every kind */}
{gateway.routes.length > 0 && (
<table className="w-full text-sm">
@@ -98,6 +102,17 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
) : (
route.address
)}
{route.public_url && (
<a
href={route.public_url}
target="_blank"
rel="noopener noreferrer"
title={route.public_url}
className="ml-2 inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 rounded bg-green-500/15 text-green-500 hover:bg-green-500/25"
>
<Cable size={10} /> public
</a>
)}
</td>
<td className="px-4 py-2">
<KindBadge kind={route.kind} />

View File

@@ -0,0 +1,96 @@
import { useState } from "react"
import { Pencil, Check, X, Cable } from "lucide-react"
import type { GatewayInfo } from "@/types"
import { useSaveGatewayConfig } from "@/services/api/hooks"
/** Editable gateway routing + public-exposure settings (domain / public_domain /
* tunnel / tls). Saves to castle.yaml; changes take effect on the next deploy. */
export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) {
const { mutate: save, isPending, data: saved } = useSaveGatewayConfig()
const [editing, setEditing] = useState(false)
const [form, setForm] = useState({
tls: gateway.tls ?? "",
domain: gateway.domain ?? "",
public_domain: gateway.public_domain ?? "",
tunnel_id: gateway.tunnel_id ?? "",
})
const field = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }))
const publicConfigured = !!gateway.public_domain && !!gateway.tunnel_id
if (editing) {
return (
<div className="px-4 py-3 border-b border-[var(--border)] grid gap-2 text-sm bg-black/10">
<Row label="TLS">
<select value={form.tls} onChange={field("tls")} className={INPUT}>
<option value="">off</option>
<option value="acme">acme</option>
<option value="internal">internal</option>
</select>
</Row>
<Row label="Domain"><input value={form.domain} onChange={field("domain")} placeholder="civil.payne.io" className={INPUT} /></Row>
<Row label="Public domain"><input value={form.public_domain} onChange={field("public_domain")} placeholder="domain0.org" className={INPUT} /></Row>
<Row label="Tunnel ID"><input value={form.tunnel_id} onChange={field("tunnel_id")} placeholder="cloudflared tunnel UUID" className={INPUT} /></Row>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => save(form, { onSuccess: () => setEditing(false) })}
disabled={isPending}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--primary)] text-white disabled:opacity-40"
>
<Check size={12} /> Save
</button>
<button onClick={() => setEditing(false)} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] text-[var(--muted)]">
<X size={12} /> Cancel
</button>
<span className="text-xs text-[var(--muted)]">Run <span className="font-mono">castle deploy</span> to apply.</span>
</div>
</div>
)
}
return (
<div className="px-4 py-2.5 border-b border-[var(--border)] flex items-center gap-x-6 gap-y-1 flex-wrap text-sm">
<span className="text-[var(--muted)]">
TLS <span className="text-[var(--foreground)] font-mono">{gateway.tls ?? "off"}</span>
</span>
<span className="text-[var(--muted)]">
Domain <span className="text-[var(--foreground)] font-mono">{gateway.domain ?? "—"}</span>
</span>
<span className="flex items-center gap-1.5 text-[var(--muted)]">
<Cable size={13} className={publicConfigured && gateway.tunnel_connected ? "text-green-500" : "text-[var(--muted)]"} />
Public
{publicConfigured ? (
<>
<span className="text-[var(--foreground)] font-mono">{gateway.public_domain}</span>
<span className={gateway.tunnel_connected ? "text-green-500 text-xs" : "text-red-500 text-xs"}>
{gateway.tunnel_connected ? "● tunnel up" : "○ tunnel down"}
</span>
</>
) : (
<span className="text-[var(--muted)]">not configured</span>
)}
</span>
<button
onClick={() => setEditing(true)}
className="ml-auto flex items-center gap-1 text-xs px-2 py-0.5 rounded text-[var(--muted)] hover:text-[var(--foreground)]"
>
<Pencil size={11} /> Edit
</button>
{saved && <span className="text-xs text-green-500">{saved.message}</span>}
</div>
)
}
const INPUT =
"bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-sm font-mono w-64 focus:outline-none focus:border-[var(--primary)]"
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex items-center gap-3">
<span className="w-28 text-[var(--muted)]">{label}</span>
{children}
</label>
)
}