diff --git a/README.md b/README.md index 5587a68..65280ec 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,55 @@ Once published: `go install github.com/cdrrazan/roost/cmd/roost@latest`. --- +## 🏠 Where to run it β€” laptop, server, or both + +roost is a Go binary driving Docker, so it runs anywhere Docker does. Three shapes: + +**On your laptop (default).** Follow the quickstart, then `roost enable` to bring +the stack up at every login. Apps are live while the machine is awake β€” see +*the honest part* above. + +**On an always-on server.** Install roost + Docker on the box, copy +`~/.roost/config.yml` and `~/.roost/credentials` over, run `roost up`, then +`roost enable` (plus `loginctl enable-linger ` on a headless Linux box so +the units start with no interactive login). The tunnel is **outbound** β€” no ports +to open, and **no DNS change** when the box's IP changes; Cloudflare finds it by +the tunnel token. Lighter option: keep roost on your laptop and run only the +containers on the box with `remote: ssh://user@box` in `config.yml`. + +**Both at once β€” a dev laptop *and* a prod box.** Two connectors sharing **one** +tunnel split traffic between them (a request randomly hits whichever answers +first β†’ intermittent 502s). To run both machines simultaneously, give each its +**own tunnel** and its **own hostnames**: + +```yaml +# Prod box β€” ~/.roost/config.yml +tunnel: + name: rserver +# apps resolve to everest.example.com +``` + +```yaml +# Dev laptop β€” ~/.roost/config.yml +tunnel: + name: rserver-local +# same apps, but everest-local.example.com +``` + +The two tunnels are independent, so **both stay live with zero conflict**: +`everest.example.com` is your always-on prod copy, `everest-local.example.com` +is the one you hack on. Point each hostname's DNS at its own tunnel (a wildcard +per suffix for prod; the `-local` names get their own records for the dev tunnel +β€” exact records win over a wildcard). The one rule: **one cloudflared per +tunnel**. + +> **Data does not cross between the two.** Each environment has its own Docker +> volumes (its own Postgres/MySQL), on purpose. If an app ships its own sync +> (e.g. a notes app with a sync server), point each client at whichever +> environment you want β€” roost keeps the two stacks isolated. + +--- + ## πŸ–₯️ Web control panel β€” `roost web` `roost web` serves a small **dashboard** so you can run the whole fleet from a @@ -392,7 +441,9 @@ it does: **share** buttons (copy / X / LinkedIn / Facebook) that post a one-line status summary. A background monitor re-checks every app on a configurable interval (**default 2 min**) and opens an incident β€” with details β€” even with no browser - open. Optional **email alerts** (SMTP; the password comes from + open. An app you've since **removed from the config** is pruned from incident + tracking on the next check, so a deleted app never lingers as a permanently-open + "down" incident. Optional **email alerts** (SMTP; the password comes from `$ROOST_SMTP_PASSWORD`, never config); the sidebar keeps a **Test alert** button. Click any app for a **detail drawer** β€” image, restarts, env **key names**, that app's own **incident history**, and a recent-log tail. The public @@ -405,7 +456,10 @@ it does: and **tech-stack label overrides** (`rails=Ruby on Rails`). - **Comfort** β€” a **Material Design 3** interface (tonal surfaces, ripples, elevated cards) in light / **dark**; search, **filter chips** with a friendly - empty state, **list / grid** views, a **⌘K command palette**, and fully + empty state, **list / grid** views with **drag-and-drop reordering** in grid + (grab a card and drop it into place β€” the order persists to `~/.roost/panel.json` + and is honoured in list view too; you can only reorder **within** a category, + never move an app to another one), a **⌘K command palette**, and fully mobile-responsive. **Exposing it.** Set the top-level `control_host:` in `config.yml` and roost @@ -710,6 +764,30 @@ the pull is fast-forward-only, a force-push or diverged branch surfaces as a failed deploy rather than a silent bad merge. +
+How do I keep roost itself in sync on my laptop and my box? + +Two moving parts β€” the roost **binary** and each app's **source**: + +- **The binary, locally.** After pulling this repo, `go install ./cmd/roost` + (installs to `~/go/bin`), or `go build -o roost ./cmd/roost && sudo install -m + 0755 roost /usr/local/bin/roost`. Restart `roost web` so the panel process picks + up the new binary. +- **The binary, on the box.** A merge to **`main`** triggers + [`.github/workflows/deploy-web.yml`](.github/workflows/deploy-web.yml): it builds + for the box's CPU arch, `scp`s the binary over a deploy key, installs it, and + restarts the `roost-web` systemd `--user` service. Nothing by hand β€” set the + `DEPLOY_SSH_KEY` / `DEPLOY_HOST` / `DEPLOY_USER` repo secrets once (see the + workflow header). So the loop is **commit to `develop` β†’ PR β†’ merge to `main`**, + and the box updates within a minute or two. +- **App source (not roost).** `roost deploy ` on the host does a `git pull + --ff-only` + rebuild of that one container β€” see the two entries above. + +Laptop and box are separate installs of the same tool; keeping them in step is +"`go install` here, merge-to-`main` there." Only `roost-web` is auto-restarted on +the box β€” a change to the running *stack* still needs a `roost up` / `roost deploy`. +
+
How do I keep it running after a reboot, with no one logged in? @@ -729,6 +807,9 @@ is the supervisor. - **[Examples](examples/)** β€” runnable configs from minimal to every-knob, plus a [demo with fake data](examples/demo/config.yml) and an [`include` walkthrough](examples/includes/). +- **[Runbook](docs/runbook.md)** β€” copy-paste developer & ops commands: git + workflow, syncing the binary local + box, adding/updating apps (incl. a forked + app with its own Dockerfile + Postgres), and common ops. - **[Website](https://roost.app.rsynk.com)** β€” one-page overview ([source](site/)). - **[Ops scripts](scripts/)** β€” running a fleet on an always-on box: an encrypted backup (DB dumps + `age`-encrypted secrets β†’ R2) and a one-shot bootstrap that diff --git a/docs/configuration.md b/docs/configuration.md index 6cbc4d0..95d8bb8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,7 +62,11 @@ apps: [...] # see below **tech-stack label overrides** (one `key=Label` per line, e.g. `rails=Ruby on Rails`). Saving rebuilds email delivery in place β€” no restart. `config.yml`'s `notify:` block still works as a fallback when the settings - page hasn't set an SMTP host. + page hasn't set an SMTP host. The panel also persists two things you set by + clicking, not typing: your **featured pins** (the star toggle, `featured:`) + and, in grid view, your **drag-and-drop card order** (`order:`, honoured in + list view too, reorderable within a category only). Both are written by the + panel β€” you don't edit `panel.json` by hand. - **Share status** β€” the Incidents page has copy / X / LinkedIn / Facebook buttons that post a one-line summary of the current status plus the `/status` link. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..03a23f7 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,165 @@ +# roost runbook β€” developer & ops notes + +Copy-paste commands, grouped by task. `` = app name, `` = host dir, +`` = git URL, `` = FQDN. Superuser for Postgres is **`roost`**. + +- [Git workflow](#git-workflow) +- [Sync the roost binary (local + box)](#sync-the-roost-binary-local--box) +- [Add an app](#add-an-app) +- [Update an app from GitHub](#update-an-app-from-github) +- [Forked app: own Dockerfile + Postgres](#forked-app-own-dockerfile--postgres) +- [Common ops](#common-ops) +- [Two environments (laptop dev + box prod)](#two-environments-laptop-dev--box-prod) + +--- + +## Git workflow + +```bash +git switch -c feat/x develop # never commit to main +# ...edit; TDD: failing test first... +go test ./... && gofmt -l . && go vet ./... # must be clean +git commit -m "feat(x): ..." # conventional commits +git push -u origin feat/x # PR -> merge to develop; then PR develop -> main +``` + +Keep a fork current with upstream: + +```bash +git remote add upstream # once +git fetch upstream +git switch main && git merge --ff-only upstream/main +git push origin main +``` + +## Sync the roost binary (local + box) + +```bash +# --- local --- +git pull --ff-only +go install ./cmd/roost # -> ~/go/bin/roost +# or system-wide: +go build -o roost ./cmd/roost && sudo install -m 0755 roost /usr/local/bin/roost +# macOS: relaunch `roost web` to pick it up + +# --- box (automatic) --- +# merging to `main` triggers .github/workflows/deploy-web.yml: +# build for box arch -> scp -> install -> restart roost-web +# nothing to run by hand once DEPLOY_SSH_KEY / DEPLOY_HOST / DEPLOY_USER secrets are set. +``` + +## Add an app + +```bash +# detected framework (rails|next|django|flask|laravel|node|static) +roost add --domain +roost up + +# clone a GitHub repo β€” roost owns the checkout under ~/.roost/sources/ +roost add --repo --name --domain +roost up + +roost list # resolved apps + URLs +roost detect # framework + the signal that triggered it +``` + +Panel: **Add app** form takes a GitHub URL *or* a host path (not both), gated by +`roost doctor`. + +## Update an app from GitHub + +```bash +roost deploy # git pull --ff-only + rebuild + restart just +# panel: app menu -> "Pull & redeploy" == same thing + +# manually-cloned fork (own Dockerfile, NOT added with --repo): +git -C pull --ff-only +cd ~/.roost/build && docker compose -p roost up -d --build +docker exec roost-caddy-1 caddy reload --config /etc/caddy/Caddyfile +``` + +## Forked app: own Dockerfile + Postgres + +The `memos` / `joplin` pattern β€” a stack roost doesn't detect and/or its own build. + +```bash +# 1. source on host (shallow clone is fine on a box) +git clone --depth 1 + +# 2. root Dockerfile β€” roost only detects a file literally named "Dockerfile" +cp /Dockerfile.server /Dockerfile # if the real build file is elsewhere +# build must be self-contained (whole app in-image). If a repo .dockerignore +# excludes a package you need, add /Dockerfile..dockerignore. + +# 3. app entry -> ~/.roost/apps/.yml +# framework: node # override skips detection; root Dockerfile builds it +# port:

# app's listen port (must bind 0.0.0.0) +# database: postgres +# migrate: false # app self-migrates on boot +# env: +roost generate + +# 4. Postgres role: auto-created ONLY on a fresh volume. +# Existing volume (any prior app) => create by hand with roost's exact line: +grep -A1 '' ~/.roost/build/postgres-init.sql +docker exec roost-postgres-1 psql -U roost -c "CREATE ROLE LOGIN CREATEDB PASSWORD 'rp_';" +docker exec roost-postgres-1 psql -U roost -c 'CREATE DATABASE "" OWNER ;' +# password is deterministic: rp_ + sha256("roost-pg:")[:24] +# -> copy it from postgres-init.sql so it matches DATABASE_URL + your env: + +# 5. build + start + route +cd ~/.roost/build && docker compose -p roost up -d --build +docker exec roost-caddy-1 caddy reload --config /etc/caddy/Caddyfile +``` + +Static front-end SPA (server URL set in-app, not baked): serve its `dist/` as a +`framework: static` app at its own host β€” no port, no db. If it calls the backend +cross-origin, the backend must send CORS for the SPA's origin. + +## Common ops + +```bash +# stack +roost up ; roost down ; roost status ; roost logs [] -f +roost start ; roost stop ; roost restart + +# rebuild ONE app's image (env/Dockerfile change) +cd ~/.roost/build && docker compose -p roost up -d --build + +# recreate ONE app WITHOUT rebuild (env-only change) +cd ~/.roost/build && docker compose -p roost up -d + +# caddy reload after a route change +docker exec roost-caddy-1 caddy reload --config /etc/caddy/Caddyfile + +# Postgres (superuser = roost) +docker exec roost-postgres-1 psql -U roost -tc "SELECT rolname FROM pg_roles;" +docker exec roost-postgres-1 psql -U roost -d -c '\dt' + +# panel: a category: change only shows after a restart (categories read at startup) +systemctl --user restart roost-web # Linux box + +# DNS / tunnel for the standard (wildcard) case +roost tunnel setup # tunnel + all DNS records via API + +# disk (box) +df -h / ; docker system df ; docker builder prune -f +``` + +## Two environments (laptop dev + box prod) + +Run both at once β€” **separate tunnel + hostnames per machine**, never two +connectors on one tunnel. + +```text +box ~/.roost/config.yml : tunnel.name rserver apps -> app.example.com +mac ~/.roost/config.yml : tunnel.name rserver-local apps -> app-local.example.com +``` + +```bash +ssh -i ~/.ssh/oracle-roost ubuntu@ # reach the box +``` + +Rule: **one cloudflared per tunnel**. Each env has isolated Docker volumes (its +own Postgres/MySQL) β€” data does not cross; use an app's own sync if you need it. +See [README β†’ Where to run it](../README.md#-where-to-run-it--laptop-server-or-both). diff --git a/internal/web/server.go b/internal/web/server.go index 65e08ce..ca21130 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -144,18 +144,41 @@ type appGroup struct { } // groupApps buckets apps into Main apps / Utilities / Workers by category, -// preserving input order within each bucket and omitting empty buckets. An -// unknown or empty category falls back to Main apps. -func groupApps(apps []runner.AppStatus) []appGroup { +// omitting empty buckets. An unknown or empty category falls back to Main apps. +// Within each bucket apps are sorted by their position in order (the user's +// drag-and-drop ordering); apps not listed in order keep their input order and +// sort after the ordered ones. Because bucketing is by category, order can only +// rearrange apps within a category β€” never move one to another category. +func groupApps(apps []runner.AppStatus, order []string) []appGroup { + rank := make(map[string]int, len(order)) + for i, n := range order { + if _, ok := rank[n]; !ok { + rank[n] = i + } + } buckets := map[string][]runner.AppStatus{} for _, a := range apps { - buckets[groupTitle(a.Category)] = append(buckets[groupTitle(a.Category)], a) + t := groupTitle(a.Category) + buckets[t] = append(buckets[t], a) } var out []appGroup for _, title := range []string{"Main apps", "Utilities", "Workers"} { - if apps := buckets[title]; len(apps) > 0 { - out = append(out, appGroup{Title: title, Apps: apps}) + bucket := buckets[title] + if len(bucket) == 0 { + continue } + sort.SliceStable(bucket, func(i, j int) bool { + ri, oki := rank[bucket[i].Name] + rj, okj := rank[bucket[j].Name] + if oki && okj { + return ri < rj + } + if oki != okj { + return oki // a ranked app sorts before an unranked one + } + return false // both unranked: stable keeps input order + }) + out = append(out, appGroup{Title: title, Apps: bucket}) } return out } @@ -499,6 +522,14 @@ func (s *Server) checkIncidents() { "Docker is reachable again.\n\n" + now.Format(time.RFC1123) + link}) } } + present := make(map[string]bool, len(apps)) + for _, a := range apps { + present[a.Name] = true + } + // An app dropped from the config no longer appears in Status(); prune + // its stale health + any open/closed incidents so a removed app can't + // linger as a permanently-"down" incident (it never recovers to resolve). + s.pruneAbsent(present) for _, a := range apps { if a.Worker { continue @@ -534,6 +565,24 @@ func (s *Server) checkIncidents() { } } +// pruneAbsent drops health tracking and incident history for apps not in the +// present set (i.e. removed from the config). Control-plane incidents (App == "") +// are always kept. Must be called under s.mu. +func (s *Server) pruneAbsent(present map[string]bool) { + for name := range s.health { + if !present[name] { + delete(s.health, name) + } + } + kept := s.incidents[:0] + for _, in := range s.incidents { + if in.App == "" || present[in.App] { + kept = append(kept, in) + } + } + s.incidents = kept +} + // openIncident records a new open incident for app+kind, unless one is already // open for that app (dedup). Newest first, capped. func (s *Server) openIncident(app, kind, detail string, at time.Time) { @@ -681,6 +730,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /app/up", s.guard(s.handleAppAction("starting", s.ctrl.StartApp))) mux.HandleFunc("POST /app/down", s.guard(s.handleAppAction("stopping", s.ctrl.StopApp))) mux.HandleFunc("POST /app/featured", s.guard(s.handleToggleFeatured)) + mux.HandleFunc("POST /order", s.guard(s.handleReorder)) mux.HandleFunc("POST /add", s.guard(s.handleAdd)) mux.HandleFunc("POST /deploy", s.guard(s.handleDeploy)) mux.HandleFunc("POST /remove", s.guard(s.handleRemove)) @@ -834,6 +884,39 @@ func (s *Server) handleToggleFeatured(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } +// handleReorder persists the user's drag-and-drop app ordering. Guarded β€” it +// mutates persisted settings. The body carries a comma-separated `order` of app +// names (grid-mode DOM order). Category grouping stays authoritative, so this +// only affects within-category order; it can never move an app to another +// category. Replies 204 (the client re-renders from its own state; no redirect). +func (s *Server) handleReorder(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + var order []string + for _, n := range strings.Split(r.FormValue("order"), ",") { + if n = strings.TrimSpace(n); n != "" { + order = append(order, n) + } + } + s.mu.Lock() + cur := s.settings + cur.Order = order + cur = cur.Normalize() + s.settings = cur + store := s.store + s.mu.Unlock() + + if store != nil { + if err := store.Save(cur); err != nil { + http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + // handleSettingsPage renders the settings form in the panel shell. func (s *Server) handleSettingsPage(w http.ResponseWriter, _ *http.Request) { s.renderPage(w, "settings") @@ -1238,7 +1321,7 @@ func (s *Server) buildStatusView() statusView { apps := data.apps view.DockerOK = true view.Apps = apps - view.Groups = groupApps(apps) + view.Groups = groupApps(apps, view.Settings.Order) view.Total = len(apps) var used, capacity float64 for _, a := range apps { @@ -1421,7 +1504,7 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ roost control - + @@ -1662,6 +1745,9 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ .glist.grid .srv{border:1px solid var(--line);border-radius:14px;padding:18px;gap:14px;background:var(--panel2)} .glist.grid .grouphdr{padding-left:4px} .glist.grid .srv-top{flex-wrap:wrap;align-items:center} + .glist.grid .srv[draggable="true"]{cursor:grab} + .glist.grid .srv.dragging{opacity:.45;cursor:grabbing} + .glist.grid .srv.dragging *{pointer-events:none} .glist.grid .srv-idb{flex:1 1 55%} .glist.grid .srv-acts{flex-basis:100%;justify-content:flex-start;margin-top:2px} .srv-top{display:flex;align-items:flex-start;gap:12px} @@ -2359,6 +2445,8 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ function apply(v){ document.querySelectorAll(".glist").forEach(function(e){e.classList.toggle("grid",v==="grid")}); document.querySelectorAll("[data-view]").forEach(function(b){b.classList.toggle("active",b.dataset.view===v)}); + // Cards are only draggable in grid mode (reorder is a grid affordance). + document.querySelectorAll(".glist .srv").forEach(function(s){s.draggable=(v==="grid")}); } apply(localStorage.getItem(KEY)||((window.__roostCfg&&window.__roostCfg.view==="grid")?"grid":"list")); document.querySelectorAll("[data-view]").forEach(function(b){ @@ -2546,6 +2634,41 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ .then(function(){refresh(true);}) .catch(function(){}); },true); + // Drag-and-drop reorder (grid mode). A card can only be dropped within its own + // category container (.glist) β€” never into another category. On drop the new + // global DOM order POSTs to /order and is persisted, so list mode respects it + // too. Listeners are delegated on document, so they survive the live refresh. + var dragEl=null, srcList=null; + document.addEventListener("dragstart",function(e){ + var s=e.target.closest(".srv"); if(!s||!s.closest(".glist.grid"))return; + // Don't hijack a drag that starts on an interactive control (buttons/links). + if(e.target.closest("a,button,input,summary")){e.preventDefault();return;} + dragEl=s; srcList=s.closest(".glist"); s.classList.add("dragging"); + e.dataTransfer.effectAllowed="move"; + try{e.dataTransfer.setData("text/plain",s.dataset.app||"");}catch(_){} + }); + document.addEventListener("dragover",function(e){ + if(!dragEl)return; + var list=e.target.closest(".glist"); if(!list||list!==srcList)return; // within-category only + e.preventDefault(); + var over=e.target.closest(".srv"); + if(!over||over===dragEl)return; + var b=over.getBoundingClientRect(); + // Insert before the hovered card when the cursor is in its upper/left half. + var before=e.clientYMaterial 3, comfortable

A Material Design 3 dashboard in light and dark: reachability chips (liveΒ·200 vs 502), a dedicated incidents page with optional email alerts, a ⌘K command - palette, filter chips, and list or grid views. Mobile-responsive β€” no - front-end to maintain.

+ palette, filter chips, and list or grid views β€” drag cards to reorder + in grid (persisted, and honoured in list, within each category). + Mobile-responsive β€” no front-end to maintain.

Yours, gated

@@ -397,6 +398,7 @@

Command reference

Full schema and hostname rules: configuration reference Β· + copy-paste commands: developer & ops runbook Β· runnable configs: examples, including a fully-populated demo.

@@ -597,6 +599,7 @@

Rajan Bhattarai

GitHub Examples Config reference + Runbook Roadmap Contributing Security