When the gateway serves internal-CA HTTPS (gateway.tls=internal), other devices
must trust Caddy's root CA to accept *.lan HTTPS. Surface that from the dashboard
instead of hand-copying root.crt around.
- API: GET /gateway/ca.crt returns the public root cert (PEM) as a download,
sourced from Caddy's admin API (/pki/ca/local — matches the running gateway)
with an on-disk root.crt fallback. 404 unless tls=internal. GatewayInfo gains
`tls` and `ca_fingerprint` (SHA-256, for out-of-band verification). The CA
private key is never exposed — only the public root.
- UI: a "CA cert" button in the Gateway panel, shown only when tls=internal,
linking to the download with the fingerprint in its tooltip.
- Tests for the tls=off defaults and the 404-without-internal-tls path; docs note
the button + endpoint.
New lines auto-scroll the view to the bottom; scrolling up to read now
pauses that (an amber 'paused' marker shows), and scrolling back to the
bottom resumes — the explicit Pause/Play button and the scroll position stay
in sync via a single at-bottom check. While paused, logs still stream in
below without moving the view. Jump-to-latest is instant rather than smooth
so mid-animation positions never register as a false scroll-up.
The LogViewer header now has a maximize toggle (and a clear button) — maximize
expands it to a full-screen overlay (Esc to restore), so you can read service/
job logs without the 24rem cap. Consolidated the per-page 'Logs' Clear button
into the viewer header (both service and job detail pages).
Editing a service/job only persisted to castle.yaml; the unit isn't
regenerated and the process isn't restarted (the Restart button just runs
'systemctl restart' on the *existing* unit), so a change like a new port
appeared saved but never took effect.
The config panel now, after saving a service/job, shows an amber 'not live
yet' banner with an Apply button that runs POST /deploy {name} (regenerate
unit + Caddyfile) then, for services, POST /services/{name}/restart — so the
edit actually goes live. Programs are unaffected (no deploy/restart needed).
App builds clean.
- Add a 'hint' to the shared Field/TextField; annotate the config forms with
short, accurate descriptions of each field:
- program: source (working copy castle builds from), behavior (tool/daemon/
frontend), stack (optional toolchain template), repo/ref (for clone),
system deps (listed for reference, NOT auto-installed), commands.
- service: port (listened-on, health-checked, proxied; map via ${port}),
health path, proxy path vs proxy host, runs.
- job: schedule (cron → systemd timer), runs.
- ProgramFields: the Commands section is now editable (build/test/lint/
type-check/run), so a program with no stack can declare its own dev verbs
by hand — a declared command overrides the stack default. Verified the
commands/build shape round-trips through the config API.
App builds clean.
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.
A service/job's env is now exactly its defaults.env — castle injects no hidden
convention vars. Values support ${port}/${data_dir}/${name} placeholders
(resolved at deploy, alongside ${secret:…}), so a program's own env var names
map to castle's computed values without hardcoding.
Why: the auto-injected <PREFIX>_PORT/<PREFIX>_DATA_DIR were a guess at the
program's env names — right for castle-scaffolded services, dead weight for
adopted ones (lakehouse carried two dead vars; notification-bridge/backup jobs
too). They also weren't visible in the config editor (computed at deploy), which
was the source of the 'four env vars but the UI shows none' mystery.
- core: resolve_env_vars gains a context (${port}/${data_dir}/${name});
deploy builds env from defaults.env only — no <PREFIX>_* injection, no
port_env. Removed the port_env field and the dead _env_prefix helper.
- cli: 'service/job create' gains repeatable --env KEY=VALUE (replaces
--port-env); 'program create' scaffolds <PREFIX>_PORT/_DATA_DIR: ${…} for new
daemons.
- app: removed the 'Port env' field; the Environment editor (defaults.env) is
the single place, with a placeholder hint.
- live migration: central-context/castle-api/power-graph/protonmail mapped their
real vars explicitly; lakehouse → just LAKEHOUSED_DAEMON_PORT: ${port}, data
stays in ~/.lakehoused. Verified all services healthy on their ports, dead
vars gone, zero failed units.
- docs: registry.md/design.md/stack guides + findings updated to the explicit
model.
core 94 / cli 24 / api 52 green; ruff + app build clean.
The Stage 2 editor split dropped the old confirm() — Remove program/service/job
deleted immediately on click. Restored confirmation on all three (FormFooter
window.confirm with a per-type message).
Removing a program that still has services/jobs referencing it would orphan
their program: ref. Now blocked:
- API: DELETE /config/programs/{name} → 409 'Programs with active jobs or
services cannot be removed. Delete these first: …' (authoritative).
- App: ProgramFields shows the blocked reason instead of a Remove button when
the program has deployments.
- CLI: castle delete refuses a program with referencing deployments not named
the same (which would survive the delete dangling).
Verified live: DELETE program lakehouse → 409. api 52 / cli 24 green; build clean.
CreateDeploymentForm builds a service or job in castle.yaml (PUT /config/...),
then deploys it (POST /deploy {name} → unit + Caddyfile + reload) and, for a
service, starts it (POST /services/{name}/start), then navigates to the new
detail page.
Reachable two ways:
- Program page Deployments section: 'Create service' / 'Create job' buttons,
prefilled from the program (name, program ref, run target, runner).
- Dashboard: standalone '+ Add service' / '+ Add job' (deployments can run
anything, not just castle programs).
Service form covers port / port_env / health / proxy path / proxy host; job
form covers schedule. Name validated against existing services/jobs.
Verified: the configs the form builds validate (PUT 200) and round-trip clean.
App builds; api 52 / core 94 green from prior stages.
- ProgramActions: install/uninstall (activate) shown only for tools and
no-service frontends. Daemons activate via a service/job, so the program
page no longer offers a misleading Install/Uninstall (which secretly
enabled/disabled the service).
- Program page: new Deployments section listing the services/jobs that run
the program, as links (a program → 0-N services/jobs).
- Service page: 'Runs' row from the run_target summary field (the old code
dug into manifest.run, which was empty pre-Stage-1), a Host row for
proxy_host, and a convenience link to the referenced program.
- lifecycle.activate on a daemon with no service now errors toward
'castle expose' instead of silently installing its binary to PATH.
core 94 green; ruff + app build clean.
The single service-shaped DeploymentFields was reused for all three sections,
so the program page bled service fields (port/proxy/health) and couldn't edit
program config, and the job page couldn't edit its schedule. Replaced it with:
- ProgramFields — description, source, stack, behavior, version, repo/ref,
system deps (+ read-only commands). Edits the program catalog entry.
- ServiceFields — run target, port, port_env, health, proxy path + host,
env/secrets. Prefills correctly now (Stage 1) and round-trips through PUT.
- JobFields — schedule, run target, env/secrets.
ConfigPanel selects the form by section. Shared Field/TextField/useEnvSecrets/
FormFooter helpers in detail/fields.tsx. Retired DeploymentFields. run editing
only touches python/command specs (container/node/remote run blocks preserved).
Verified: GET service manifest → PUT back validates 200. App type-checks/builds.
The config editor read service/job detail 'manifest' as the castle.yaml spec
but the endpoint returned the flat *runtime registry* shape for deployed
entries — so service fields (port/health/proxy) never prefilled and a save
would 422/corrupt. Detail 'manifest' is now always the editable castle.yaml
ServiceSpec/JobSpec when the entry is declared there; the flat registry shape
is a display-only fallback for entries that exist only at runtime.
Also:
- ServiceSummary/JobSummary gain run_target (what it runs) + program ref;
ServiceSummary gains proxy_host.
- ProgramSummary gains services[]/jobs[] — the deployments referencing a
program (a program → 0-N services/jobs), for linking + create prefill.
- App types updated to match; deleted orphaned DeploymentEditor/AddDeployment
(leftovers from the removed ConfigEditor).
Verified live: service manifest nested (expose.http.internal.port, proxy.caddy
.host prefill), run_target=lakehoused, program lakehouse → services:[lakehouse].
api 52 green; ruff + app build clean.
The Programs table's install/uninstall icon keyed off `installed`
(shutil.which(name)) — a PATH lookup that only makes sense for python CLI
tools. Static frontends have no PATH binary so they were always
`installed=false` → a green 'Install' icon even while actively served;
container daemons were `installed=null` → same. And for tools the value
reflected the castle-api service's PATH, which isn't guaranteed to include
~/.local/bin across reboots, so they could all flip to 'not installed'.
Switch ProgramActions/ProgramTable/ProgramDetail to the uniform `active`
state (Phase 2's lifecycle.is_active: PATH-independent ~/.local/bin check for
tools, systemctl for services/jobs, served-assets check for static
frontends). Now every program reflects real state: served frontends show
Uninstall, container daemons (no install verb) show no icon.
App builds clean.
The dashboard widgets were named after the old 'component' concept. Renamed
each to what it actually renders:
- ComponentCard → ServiceCard (ServiceSummary, /services/, service actions)
- ComponentTable → ProgramTable (ProgramSummary[], the program catalog)
- ComponentEditor→ DeploymentEditor (DeploymentDetail, the unified type)
- ComponentFields→ DeploymentFields (AnyDetail)
- AddComponent → AddDeployment
Also purged the domain term 'component' from the rest of the app: the
useComponent hook → useDeployment, ConfigPanel + the three detail pages'
`component` prop/var → `deployment`, and the gateway/node table headers
(Component → Program / Deployment). Props renamed to match (component →
service / program / deployment). The React UI directory app/src/components/
keeps its conventional name.
App type-checks and builds clean.
The 'tools' concept was just a filtered view of programs and added unnecessary
complexity to the frontend. This commit:
- Removes ToolCard.tsx and Tools.tsx (pages/components never used in router)
- Renames ComponentDetail.tsx → ProgramDetail.tsx (exports ProgramDetailPage)
- Renames ComponentRedirect.tsx → ProgramRedirect.tsx (exports ProgramRedirect)
- Updates imports in routes.tsx to match new file names
- Fixes stale comment in hooks.ts
Frontend naming now aligns with the unified programs model.
Eliminates inconsistency where tools had their own filtered view but daemons and frontends didn't.
Changes:
- API: Removed tools router and ToolSummary/ToolDetail models. Added optional behavior query parameter to GET /programs for filtering by program type (tool, daemon, frontend).
- Frontend: Updated hooks to pass behavior param to usePrograms instead of separate useTools. Updated components to use ProgramSummary type. Removed useToolDetail hook.
- Docs: Updated API documentation to reflect program behavior filtering.
- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications.
- Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks.
- Adjusted tests to reflect the new program structure and ensure proper functionality.
- Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
feat: implement HealthBadge and RoleBadge components for displaying health status and roles
feat: create LogViewer component for streaming logs of services
feat: develop SecretsEditor for managing secrets with CRUD operations
feat: introduce ToolCard component for displaying tool information
style: add global CSS variables and styles for consistent theming
feat: set up API client for handling requests to the backend
feat: implement hooks for fetching components, statuses, and tools
feat: create routes for dashboard, component details, and tools
feat: add service management endpoints for starting, stopping, and restarting services
feat: implement event bus for handling real-time updates via SSE
feat: create health check and logs endpoints for monitoring services
feat: add tests for health and tools endpoints to ensure functionality
chore: update project configuration files and dependencies for better development experience