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:
@@ -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("/")
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ export function ProgramDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// A static frontend (frontend behavior, build outputs, no service) is served
|
||||
// by the gateway in place — show where.
|
||||
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
|
||||
const servedAt =
|
||||
deployment.behavior === "frontend" && deployment.services.length === 0 && buildOutputs?.length
|
||||
? deployment.id === "castle-app"
|
||||
? "/"
|
||||
: `/${deployment.id}/`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
<DetailHeader
|
||||
@@ -101,6 +111,14 @@ export function ProgramDetailPage() {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{servedAt && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Reachable at</span>
|
||||
<a href={servedAt} className="font-mono text-[var(--primary)] hover:underline">
|
||||
{servedAt} <span className="text-[var(--muted)]">· served (static)</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deployment.commands && Object.keys(deployment.commands).length > 0 && (
|
||||
|
||||
@@ -110,9 +110,10 @@ export interface StatusResponse {
|
||||
}
|
||||
|
||||
export interface GatewayRoute {
|
||||
path: string
|
||||
target_port: number
|
||||
program: string
|
||||
address: string // "/foo" (path prefix) or "foo.lan" (host)
|
||||
kind: "static" | "proxy" | "remote"
|
||||
target: string // serve dir, "localhost:PORT", or "host:PORT"
|
||||
name: string | null
|
||||
node: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user