feat: unified gateway route table (static · proxy · remote)

The gateway does two things — reverse-proxy services and serve static
frontends — but the dashboard/API route table only ever showed the proxy
routes, so 'serving a frontend' and 'proxying a service' looked like unrelated
features and castle-app/power-graph-app were invisible in the route view.

Now there's one concept: a route maps an address (path '/foo' or host 'foo.lan')
to a target of one kind — static (a built dist served by file_server), proxy
(a local service port), or remote (a service on another node).

- core: caddyfile.py gains compute_routes() — the single source of truth for
  the route list; generate_caddyfile_from_registry renders it (output byte-for-
  byte identical, verified against the live Caddyfile). Adds a GatewayRoute
  dataclass.
- api: GatewayRoute model → {address, kind, target, name, node}; GET /gateway
  builds from compute_routes (incl. config for static frontends + mesh for
  remote), so the table matches what Caddy actually does.
- app: Gateway panel shows Address · Kind · Target for every route (static
  frontends + host routes now appear); program detail shows 'Reachable at
  /foo/ · served (static)' for static frontends.
- cli: 'castle gateway status' prints the full route table.
- docs: registry.md/design.md/CLAUDE.md describe routes as one concept with
  three target kinds.

core 94 / cli 24 / api 52 green; ruff + app build clean; Caddyfile unchanged.
This commit is contained in:
2026-06-14 17:48:35 -07:00
parent 7314b5cddb
commit c40f84104d
11 changed files with 304 additions and 194 deletions

View File

@@ -57,14 +57,14 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</div>
</div>
{/* Route table */}
{/* Route table — every gateway route, of every kind */}
{gateway.routes.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-4 py-2 font-medium text-[var(--muted)]">Path</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Program</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Port</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Address</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Kind</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Target</th>
{multiNode && (
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
)}
@@ -73,25 +73,26 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</thead>
<tbody>
{gateway.routes.map((route) => {
const health = statusMap.get(route.program)
// Health applies to proxy/remote targets (a running service);
// static targets are files on disk.
const health = route.kind !== "static" && route.name ? statusMap.get(route.name) : undefined
return (
<tr
key={route.path}
key={`${route.address}-${route.node}`}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-black/20 transition-colors"
>
<td className="px-4 py-2 font-mono text-[var(--primary)]">
{route.path}
</td>
<td className="px-4 py-2 font-mono text-[var(--primary)]">{route.address}</td>
<td className="px-4 py-2">
<Link
to={`/deployment/${route.program}`}
className="hover:text-[var(--primary)] transition-colors"
>
{route.program}
</Link>
<KindBadge kind={route.kind} />
</td>
<td className="px-4 py-2 font-mono text-[var(--muted)]">
{route.target_port}
<td className="px-4 py-2 font-mono text-xs text-[var(--muted)]">
{route.name ? (
<Link to={`/deployment/${route.name}`} className="hover:text-[var(--primary)]">
{route.kind === "static" ? shortDir(route.target) : route.target}
</Link>
) : (
route.target
)}
</td>
{multiNode && (
<td className="px-4 py-2">
@@ -104,10 +105,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</td>
)}
<td className="px-4 py-2">
{health ? (
<HealthBadge status={health.status} latency={health.latency_ms} />
{route.kind === "static" ? (
<span className="text-xs text-[var(--muted)]"></span>
) : (
<HealthBadge status="unknown" />
<HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} />
)}
</td>
</tr>
@@ -119,7 +120,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
{gateway.routes.length === 0 && (
<p className="px-4 py-6 text-center text-[var(--muted)] text-sm">
No proxy routes configured.
No gateway routes configured.
</p>
)}
@@ -134,3 +135,24 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</section>
)
}
const KIND_STYLE: Record<string, string> = {
static: "bg-cyan-900/30 text-cyan-300 border-cyan-800",
proxy: "bg-green-900/30 text-green-300 border-green-800",
remote: "bg-purple-900/30 text-purple-300 border-purple-800",
}
/** Caddy serves static files; proxy/remote forward to a process. */
function KindBadge({ kind }: { kind: string }) {
return (
<span className={`text-xs px-2 py-0.5 rounded border ${KIND_STYLE[kind] ?? "text-[var(--muted)]"}`}>
{kind}
</span>
)
}
/** Show the tail of a serve directory (…/app/dist). */
function shortDir(path: string): string {
const parts = path.split("/").filter(Boolean)
return parts.length <= 2 ? path : "…/" + parts.slice(-2).join("/")
}