diff --git a/CLAUDE.md b/CLAUDE.md index b4a4a8c..16f273a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,13 +42,13 @@ config.yml → detect (framework/port/db) → generate (~/.roost/build/*) → ru - **`internal/config`** — loads/validates `config.yml`, resolves app paths to absolute dirs, resolves each app to exactly one FQDN (a `worker: true` app instead resolves with an empty FQDN — no route). `FindConfig` resolution order: `--config` flag → `$ROOST_CONFIG` → `./roost.yml` → `~/.roost/config.yml` (first hit wins). The optional top-level `remote:` key (an `ssh://`/`tcp://`/`unix://` Docker endpoint) runs the stack on a remote daemon: `root.go`'s `applyRemote` sets `DOCKER_HOST` from it (an explicit `$DOCKER_HOST` wins), and `Plan` sets `App.NoSourceMount` so remote apps build into their image rather than bind-mounting local source the remote host doesn't have. Local is the default (empty `remote:`). The optional top-level `include:` key (glob or list) pulls `apps:` from other files — each included file carries only `apps:` (other keys rejected), its paths resolve against its own dir, and its apps append after the main file's in pattern order. `internal/config/edit.go` mutates the app list while preserving comments (`roost add`/`remove`). An app may carry an optional `repo:` (its git origin URL); `roost add --repo ` clones into `~/.roost/sources/` (via `internal/source`) and writes both `path:` and `repo:`. Redeploying that checkout is the existing `roost deploy ` (`git pull --ff-only` + rebuild just that app) — no separate update verb. The optional top-level `control_host:` names the FQDN that routes to the `roost web` control panel (a Caddy route + tunnel ingress to the host process); unset means the panel stays loopback-only. The optional `tunnel.protocol:` (`""`/`http2`) overrides the cloudflared edge transport — empty lets cloudflared pick QUIC (UDP, fastest on clean links), `http2` forces TCP/443 for networks that throttle/drop UDP (many home/office ISPs) where QUIC flaps and every app 502s; threaded into generation as `generate.Opts{ControlHost, TunnelProtocol}` (the compose cloudflared `command:` gets `--protocol

`). - **`internal/detect`** — infers framework/port/start/db/redis/runtime from folder signals. Explainable: every `Detection` names the signal that triggered it; an unrecognizable folder is an explicit error, never a silent guess. Rules are priority-ordered; fixtures live in `testdata/-app/`. `Detection.Redis` fires on a `sidekiq`/`redis` gem or a `REDIS_URL` in `.env.example`. -- **`internal/generate`** — `Plan()` turns config + resolved apps into `[]App`; `Generate()` renders `compose.yml`, per-app Dockerfiles, the `Caddyfile`, and DB init scripts from `templates/*.tmpl` (embedded via `embed`). Postgres apps each get their own login role owning their database (`dbUser`/`dbPassword` — a name-derived, regeneration-stable password, `CREATEDB` so Rails multi-db can make sibling `_*` DBs); `appEnv` builds `DATABASE_URL` from those per-app creds. MySQL apps still use `root` on the internal network. Per-app `env:` is runtime (compose `environment:`); `build_env:` is build-time (`ENV` in the Dockerfile builder stage, injected into all four generated templates) for frameworks that validate env during their build (e.g. Next.js `@t3-oss/env` needing `SKIP_ENV_VALIDATION`). Per-app `migrate:` (`MigrateSpec`, bool-or-string) controls the setup step: absent/`true` → framework `db:prepare`/`migrate` (`App.SetupCommand`); `false` → skip it (for images whose entrypoint self-migrates — roost running a second concurrent `db:prepare` races them and a Rails multi-db app dies with "No database selected"); a string overrides the command. Per-app `redis:` (`RedisSpec`, bool) forces the shared `redis:7-alpine` service on/off over detection; when on, `appEnv` injects `REDIS_URL=redis://redis:6379/0` and the app gets a `depends_on: redis`. Per-app `command:` overrides the container start command (sets both `App.Command`, the compose `command:` override for own-Dockerfile apps, and `App.StartCommand`, the generated-Dockerfile CMD). Per-app `volumes:` (`[]string`, `source:/container/path[:ro]`) are persistent mounts for apps that keep state on disk rather than in the shared DB (paperless documents/index): `appVolumes` renders each entry, namespacing a **named** source with the app name (`data` → `paperless-data`, declared under the compose top-level `volumes:` via `composeData.NamedVolumes`) so two apps' `data` volumes never collide, and passing a **path** source (`/srv/x`, `~/x`, `.`) through as a host bind mount; `config.ValidateVolume` rejects a missing/relative container path. They merge with the source mount under the one service `volumes:` key. An **own-Dockerfile app gets no source mount** (`MountSource` is gated on `!HasOwnDockerfile`) — it builds its own filesystem, so bind-mounting host source over `/app` would only shadow it. Each HTTP app also gets a generated compose `healthcheck:` (`App.HealthCheck` via `healthCommand`) — a TCP probe of its own port using a runtime binary the image is guaranteed to have (`ruby -rsocket`, `node net`, `python socket`, `php fsockopen`, busybox `wget` for static; curl/wget aren't in slim images). Workers and own-Dockerfile apps get none, so `docker compose ps` / `roost status` show health only where roost owns the image. `worker: true` marks a non-HTTP background entry (a second entry over another app's `path`, e.g. a Sidekiq consumer): no FQDN, excluded from the `Caddyfile`, and no `db:prepare`/seed (the web entry owns the DB lifecycle) — `config.Resolve` requires it to carry a `command:`. cmd hostname collectors (`tunnel.go`, `doctor.go`) skip empty-FQDN worker apps so they never get a DNS record. +- **`internal/generate`** — `Plan()` turns config + resolved apps into `[]App`; `Generate()` renders `compose.yml`, per-app Dockerfiles, the `Caddyfile`, `error.html` (the branded offline page), and DB init scripts from `templates/*.tmpl` (embedded via `embed`). The `Caddyfile` defines a `(roost_maintenance)` snippet each app site `import`s: a `handle_errors` block that serves `error.html` (mounted into the caddy service) whenever an upstream is down, so a dead app returns a branded page instead of a bare 502. `RenderErrorPage()` is the single source of that page — the edge Worker (`internal/tunnel/worker.go`) embeds the same bytes, so the app-down (Caddy) and tunnel-down (Worker/1033) pages are identical. Postgres apps each get their own login role owning their database (`dbUser`/`dbPassword` — a name-derived, regeneration-stable password, `CREATEDB` so Rails multi-db can make sibling `_*` DBs); `appEnv` builds `DATABASE_URL` from those per-app creds. MySQL apps still use `root` on the internal network. Per-app `env:` is runtime (compose `environment:`); `build_env:` is build-time (`ENV` in the Dockerfile builder stage, injected into all four generated templates) for frameworks that validate env during their build (e.g. Next.js `@t3-oss/env` needing `SKIP_ENV_VALIDATION`). Per-app `migrate:` (`MigrateSpec`, bool-or-string) controls the setup step: absent/`true` → framework `db:prepare`/`migrate` (`App.SetupCommand`); `false` → skip it (for images whose entrypoint self-migrates — roost running a second concurrent `db:prepare` races them and a Rails multi-db app dies with "No database selected"); a string overrides the command. Per-app `redis:` (`RedisSpec`, bool) forces the shared `redis:7-alpine` service on/off over detection; when on, `appEnv` injects `REDIS_URL=redis://redis:6379/0` and the app gets a `depends_on: redis`. Per-app `command:` overrides the container start command (sets both `App.Command`, the compose `command:` override for own-Dockerfile apps, and `App.StartCommand`, the generated-Dockerfile CMD). Per-app `volumes:` (`[]string`, `source:/container/path[:ro]`) are persistent mounts for apps that keep state on disk rather than in the shared DB (paperless documents/index): `appVolumes` renders each entry, namespacing a **named** source with the app name (`data` → `paperless-data`, declared under the compose top-level `volumes:` via `composeData.NamedVolumes`) so two apps' `data` volumes never collide, and passing a **path** source (`/srv/x`, `~/x`, `.`) through as a host bind mount; `config.ValidateVolume` rejects a missing/relative container path. They merge with the source mount under the one service `volumes:` key. An **own-Dockerfile app gets no source mount** (`MountSource` is gated on `!HasOwnDockerfile`) — it builds its own filesystem, so bind-mounting host source over `/app` would only shadow it. Each HTTP app also gets a generated compose `healthcheck:` (`App.HealthCheck` via `healthCommand`) — a TCP probe of its own port using a runtime binary the image is guaranteed to have (`ruby -rsocket`, `node net`, `python socket`, `php fsockopen`, busybox `wget` for static; curl/wget aren't in slim images). Workers and own-Dockerfile apps get none, so `docker compose ps` / `roost status` show health only where roost owns the image. `worker: true` marks a non-HTTP background entry (a second entry over another app's `path`, e.g. a Sidekiq consumer): no FQDN, excluded from the `Caddyfile`, and no `db:prepare`/seed (the web entry owns the DB lifecycle) — `config.Resolve` requires it to carry a `command:`. cmd hostname collectors (`tunnel.go`, `doctor.go`) skip empty-FQDN worker apps so they never get a DNS record. - **`internal/runner`** — orchestrates `docker compose` for the generated stack via a `shell.Runner`. **There is no roost daemon**; Docker's restart policy is the supervisor. Handles up (staggered starts) / down / status / logs (per-app or all apps when unnamed) / start / stop / restart (per-app), and profile selection (`AppSelected`). `TunnelStatus` classifies the cloudflared connector (connected / reconnecting-after-wake / down) from its log tail — advisory, so `roost status` can tell a transient edge 502 apart from a real app-down. After (re)creating app containers it reloads Caddy (`reloadProxy`) so the proxy never serves stale upstreams. `Prepare()` runs each DB app's idempotent setup command (`generate.App.SetupCommand`, e.g. Rails `db:prepare`) on every up, then — for apps with a `SeedCommand`, via `sh -lc` with `SEED_DEMO=1` — seeds once, gated by an injected `shouldSeed`/`onSeeded` pair the up command backs with `state.Seeded` (so `roost up` seeds each app once; `--reseed` forces). A failed seed exec is never marked seeded. `MysqlVolumeID()` (`docker volume inspect roost_roost-mysql-data`) lets the up command detect a recreated data volume and reset the seeded set. -- **`internal/tunnel`** — Cloudflare API client (`client.go`), tunnel ensure/adopt logic (`ensure.go`), and DNS record planning (`plan.go`). One wildcard DNS record per routing suffix + host-header routing inside, which is why adding an app is a purely local change (no per-app DNS call). Refuses to overwrite DNS or adopt tunnels it didn't create without `--force`/`--adopt`. +- **`internal/tunnel`** — Cloudflare API client (`client.go`), tunnel ensure/adopt logic (`ensure.go`), and DNS record planning (`plan.go`). One wildcard DNS record per routing suffix + host-header routing inside, which is why adding an app is a purely local change (no per-app DNS call). Refuses to overwrite DNS or adopt tunnels it didn't create without `--force`/`--adopt`. `worker.go` is the opt-in **edge maintenance page** (`config` key `tunnel.maintenance_page`): when set, `tunnel setup` calls `EnsureWorker` to upload a fallback Cloudflare Worker (fixed name `roost-maintenance`, uploaded raw via `client.doRaw` with `application/javascript`) and ensure one route (`*./*`) per zone in the DNS plan — idempotent (an existing route on our pattern is repointed via `UpdateWorkerRoute`, never duplicated). The Worker proxies every request through and only substitutes the branded page on an origin-connectivity failure (thrown fetch or 502/503/504/52x/**530** — 530 is the 1033 that never reaches Caddy). The page bytes come from `generate.RenderErrorPage()`, JSON-encoded into the Worker as a safe JS string literal, so Caddy's 502 page and the Worker's 1033 page are byte-identical. State records the script+routes (`state.Worker`) so `Teardown` (uninstall, `removeTunnel=true`) removes exactly what roost created via `TeardownWorker` (routes first, then script; a failed route is kept for retry). - **`internal/state`** — persists roost's remote-side ownership in `~/.roost/state.json` (tunnel ID, account, created DNS records) so down/uninstall clean up only what roost made. Also tracks `Seeded` (apps already seeded) via `HasSeeded`/`MarkSeeded` so `roost up` seeds each app once, plus `MysqlVolumeID` — `SyncMysqlVolume(id)` clears `Seeded` when the data volume's identity changes (Clean/Purge, `volume rm`) so a wiped DB is re-seeded instead of skipped. - **`internal/doctor`** — preflight checks (Docker running, token scopes, SSL depth, DNS shadowing, credentials perms); every `Finding` has a severity and a specific remedy. A fixable `Finding` also carries a `*Fix` (kind + primitive params, no client import in the core); `fix.go`'s `ApplyFixes(findings, client)` runs the safe subset for `roost doctor --fix` — chmod credentials (no client needed), create a missing tunnel DNS record, flip a grey-cloud record to proxied. It never repoints a wrong-content record. The multi-level-subdomain SSL trap check matters: free Universal SSL covers one subdomain level only. - **`internal/lifecycle`** — boot-on-login unit: launchd on macOS, systemd `--user` on Linux, a Task Scheduler logon task on Windows (via `schtasks`, no unit file). All just run `roost up` at login (parameterized units also install `roost web` as an always-on user service). A genuinely unsupported GOOS returns manual instructions rather than failing. -- **`internal/web`** — the `roost web` browser control panel (Material Design 3 UI). A long-running host process **outside** the compose stack (so `down` can't kill the thing that brings the stack back up), driving a fakeable `Controller` interface so tests use `httptest` + a fake and never touch Docker. Serves status/health, whole-stack and per-app start/stop, and add/remove/update apps behind a `roost doctor` preflight gate. The add form takes a **GitHub repo URL** (roost clones it via `AddApp(path, domain, repo, emit)` → `source.Clone` into `~/.roost/sources/`) **or** a host path already on disk — not both; `handleAdd` 400s if both or neither is given. Each git-backed card (any app whose `AppStatus.Repo` origin resolved, `web.go`'s `repoURL`) gets a **Pull & redeploy** menu action posting to `POST /deploy` (`handleDeploy` → `DeployApp`, which reuses `cmd/roost/deploy.go`'s `deployApp` — the same `git pull --ff-only` + rebuild that `roost deploy` runs). The Edge card carries `EdgeInfo.TunnelState` (from `runner.TunnelStatus`) as a live connected/reconnecting/down chip, refreshed with the rail every 5s. Incidents get a dedicated page: `GET /incidents` (`handleIncidentsPage`) reuses the dashboard shell via a `statusView.Page` flag so the `

` swaps to the incident timeline while both sidebars stay put; `POST /incidents/read` marks every incident read (acknowledged) — the full history is kept, read entries just render dimmed; nothing is deleted. The sidebar block holds just the **Test alert** button — no live incident list. `handleAppDetail` folds that app's own incident history (`AppDetail.Incidents`, from `Server.appIncidents` — incident state lives in the Server, not the `Controller`) into the drawer so each app card doubles as its status page. The incidents page also offers **share** buttons (copy / X / LinkedIn / Facebook) built from `incidentSummary(view)` (server-composed, client appends the `/status` URL). **Featured apps**: `POST /app/featured` (`handleToggleFeatured`, guarded) toggles an app name in `Settings.Featured` (capped at `featuredCap`=2, dedup/trim in `Normalize`, persisted via the store) and redirects to `/`; `buildStatusView` resolves those names to `statusView.Featured` (+ `FeaturedSet` for star-fill) via `featuredApps` — **opt-in, no fallback**, so the strip and the star toggles stay unambiguous. The template renders a Featured strip (between the gauges and Applications) plus a star button on every non-worker card, both form-POSTing to the endpoint (the handler 303-redirects for the no-JS path; client JS intercepts the submit, fetches, and calls the live-swap `refresh(force)` so pinning never reloads). **Settings** (`internal/web/settings.go`: `Settings` + `Normalize` + `TechLabel` + email template `renderEmail` + a `SettingsStore` interface; file store is `cmd/roost/panelstore.go` → `~/.roost/panel.json`, 0600, no password) drive: `GET/POST /settings` (guarded save rebuilds the notifier in place via an injected `mailerFactory`), the initial view/theme (server default when no localStorage), a `mask` template func that hides IP/SSH/host/tunnel-ids on the Server & Edge cards, per-key tech-stack label overrides on the cards, custom incident-email templates, and the monitor cadence (`StartMonitor` re-reads `Settings.MonitorMins`, default 2 min, each pass so a change applies on the next tick). The public `/status` page shows incident detail and carries a 2-min ``. Default bind is loopback; `--token`/`$ROOST_WEB_TOKEN` gates mutations; remote exposure is opt-in via `control_host:` + Cloudflare Access. Auto-deploys to the box on merge to `main` via `.github/workflows/deploy-web.yml`. +- **`internal/web`** — the `roost web` browser control panel (Material Design 3 UI). A long-running host process **outside** the compose stack (so `down` can't kill the thing that brings the stack back up), driving a fakeable `Controller` interface so tests use `httptest` + a fake and never touch Docker. Serves status/health, whole-stack and per-app start/stop, and add/remove/update apps behind a `roost doctor` preflight gate. The add form takes a **GitHub repo URL** (roost clones it via `AddApp(path, domain, repo, emit)` → `source.Clone` into `~/.roost/sources/`) **or** a host path already on disk — not both; `handleAdd` 400s if both or neither is given. Each git-backed card (any app whose `AppStatus.Repo` origin resolved, `web.go`'s `repoURL`) gets a **Pull & redeploy** menu action posting to `POST /deploy` (`handleDeploy` → `DeployApp`, which reuses `cmd/roost/deploy.go`'s `deployApp` — the same `git pull --ff-only` + rebuild that `roost deploy` runs). The Edge card carries `EdgeInfo.TunnelState` (from `runner.TunnelStatus`) as a live connected/reconnecting/down chip, refreshed with the rail every 5s. A dedicated **Dashboard** page (sidebar nav above Resources): `GET /dashboard` (`handleDashboardPage`, `Page="metrics"`) reuses the shell and renders monitoring charts fed by `GET /api/metrics` (`handleMetricsAPI`, read-only/unguarded like `/api/app`) — a JSON snapshot of aggregate + per-app metrics, host/system, incidents (open/resolved + 14-day buckets), and an in-memory aggregate time-series ring (`metricSample`, `recordSample`, capped `metricsCap`). The page polls every 5s and draws hand-rolled inline-SVG charts (no libs); `refresh()` early-returns when `#dash` is present so the whole-page swap can't wipe them. The logo is an `` (home). Incidents get a dedicated page: `GET /incidents` (`handleIncidentsPage`) reuses the dashboard shell via a `statusView.Page` flag so the `
` swaps to the incident timeline while both sidebars stay put; `POST /incidents/read` marks every incident read (acknowledged) — the full history is kept, read entries just render dimmed; nothing is deleted. The sidebar block holds just the **Test alert** button — no live incident list. `handleAppDetail` folds that app's own incident history (`AppDetail.Incidents`, from `Server.appIncidents` — incident state lives in the Server, not the `Controller`) into the drawer so each app card doubles as its status page. The incidents page also offers **share** buttons (copy / X / LinkedIn / Facebook) built from `incidentSummary(view)` (server-composed, client appends the `/status` URL). **Featured apps**: `POST /app/featured` (`handleToggleFeatured`, guarded) toggles an app name in `Settings.Featured` (capped at `featuredCap`=2, dedup/trim in `Normalize`, persisted via the store) and redirects to `/`; `buildStatusView` resolves those names to `statusView.Featured` (+ `FeaturedSet` for star-fill) via `featuredApps` — **opt-in, no fallback**, so the strip and the star toggles stay unambiguous. The template renders a Featured strip (between the gauges and Applications) plus a star button on every non-worker card, both form-POSTing to the endpoint (the handler 303-redirects for the no-JS path; client JS intercepts the submit, fetches, and calls the live-swap `refresh(force)` so pinning never reloads). **Settings** (`internal/web/settings.go`: `Settings` + `Normalize` + `TechLabel` + email template `renderEmail` + a `SettingsStore` interface; file store is `cmd/roost/panelstore.go` → `~/.roost/panel.json`, 0600, no password) drive: `GET/POST /settings` (guarded save rebuilds the notifier in place via an injected `mailerFactory`), the initial view/theme (server default when no localStorage), a `mask` template func that hides IP/SSH/host/tunnel-ids on the Server & Edge cards, per-key tech-stack label overrides on the cards, custom incident-email templates, and the monitor cadence (`StartMonitor` re-reads `Settings.MonitorMins`, default 2 min, each pass so a change applies on the next tick). The public `/status` page shows incident detail and carries a 2-min ``. Default bind is loopback; `--token`/`$ROOST_WEB_TOKEN` gates mutations; remote exposure is opt-in via `control_host:` + Cloudflare Access. Auto-deploys to the box on merge to `main` via `.github/workflows/deploy-web.yml`. - **`internal/notify`** — incident email (SMTP) for the panel's health monitor: a `Mailer` that's a no-op unless a host and recipients are configured, with the password read only from `$ROOST_SMTP_PASSWORD` (never `config.yml`). - **`internal/source`** — manages the app checkouts roost owns under `~/.roost/sources/`. `Clone(runner, repo, dest)` (git clone through a `shell.Runner`, refuses an existing dest, so tests never touch a real repo or the network) and `NameFromRepo(url)` (derives an app name from a git URL the same way `config.Slugify` resolves a name from a path, so `add --repo` and detection agree). Used by `roost add --repo` and the panel's add-from-repo. Pulling an existing checkout is the pre-existing `cmd/roost/deploy.go` (`git pull --ff-only` + rebuild), which both `roost deploy` and the panel's **Pull & redeploy** button reuse — not a second code path here. - **`internal/shell`** — the only package permitted to call `os/exec`. `Runner` interface with `Exec` (real) and `Fake` (records calls, answers via hooks) implementations. diff --git a/README.md b/README.md index f62424f..fdc5fe4 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ domain: demo.example.com # fallback suffix for bare-path apps tunnel: name: rserver # your tunnel's name (never generated) protocol: http2 # optional; force TCP/443 when your ISP throttles UDP/QUIC (default: QUIC) + maintenance_page: true # optional; edge Worker serves a branded page on a full-tunnel-down (1033) access: emails: [me@example.com] # Cloudflare Access wall before first exposure defaults: @@ -291,6 +292,15 @@ and personal-hosting** tool for demos, side projects, and sharing work in progress. It is not a replacement for a server; when the laptop wakes, `cloudflared` reconnects within ~5–10 seconds and everything is live again. +**Visitors don't see a raw error while you're away.** If a single app is down, +Caddy serves a branded *"temporarily offline"* page instead of a bare 502 — +automatic, no config. If the *whole* tunnel is down (lid shut, machine off → +Cloudflare's blunt **1033**), set `tunnel.maintenance_page: true` and +`roost tunnel setup` deploys a tiny Cloudflare **Worker** that answers with the +same page from the edge, where your host can't. Both render an identical +self-contained page that auto-retries every 30s. See +[docs/configuration.md](docs/configuration.md#offline--maintenance-ui--two-layers). + --- ## ⚡ 60-second quickstart @@ -415,6 +425,12 @@ it does: pill, health, and a colour-coded **memory bar**; a *Needs attention* strip surfaces anything not running, and metric cards summarise running / memory / stopped. +- **Dashboard** (sidebar → **Dashboard**, above Resources) — a real-time + monitoring page that polls `GET /api/metrics` every 5s and draws hand-rolled + inline SVG charts (no external libs): CPU %, memory %, and network I/O **over + time**, **memory by app**, utilization **gauges** (uptime / memory / disk), + docker **storage & cache**, a **14-day incidents** bar chart, and per-app + uptime — plus a stat-tile row. The clickable **logo returns to the home view**. - **Control** — **Start all** / **Stop all**, or per-app **Start** / **Stop**. Stop leaves Caddy + the tunnel up so the panel stays reachable (only the CLI `roost down` tears down everything). diff --git a/cmd/roost/tunnel.go b/cmd/roost/tunnel.go index ce03f92..1857aa3 100644 --- a/cmd/roost/tunnel.go +++ b/cmd/roost/tunnel.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/cdrrazan/roost/internal/config" + "github.com/cdrrazan/roost/internal/generate" "github.com/cdrrazan/roost/internal/state" "github.com/cdrrazan/roost/internal/tunnel" ) @@ -192,6 +193,26 @@ func accessPatterns(plan []tunnel.PlannedRecord) []string { return patterns } +// workerRouteSpecs derives one Worker route per distinct zone in the plan, +// each a "*./*" wildcard so the fallback Worker fronts every app in +// that zone. One route per zone keeps the Worker independent of how many +// apps exist — the same reason DNS is one wildcard per suffix. +func workerRouteSpecs(plan []tunnel.PlannedRecord) []tunnel.WorkerRouteSpec { + seen := map[string]bool{} + var specs []tunnel.WorkerRouteSpec + for _, rec := range plan { + if seen[rec.Zone.ID] { + continue + } + seen[rec.Zone.ID] = true + specs = append(specs, tunnel.WorkerRouteSpec{ + ZoneID: rec.Zone.ID, + Pattern: "*." + rec.Zone.Name + "/*", + }) + } + return specs +} + // newTunnelCmd groups `tunnel setup` (create the tunnel, plan and // create every DNS record, push ingress, apply Access — the whole // remote side, no dashboard visit) and `tunnel access` (policies only). @@ -318,6 +339,22 @@ func newTunnelCmd(flags *rootFlags) *cobra.Command { return err } + if tc.cfg.Tunnel.MaintenancePage { + page, err := generate.RenderErrorPage() + if err != nil { + return err + } + worker, err := tunnel.EnsureWorker(tc.client, tc.accountID, page, workerRouteSpecs(plan)) + if err != nil { + return err + } + tc.st.Worker = worker + if err := tc.st.Save(tc.statePath); err != nil { + return err + } + cmd.Printf("maintenance Worker deployed (%d route(s)) — the edge serves roost's offline page when the tunnel is down\n", len(worker.Routes)) + } + if tc.cfg.Tunnel.Access != nil { created, err := tunnel.EnsureAccess(tc.client, tc.accountID, accessPatterns(plan), tc.cfg.Tunnel.Access.Emails) if err != nil { diff --git a/docs/configuration.md b/docs/configuration.md index 4cfe319..db1f5f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,6 +24,8 @@ include: # OPTIONAL glob(s) pulling apps from other files - apps/*.yml tunnel: name: roost # explicit; never generated by roost + protocol: http2 # OPTIONAL; force TCP/443 when your ISP drops UDP/QUIC + maintenance_page: true # OPTIONAL; edge Worker serves a branded page on 1033 access: # optional; omit to leave apps public (roost warns) emails: - me@example.com @@ -44,6 +46,11 @@ apps: [...] # see below — connected / reconnecting-after-wake / down), a per-app detail drawer (image, restarts, env keys, recent logs), an activity timeline, and a ⌘K command palette. It auto-refreshes every 5s. + - **Dashboard page** — a dedicated real-time monitoring view (sidebar → + **Dashboard**) that polls `/api/metrics` every 5s and draws inline-SVG + charts: CPU / memory / network over time, memory by app, utilization gauges + (uptime / memory / disk), docker storage & cache, a 14-day incidents chart, + and per-app uptime. - **Public status page** — the panel also serves a controls-free, secret-free board at **`/status`** (app name + operational/degraded/down + uptime + open incident detail), safe to share. It **auto-refreshes every 2 min** so a fresh @@ -87,6 +94,34 @@ apps: [...] # see below and the SSH login command. No effect on how roost runs — and the card only shows behind your Access-gated `control_host`. +## Offline / maintenance UI — two layers + +When something is down, visitors otherwise see raw error screens. roost replaces +both, matching the failure to who can still answer: + +- **An app container is down or unhealthy** (Caddy is up, its upstream isn't → + **502/503**). Caddy itself answers with a branded *"temporarily offline"* page. + This is always on — roost generates `error.html` and mounts it into Caddy; no + configuration needed. +- **The tunnel is wholly down** (cloudflared stopped, Docker stopped, or the box + is off → Cloudflare **error 1033**). Now *nothing on your host is reachable* — + Caddy can't answer, because the tunnel that carries traffic to it is gone. Only + Cloudflare's own edge can respond. Set **`tunnel.maintenance_page: true`** and + `roost tunnel setup` deploys a tiny **Cloudflare Worker** (named + `roost-maintenance`, one route — `*./*` — per routing suffix) that serves + the *same* branded page from the edge. While the stack is healthy the Worker is + invisible: it proxies every request straight through and only substitutes the + page on an origin-connectivity failure (a thrown fetch or a 502/503/504/52x/530). + +Both layers render the identical self-contained page (no external assets, dark/ +light aware, auto-retries every 30s), so the experience is the same whichever +layer catches the outage. `roost uninstall` removes the Worker and its routes +along with the tunnel; it only ever deletes what roost recorded creating. + +> Custom Error Pages in the Cloudflare dashboard would be the "native" fix for +> 1033, but Cloudflare gates the `1000_errors` class to **Enterprise** plans. The +> Worker approach works on the free plan. + ## Incident email alerts — `notify:` The `roost web` panel runs a background monitor (every 30s, even with no browser diff --git a/examples/full.yml b/examples/full.yml index 61c0e06..4017d4d 100644 --- a/examples/full.yml +++ b/examples/full.yml @@ -23,6 +23,11 @@ tunnel: # Always the literal default or your own value — roost never invents # tunnel names, so the Cloudflare dashboard stays recognizable. name: roost + # When the whole tunnel is down (cloudflared/Docker/box off), Cloudflare + # shows a bare 1033. Set this and `tunnel setup` deploys an edge Worker + # that serves roost's branded "temporarily offline" page instead. (A + # single app being down is handled by Caddy automatically — no config.) + maintenance_page: true # With access set, every routing suffix gets a Cloudflare Access # wall BEFORE the first `up`. Hostnames leak via Certificate # Transparency logs within hours; personal apps want this. diff --git a/fleet-dashboard/CHANGELOG.md b/fleet-dashboard/CHANGELOG.md index b5b127e..7392d8b 100644 --- a/fleet-dashboard/CHANGELOG.md +++ b/fleet-dashboard/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Fleet are documented here. Format: [Keep a Changelog](https://keepachangelog.com/); versioning: [SemVer](https://semver.org/). +## [1.7.0] — 2026-07-31 + +### Added +- **Offline / maintenance page** (`offline.html`) — a standalone branded page to + serve when a service is unreachable (proxy 502/503 or a downed tunnel). An + animated moonlit-harbour scene (a sailboat riding at anchor, drifting waves, + twinkling stars), an *At anchor* status badge, a spinner, and an auto-retry + every 30s (`location.reload`, honours `prefers-reduced-motion`). Fully themed + via the MD3 tokens — light + dark. Linked from Components → **Offline page**. + ## [1.6.0] — 2026-07-28 ### Added diff --git a/fleet-dashboard/components.html b/fleet-dashboard/components.html index 4f059bb..c22e06d 100644 --- a/fleet-dashboard/components.html +++ b/fleet-dashboard/components.html @@ -33,6 +33,7 @@ Incidents Status page ↗ + Offline page ↗ Components Settings diff --git a/fleet-dashboard/offline.html b/fleet-dashboard/offline.html new file mode 100644 index 0000000..7c5974f --- /dev/null +++ b/fleet-dashboard/offline.html @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + +At anchor · Fleet + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
At anchor
+

This service has dropped anchor

+

It's pulled into harbour for a moment — restarting or down for a spell + of maintenance. It'll set sail again on its own. No need to refresh.

+ +
Auto-checking every 30s · retry now
+ + +
+
+ + + diff --git a/internal/config/config.go b/internal/config/config.go index 1b7a35e..1d64a70 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,6 +82,11 @@ type Tunnel struct { // Set "http2" to force TCP/443 on networks that throttle or drop UDP // (many home/office ISPs), where QUIC flaps and every app 502s. Protocol string `yaml:"protocol"` + // MaintenancePage, when true, makes `tunnel setup` deploy a Cloudflare + // Worker that serves roost's branded "temporarily offline" page from the + // edge whenever the tunnel is wholly down (a 1033 that never reaches + // Caddy). Opt-in: it deploys a Worker + one route per routing suffix. + MaintenancePage bool `yaml:"maintenance_page"` } // Access is the optional Cloudflare Access policy configuration. diff --git a/internal/generate/generate.go b/internal/generate/generate.go index 93f7baf..7d5cda2 100644 --- a/internal/generate/generate.go +++ b/internal/generate/generate.go @@ -522,6 +522,14 @@ func RenderCaddyfile(apps []App, controlHost string) ([]byte, error) { return render("Caddyfile.tmpl", caddyData{Apps: routed, ControlHost: controlHost}) } +// RenderErrorPage renders the branded "temporarily offline" HTML. It is the +// single source of truth for the maintenance UI: Caddy serves it as a static +// file when an app upstream is down (502/503), and the edge Worker embeds the +// same bytes to answer a wholly-down tunnel (Cloudflare 1033). +func RenderErrorPage() ([]byte, error) { + return render("error.html.tmpl", nil) +} + // RenderMySQLInit renders mysql-init.sql: one database per mysql app, // all granted to the shared roost user. func RenderMySQLInit(apps []App) ([]byte, error) { @@ -693,6 +701,16 @@ func Generate(buildDir string, apps []App, opts Opts) ([]string, error) { return nil, err } + // The maintenance page Caddy serves for a downed upstream (mounted into + // the caddy service by compose.yml). + errPage, err := RenderErrorPage() + if err != nil { + return nil, err + } + if err := write("error.html", errPage); err != nil { + return nil, err + } + needs := map[string]bool{} for _, app := range apps { needs[app.Database] = true diff --git a/internal/generate/generate_test.go b/internal/generate/generate_test.go index f29519b..1e480d0 100644 --- a/internal/generate/generate_test.go +++ b/internal/generate/generate_test.go @@ -530,6 +530,70 @@ func TestRenderComposeVolumesCoexistWithSourceMount(t *testing.T) { } } +func TestRenderErrorPage(t *testing.T) { + out, err := RenderErrorPage() + if err != nil { + t.Fatalf("RenderErrorPage: %v", err) + } + s := string(out) + if !strings.HasPrefix(s, "") { + t.Errorf("error page is not HTML:\n%s", s[:min(120, len(s))]) + } + // No externally *loaded* assets — the page must render standalone (it is + // also embedded into the edge Worker where the origin is unreachable). + // Plain links are fine: they navigate on click, they aren't + // fetched to render the page. + for _, bad := range []string{"src=", " + + + + + +Gone to roost · temporarily offline + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + z + z + z + + + +
+ + + diff --git a/internal/state/state.go b/internal/state/state.go index 1096981..3f86428 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -37,6 +37,25 @@ type State struct { // retained (with their path + domain) so the panel can offer a one-click // re-add. A successful re-add clears the entry — see ClearRemoved. Removed []RemovedApp `json:"removed,omitempty"` + // Worker is the edge fallback Worker roost deployed, if any, so teardown + // can remove exactly the script and routes roost created. + Worker *Worker `json:"worker,omitempty"` +} + +// WorkerRoute is one Cloudflare Worker route roost created, kept so +// teardown removes only routes roost made. +type WorkerRoute struct { + ID string `json:"id"` + ZoneID string `json:"zone_id"` + Pattern string `json:"pattern,omitempty"` +} + +// Worker records the edge fallback Worker roost deployed — the branded +// maintenance page the Cloudflare edge serves when the tunnel is wholly +// down (a 1033 that never reaches Caddy). Nil when not deployed. +type Worker struct { + ScriptName string `json:"script_name"` + Routes []WorkerRoute `json:"routes,omitempty"` } // RemovedApp is an app the panel removed, kept so it can be re-added without diff --git a/internal/tunnel/client.go b/internal/tunnel/client.go index 4857209..f8cb8c0 100644 --- a/internal/tunnel/client.go +++ b/internal/tunnel/client.go @@ -69,13 +69,26 @@ type envelope struct { // do performs one API call, unwrapping Cloudflare's response envelope. func (c *Client) do(method, path string, body, out any) error { - reqBody := bytes.NewBuffer(nil) + var raw []byte if body != nil { data, err := json.Marshal(body) if err != nil { return fmt.Errorf("encode request: %w", err) } - reqBody = bytes.NewBuffer(data) + raw = data + } + return c.doRaw(method, path, "application/json", raw, out) +} + +// doRaw is do without JSON encoding of the body — for endpoints (Worker +// script upload) that take a raw payload under a non-JSON content type. +// The response is still Cloudflare's standard envelope. +func (c *Client) doRaw(method, path, contentType string, body []byte, out any) error { + var reqBody *bytes.Buffer + if body != nil { + reqBody = bytes.NewBuffer(body) + } else { + reqBody = bytes.NewBuffer(nil) } base := c.BaseURL if base == "" { @@ -86,7 +99,7 @@ func (c *Client) do(method, path string, body, out any) error { return fmt.Errorf("build request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.Token) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) httpClient := c.HTTP if httpClient == nil { diff --git a/internal/tunnel/teardown.go b/internal/tunnel/teardown.go index 97f6d98..7ccf935 100644 --- a/internal/tunnel/teardown.go +++ b/internal/tunnel/teardown.go @@ -27,6 +27,14 @@ func Teardown(client *Client, st *state.State, removeTunnel bool) error { } st.Records = kept + // The fallback Worker is edge infra created alongside the tunnel; remove + // it on a full uninstall, before the tunnel it fronts. + if removeTunnel { + if err := TeardownWorker(client, st); err != nil { + errs = append(errs, err) + } + } + if removeTunnel && st.TunnelID != "" { if err := client.DeleteTunnel(st.AccountID, st.TunnelID); err != nil { errs = append(errs, fmt.Errorf("tunnel %s: %w", st.TunnelName, err)) diff --git a/internal/tunnel/worker.go b/internal/tunnel/worker.go new file mode 100644 index 0000000..65047a3 --- /dev/null +++ b/internal/tunnel/worker.go @@ -0,0 +1,178 @@ +package tunnel + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/cdrrazan/roost/internal/state" +) + +// WorkerScriptName is the fixed name of the fallback Worker roost deploys. +const WorkerScriptName = "roost-maintenance" + +// WorkerRoute is one Cloudflare Worker route (a pattern bound to a script). +type WorkerRoute struct { + ID string `json:"id,omitempty"` + Pattern string `json:"pattern"` + Script string `json:"script"` +} + +// workerJS is the fallback Worker. It proxies every request to the origin +// and, only when the origin is unreachable (a thrown fetch, or a Cloudflare +// origin-connectivity status — 502/503/504 and the 52x/530 tunnel-down +// family that produces the 1033 page), answers with the branded page instead. +// A healthy response passes straight through, so the Worker is invisible while +// the stack is up. The page is injected as a JS string literal (%s). +const workerJS = `const PAGE = %s; +const OFFLINE = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]); +addEventListener("fetch", (event) => { event.respondWith(handle(event.request)); }); +async function handle(request) { + try { + const resp = await fetch(request); + if (OFFLINE.has(resp.status)) return offline(); + return resp; + } catch (e) { + return offline(); + } +} +function offline() { + return new Response(PAGE, { + status: 503, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "retry-after": "30", + }, + }); +} +` + +// BuildWorkerScript renders the fallback Worker with the given maintenance +// page embedded. The page is JSON-encoded so it becomes a valid, fully +// escaped JS string literal regardless of its contents. +func BuildWorkerScript(page []byte) string { + lit, _ := json.Marshal(string(page)) + return fmt.Sprintf(workerJS, lit) +} + +// PutWorkerScript uploads (creates or overwrites) a Worker script. The body +// is raw JS under application/javascript, not a JSON envelope. +func (c *Client) PutWorkerScript(accountID, name, script string) error { + path := "/accounts/" + accountID + "/workers/scripts/" + name + return c.doRaw(http.MethodPut, path, "application/javascript", []byte(script), nil) +} + +// DeleteWorkerScript removes a Worker script from the account. +func (c *Client) DeleteWorkerScript(accountID, name string) error { + return c.do(http.MethodDelete, "/accounts/"+accountID+"/workers/scripts/"+name, nil, nil) +} + +// ListWorkerRoutes lists the Worker routes in a zone. +func (c *Client) ListWorkerRoutes(zoneID string) ([]WorkerRoute, error) { + var routes []WorkerRoute + if err := c.do(http.MethodGet, "/zones/"+zoneID+"/workers/routes", nil, &routes); err != nil { + return nil, err + } + return routes, nil +} + +// CreateWorkerRoute binds a pattern to a script in a zone. +func (c *Client) CreateWorkerRoute(zoneID, pattern, script string) (WorkerRoute, error) { + var created WorkerRoute + body := map[string]string{"pattern": pattern, "script": script} + if err := c.do(http.MethodPost, "/zones/"+zoneID+"/workers/routes", body, &created); err != nil { + return WorkerRoute{}, err + } + created.Pattern = pattern + created.Script = script + return created, nil +} + +// UpdateWorkerRoute repoints an existing route at a script. +func (c *Client) UpdateWorkerRoute(zoneID, id, pattern, script string) error { + body := map[string]string{"pattern": pattern, "script": script} + return c.do(http.MethodPut, "/zones/"+zoneID+"/workers/routes/"+id, body, nil) +} + +// DeleteWorkerRoute removes a route from a zone. +func (c *Client) DeleteWorkerRoute(zoneID, id string) error { + return c.do(http.MethodDelete, "/zones/"+zoneID+"/workers/routes/"+id, nil, nil) +} + +// WorkerRouteSpec is a route to ensure: a pattern in a zone. +type WorkerRouteSpec struct { + ZoneID string + Pattern string +} + +// EnsureWorker uploads the fallback script and ensures a route for each spec, +// idempotently: an existing route with the same pattern is repointed at our +// script rather than duplicated (Cloudflare rejects a duplicate pattern). It +// returns the state.Worker the caller should persist so teardown can later +// remove exactly what was created. +func EnsureWorker(client *Client, accountID string, page []byte, specs []WorkerRouteSpec) (*state.Worker, error) { + script := BuildWorkerScript(page) + if err := client.PutWorkerScript(accountID, WorkerScriptName, script); err != nil { + return nil, fmt.Errorf("upload worker script: %w", err) + } + + worker := &state.Worker{ScriptName: WorkerScriptName} + for _, spec := range specs { + existing, err := client.ListWorkerRoutes(spec.ZoneID) + if err != nil { + return nil, fmt.Errorf("list worker routes: %w", err) + } + var found *WorkerRoute + for i := range existing { + if existing[i].Pattern == spec.Pattern { + found = &existing[i] + break + } + } + if found != nil { + if found.Script != WorkerScriptName { + if err := client.UpdateWorkerRoute(spec.ZoneID, found.ID, spec.Pattern, WorkerScriptName); err != nil { + return nil, fmt.Errorf("update worker route %s: %w", spec.Pattern, err) + } + } + worker.Routes = append(worker.Routes, state.WorkerRoute{ID: found.ID, ZoneID: spec.ZoneID, Pattern: spec.Pattern}) + continue + } + created, err := client.CreateWorkerRoute(spec.ZoneID, spec.Pattern, WorkerScriptName) + if err != nil { + return nil, fmt.Errorf("create worker route %s: %w", spec.Pattern, err) + } + worker.Routes = append(worker.Routes, state.WorkerRoute{ID: created.ID, ZoneID: spec.ZoneID, Pattern: spec.Pattern}) + } + return worker, nil +} + +// TeardownWorker removes the routes and script recorded in st.Worker, then +// clears it. Like Teardown it only touches what roost recorded; a route that +// fails to delete is kept so a retry re-attempts just the leftovers. +func TeardownWorker(client *Client, st *state.State) error { + if st.Worker == nil { + return nil + } + var kept []state.WorkerRoute + var errs []error + for _, r := range st.Worker.Routes { + if err := client.DeleteWorkerRoute(r.ZoneID, r.ID); err != nil { + errs = append(errs, fmt.Errorf("worker route %s: %w", r.Pattern, err)) + kept = append(kept, r) + } + } + if len(kept) > 0 { + st.Worker.Routes = kept + return errors.Join(errs...) + } + if err := client.DeleteWorkerScript(st.AccountID, st.Worker.ScriptName); err != nil { + errs = append(errs, fmt.Errorf("worker script %s: %w", st.Worker.ScriptName, err)) + st.Worker.Routes = nil + return errors.Join(errs...) + } + st.Worker = nil + return errors.Join(errs...) +} diff --git a/internal/tunnel/worker_test.go b/internal/tunnel/worker_test.go new file mode 100644 index 0000000..3fb73ca --- /dev/null +++ b/internal/tunnel/worker_test.go @@ -0,0 +1,162 @@ +package tunnel + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/cdrrazan/roost/internal/state" +) + +func TestBuildWorkerScriptEmbedsPageSafely(t *testing.T) { + // A page with characters that would break a naive JS literal. + page := []byte(`

down "now" ` + "`backtick`" + `

`) + script := BuildWorkerScript(page) + if !strings.Contains(script, "addEventListener") { + t.Fatalf("script missing worker body:\n%s", script) + } + // The embedded literal must be valid JSON (hence a valid JS string), + // so the raw backtick/quote/closing-script don't terminate it early. + start := strings.Index(script, "const PAGE = ") + if start < 0 { + t.Fatal("no PAGE literal") + } + rest := script[start+len("const PAGE = "):] + end := strings.Index(rest, ";\n") + if end < 0 { + t.Fatal("PAGE literal not terminated") + } + var decoded string + if err := json.Unmarshal([]byte(rest[:end]), &decoded); err != nil { + t.Fatalf("PAGE is not a valid string literal: %v", err) + } + if decoded != string(page) { + t.Errorf("round-trip mismatch:\n got %q\nwant %q", decoded, page) + } +} + +func TestEnsureWorkerUploadsAndRoutes(t *testing.T) { + f := newFakeCF(t) + var uploaded string + f.mux.HandleFunc("PUT /accounts/acc1/workers/scripts/roost-maintenance", func(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); ct != "application/javascript" { + t.Errorf("script upload content-type = %q, want application/javascript", ct) + } + b, _ := io.ReadAll(r.Body) + uploaded = string(b) + reply(w, map[string]any{"id": "roost-maintenance"}) + }) + // No existing routes → a create happens. + f.mux.HandleFunc("GET /zones/z1/workers/routes", func(w http.ResponseWriter, r *http.Request) { + reply(w, []WorkerRoute{}) + }) + f.mux.HandleFunc("POST /zones/z1/workers/routes", func(w http.ResponseWriter, r *http.Request) { + var got map[string]string + _ = json.NewDecoder(r.Body).Decode(&got) + if got["pattern"] != "*.example.com/*" || got["script"] != "roost-maintenance" { + t.Errorf("route create body = %+v", got) + } + reply(w, map[string]string{"id": "route-1"}) + }) + + worker, err := EnsureWorker(f.client(), "acc1", + []byte("offline"), + []WorkerRouteSpec{{ZoneID: "z1", Pattern: "*.example.com/*"}}) + if err != nil { + t.Fatalf("EnsureWorker: %v", err) + } + if !strings.Contains(uploaded, "addEventListener") { + t.Errorf("uploaded script missing worker body") + } + if worker.ScriptName != "roost-maintenance" { + t.Errorf("script name = %q", worker.ScriptName) + } + if len(worker.Routes) != 1 || worker.Routes[0].ID != "route-1" || worker.Routes[0].ZoneID != "z1" { + t.Errorf("routes = %+v", worker.Routes) + } +} + +func TestEnsureWorkerRepointsExistingRoute(t *testing.T) { + f := newFakeCF(t) + f.mux.HandleFunc("PUT /accounts/acc1/workers/scripts/roost-maintenance", func(w http.ResponseWriter, r *http.Request) { + reply(w, map[string]any{"id": "roost-maintenance"}) + }) + // A route with our pattern already exists but points at another script: + // EnsureWorker must UPDATE it, never POST a duplicate (CF rejects dupes). + f.mux.HandleFunc("GET /zones/z1/workers/routes", func(w http.ResponseWriter, r *http.Request) { + reply(w, []WorkerRoute{{ID: "old", Pattern: "*.example.com/*", Script: "someone-else"}}) + }) + updated := false + f.mux.HandleFunc("PUT /zones/z1/workers/routes/old", func(w http.ResponseWriter, r *http.Request) { + updated = true + reply(w, map[string]string{"id": "old"}) + }) + f.mux.HandleFunc("POST /zones/z1/workers/routes", func(w http.ResponseWriter, r *http.Request) { + t.Error("must not POST a duplicate route") + replyError(w, http.StatusBadRequest, 10020, "duplicate route") + }) + + worker, err := EnsureWorker(f.client(), "acc1", []byte("x"), + []WorkerRouteSpec{{ZoneID: "z1", Pattern: "*.example.com/*"}}) + if err != nil { + t.Fatalf("EnsureWorker: %v", err) + } + if !updated { + t.Error("existing route was not repointed") + } + if len(worker.Routes) != 1 || worker.Routes[0].ID != "old" { + t.Errorf("routes = %+v", worker.Routes) + } +} + +func TestTeardownWorkerRemovesRoutesThenScript(t *testing.T) { + f := newFakeCF(t) + f.mux.HandleFunc("DELETE /zones/z1/workers/routes/route-1", func(w http.ResponseWriter, r *http.Request) { + reply(w, map[string]string{"id": "route-1"}) + }) + f.mux.HandleFunc("DELETE /accounts/acc1/workers/scripts/roost-maintenance", func(w http.ResponseWriter, r *http.Request) { + reply(w, map[string]string{"id": "roost-maintenance"}) + }) + + st := &state.State{ + AccountID: "acc1", + Worker: &state.Worker{ + ScriptName: "roost-maintenance", + Routes: []state.WorkerRoute{{ID: "route-1", ZoneID: "z1", Pattern: "*.example.com/*"}}, + }, + } + if err := TeardownWorker(f.client(), st); err != nil { + t.Fatalf("TeardownWorker: %v", err) + } + if st.Worker != nil { + t.Errorf("worker not cleared: %+v", st.Worker) + } +} + +func TestTeardownWorkerKeepsScriptWhenRouteFails(t *testing.T) { + f := newFakeCF(t) + f.mux.HandleFunc("DELETE /zones/z1/workers/routes/route-1", func(w http.ResponseWriter, r *http.Request) { + replyError(w, http.StatusInternalServerError, 1000, "boom") + }) + f.mux.HandleFunc("DELETE /accounts/acc1/workers/scripts/roost-maintenance", func(w http.ResponseWriter, r *http.Request) { + t.Error("must not delete the script while a route survives") + reply(w, map[string]string{}) + }) + + st := &state.State{ + AccountID: "acc1", + Worker: &state.Worker{ + ScriptName: "roost-maintenance", + Routes: []state.WorkerRoute{{ID: "route-1", ZoneID: "z1", Pattern: "*.example.com/*"}}, + }, + } + err := TeardownWorker(f.client(), st) + if err == nil { + t.Fatal("want error naming the failed route") + } + if st.Worker == nil || len(st.Worker.Routes) != 1 { + t.Errorf("failed route must be kept for retry: %+v", st.Worker) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index ca21130..b328580 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -336,6 +336,12 @@ type Server struct { trendMu sync.Mutex trend map[string][]float64 + // hist is the aggregate metrics time-series the Dashboard charts read + // (most recent last, capped). Sampled on each /api/metrics poll. In-memory + // only — it lives for the panel process's lifetime, like trend. + histMu sync.Mutex + hist []metricSample + // events is a rolling in-memory audit log of panel actions (most recent // first, capped), rendered as the activity timeline. Guarded by mu. events []event @@ -735,6 +741,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /deploy", s.guard(s.handleDeploy)) mux.HandleFunc("POST /remove", s.guard(s.handleRemove)) mux.HandleFunc("POST /test-alert", s.guard(s.handleTestAlert)) + mux.HandleFunc("GET /dashboard", s.handleDashboardPage) + mux.HandleFunc("GET /api/metrics", s.handleMetricsAPI) mux.HandleFunc("GET /incidents", s.handleIncidentsPage) mux.HandleFunc("POST /incidents/read", s.guard(s.handleMarkRead)) mux.HandleFunc("GET /settings", s.handleSettingsPage) @@ -1428,6 +1436,180 @@ func buildAlerts(apps []runner.AppStatus) []Alert { return alerts } +// metricsCap bounds the aggregate time-series ring the Dashboard charts read. +// 240 samples at the 5s poll cadence is ~20 minutes of live history. +const metricsCap = 240 + +// metricSample is one point in the Dashboard's aggregate time-series. +type metricSample struct { + T time.Time + CPU float64 // summed CPU% across running apps + MemUsed float64 // summed bytes in use + MemCap float64 // summed memory caps + NetRx float64 // summed network read bytes + NetTx float64 // summed network write bytes + Running int + Total int +} + +// round1 rounds to one decimal without importing math. +func round1(f float64) float64 { return float64(int(f*10+0.5)) / 10 } + +// recordSample appends an aggregate sample to the ring, capping its length. +func (s *Server) recordSample(m metricSample) { + s.histMu.Lock() + defer s.histMu.Unlock() + s.hist = append(s.hist, m) + if len(s.hist) > metricsCap { + s.hist = s.hist[len(s.hist)-metricsCap:] + } +} + +// historyJSON renders the aggregate ring for the /api/metrics response. +func (s *Server) historyJSON() []map[string]any { + s.histMu.Lock() + defer s.histMu.Unlock() + out := make([]map[string]any, 0, len(s.hist)) + for _, m := range s.hist { + pct := 0 + if m.MemCap > 0 { + pct = int(m.MemUsed/m.MemCap*100 + 0.5) + } + out = append(out, map[string]any{ + "t": m.T.Format("15:04:05"), "cpu": round1(m.CPU), + "mem": m.MemUsed, "memPct": pct, "netRx": m.NetRx, "netTx": m.NetTx, + "running": m.Running, "total": m.Total, + }) + } + return out +} + +// handleDashboardPage renders the monitoring dashboard (charts) in the shared +// shell, mirroring the incidents page. +func (s *Server) handleDashboardPage(w http.ResponseWriter, _ *http.Request) { + s.renderPage(w, "metrics") +} + +// handleMetricsAPI is the Dashboard's real-time data feed: a JSON snapshot of +// aggregate + per-app metrics, host/system/edge facts, incidents, and the +// accumulated time-series. Read-only (no mutation), so it is unguarded like +// GET /api/app; the panel itself is loopback/Access-gated. +func (s *Server) handleMetricsAPI(w http.ResponseWriter, _ *http.Request) { + data := s.dashData() + now := time.Now() + payload := map[string]any{"ts": now.Format("15:04:05")} + + if data.statusErr != nil { + payload["dockerOK"] = false + payload["error"] = data.statusErr.Error() + } else { + var cpu, memU, memC, rx, tx float64 + running := 0 + apps := make([]map[string]any, 0, len(data.apps)) + for _, a := range data.apps { + c := parseCPU(a.CPU) + var mu, mc float64 + if u, cp, ok := parseMem(a.Memory); ok { + mu, mc = u, cp + } + var arx, atx float64 + if r, t, ok := parseMem(a.Net); ok { + arx, atx = r, t + } + if a.State == "running" { + running++ + cpu += c + memU += mu + memC += mc + rx += arx + tx += atx + } + apps = append(apps, map[string]any{ + "name": a.Name, "state": a.State, "health": a.Health, + "cpu": a.CPU, "cpuPct": c, "mem": a.Memory, "memUsed": mu, + "memPct": memPct(a.Memory), "net": a.Net, "up": a.Up, + "url": a.URL, "reachable": a.Reachable, "category": a.Category, + }) + } + total := len(data.apps) + s.recordSample(metricSample{T: now, CPU: cpu, MemUsed: memU, MemCap: memC, + NetRx: rx, NetTx: tx, Running: running, Total: total}) + memPctAgg := 0 + if memC > 0 { + memPctAgg = int(memU/memC*100 + 0.5) + } + payload["dockerOK"] = true + payload["aggregate"] = map[string]any{ + "cpuPct": round1(cpu), "memUsed": memU, "memCap": memC, + "memUsedH": humanBytes(memU), "memCapH": humanBytes(memC), "memPct": memPctAgg, + "netRx": rx, "netTx": tx, "netRxH": humanBytes(rx), "netTxH": humanBytes(tx), + "running": running, "total": total, + } + payload["apps"] = apps + } + + payload["server"] = map[string]any{ + "host": data.server.Host, "os": data.server.OS, "uptime": data.server.Uptime, + "cores": data.server.Cores, "ram": data.server.RAM, + "diskUsed": data.server.DiskUsed, "diskCap": data.server.DiskCap, "diskPct": data.server.DiskPct, + } + payload["system"] = map[string]any{ + "images": data.system.Images, "imagesSize": data.system.ImagesSize, + "containers": data.system.Containers, "volumes": data.system.Volumes, + "volumesSize": data.system.VolumesSize, "buildCache": data.system.BuildCache, + "reclaimable": data.system.Reclaimable, + } + payload["edge"] = map[string]any{ + "tunnelName": data.edge.TunnelName, "tunnelState": data.edge.TunnelState, + "protected": data.edge.Protected, "hosts": data.edge.Hosts, + } + payload["incidents"] = s.incidentsMetrics(now) + payload["history"] = s.historyJSON() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(payload) +} + +// incidentsMetrics summarizes incidents for the Dashboard: open/resolved counts, +// the recent list, and a 14-day opened-per-day bucket for the incidents chart. +func (s *Server) incidentsMetrics(now time.Time) map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + open, resolved := 0, 0 + recent := make([]map[string]any, 0, len(s.incidents)) + const days = 14 + buckets := make([]int, days) + today := now.Truncate(24 * time.Hour) + for _, in := range s.incidents { + label := "Control plane" + if in.App != "" { + label = humanize(in.App) + } + isOpen := in.Resolved.IsZero() + if isOpen { + open++ + } else { + resolved++ + } + ago := "resolved after " + compactDur(in.Resolved.Sub(in.Since)) + if isOpen { + ago = "down " + compactDur(now.Sub(in.Since)) + } + recent = append(recent, map[string]any{ + "label": label, "detail": in.Detail, "kind": in.Kind, "open": isOpen, "ago": ago, + }) + if d := int(today.Sub(in.Since.Truncate(24*time.Hour)) / (24 * time.Hour)); d >= 0 && d < days { + buckets[days-1-d]++ + } + } + series := make([]map[string]any, days) + for i := 0; i < days; i++ { + day := today.AddDate(0, 0, -(days - 1 - i)) + series[i] = map[string]any{"day": day.Format("Jan 2"), "count": buckets[i]} + } + return map[string]any{"open": open, "resolved": resolved, "recent": recent, "days": series} +} + // recordAndRenderTrends appends each app's current CPU sample to the ring and // returns a per-app inline sparkline SVG keyed by app name. func (s *Server) recordAndRenderTrends(apps []runner.AppStatus) map[string]template.HTML { @@ -1574,7 +1756,7 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ /* sidebar — fixed column, scrolls on its own */ .side{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:16px 12px;overflow:hidden} .brand{display:flex;align-items:center;gap:11px;padding:6px 8px 14px;flex:none} - .logo{width:36px;height:36px;border-radius:10px;box-shadow:var(--shadow);flex:none;overflow:hidden} + .logo{display:block;width:36px;height:36px;border-radius:10px;box-shadow:var(--shadow);flex:none;overflow:hidden} .logo svg{width:100%;height:100%;display:block} .brand .bt{font-size:15.5px;font-weight:700;letter-spacing:-.2px} .brand .bs{font-size:12px;color:var(--faint)} @@ -2047,12 +2229,13 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{