The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.
670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.
test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.
test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.
Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.
test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.
The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.
ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).
CI now runs check (ruff, pytest, tsc) and only builds if it passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
F1 — Any authenticated user could read any file the backend could see.
/api/files/read and /download hung on get_current_user, and the sandbox that
should have caught that was open by default: ALLOWED_BROWSE_ROOTS contained
"/", for which _is_allowed() waves through every path. So the `user` role could
download stackpilot.db (password hashes, agent tokens, backup credentials),
every stack's .env and every .secrets/* file — with no audit trail, because
only mutations were logged.
Implementing that turned up three more doors into the same room, all fixed
here since closing only the first would have made the fix cosmetic:
GET /api/stacks/{id} handed the .env to any user, /export tarred the whole
stack dir including .secrets/*, and both the agent file proxies and
/api/agents/{id}/stacks/{id} repeated the leak for every remote host. All 24
filesystem-touching routes are now admin-only; reads and downloads are audited
(listing is not — the Files page polls it). DATA_DIR is refused outright, since
the API deliberately masks agent tokens and destination secrets and the browser
would otherwise be the way around that. "/" is out of the default browse roots.
F2 — Backup destination credentials were plaintext JSON in the DB, which is
what made F1 worth exploiting. They are now Fernet-encrypted at rest behind
parse_config/dump_config, with existing rows migrated at startup.
This needed a prerequisite from F6: the key is derived from SECRET_KEY, which
was regenerated on every boot when unset. Encrypting against a key that changes
per restart would be worse than plaintext, so an auto-generated SECRET_KEY is
now persisted to ${DATA_DIR}/secret_key at mode 0600. Sessions surviving a
restart is a welcome side effect.
F3 — /api/audit is admin-only. Also hidden from the dashboard and the nav for
non-admins, so nobody polls into a 403.
F4 — uvicorn now runs with --proxy-headers, so nginx's X-Forwarded-For is
honoured. Without it request.client.host was the frontend container's IP for
every request, which made the login rate limit global instead of per-IP (10
failures locked out everyone) and filled the audit log's IP column with one
useless value.
Verified: encrypt/decrypt round-trip incl. plaintext passthrough, idempotent
re-encryption and wrong-key handling; sandbox denial for DATA_DIR, traversal
into it, and paths outside the roots, with the allowed roots still reachable.
Both against stubbed settings — there is no Docker here, so nothing was run
end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
The library shipped five templates. This adds 78 more, covering what
homelab lists and the self-hosted community actually run: media servers
and the *arr automation chain, DNS ad-blocking, reverse proxies, VPN,
SSO, monitoring and dashboards, files/backup, notes and wikis, home
automation, dev tooling, databases, local AI, finance and notifications.
Every template follows the existing shape — compose.yaml, .env.example,
template.json — with PUID/PGID/TZ/DATA_PATH/HTTP_PORT knobs and no
literal secrets: anything that must be set uses ${VAR:?...} so deploy
fails loudly instead of coming up with a default password. Six ship the
extra config file their app needs (prometheus.yml, Caddyfile,
mosquitto.conf, frigate config.yml, Authelia's two files,
zigbee2mqtt configuration.yaml), which the folder-copy pull already
carries into the new stack.
All 95 referenced images were verified pullable against their
registries. Default host ports were deconflicted so several templates
can be pulled side by side; the only remaining overlaps are between
services you would never run together anyway (two DNS blockers on 53,
three reverse proxies on 80/443).
The Templates page would have been an unusable 83-card grid, so it now
has a search box and tag filter chips, with the tags on each card
clickable to filter by.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
The env table derived its rows from the serialized text on every render,
and serialize() drops rows with an empty key — so a freshly added blank
row was discarded before it could be typed into. The rows are now owned
by the component and re-parsed only when `value` changes from outside,
with stable per-row ids so deleting a row no longer shifts the reveal
state onto its neighbour.
AppShell's <main> is content-height, so the editor page's `h-full`
collapsed to auto: Monaco and the raw .env textarea fell back to their
intrinsic size, the textarea to a two-row default. The page is now sized
against the viewport minus the top bar and page padding, so both editors
fill the screen, and the textarea gets min-h-0 so flex-1 can grow it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
The stacks-list bar sat below the name and grew the row when an action
started. It now runs inline to the right of the name and service count,
filling the space before the CPU column, so the row keeps its height.
Label, percentage and byte detail sit on that same line.
Also fixes the self-update prompt being cut off at the top. The top bar
is backdrop-blurred, and a non-none backdrop-filter makes an element the
containing block for `position: fixed` descendants — so the dialog
centred itself in the 60px header instead of the viewport and overflowed
off-screen. ConfirmDialog and the update overlay now render through a
portal on document.body. ConfirmDialog also scrolls itself rather than
its backdrop, which would otherwise strand its top edge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
Update ran as a blocking POST with nothing to show but a spinner, so the
status added in 86c67df could only sit above the table as a banner.
Adds /ws/update/{stack_id}, streaming `compose pull` then `up -d` with
--progress json, and feeds it through the existing DeployTracker — the
same weighting the deploy console uses. The result renders as a progress
bar inside the stack's own row: percentage, phase label, and layer/byte
detail. Non-streaming actions (start/stop/restart/pull/down) reuse the
bar in its indeterminate form, so every row action looks consistent.
compose_service gains _stream_phase, shared by stream_up and the new
stream_update; a failed pull short-circuits before `up`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
The status banner added in 9d28e12 only rendered on the stack detail
page, but Update is most often clicked from the stacks list — so in
practice the status was invisible. Render it on the stacks list and
dashboard too.
Actions on different stacks run concurrently from the list, so busy
state and status are now keyed by stack id instead of a single value:
previously the first action to finish cleared every row's spinner, and
each new action overwrote the previous one's status. StacksTable takes
an isBusy(id) predicate in place of the single busyId prop.
Also bumps the version so the newly version-tagged CI images (f8bfc91)
actually differ from the running release — self-update compares tags
against APP_VERSION, so shipping without a bump shows no update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
self_update_service compares registry version tags against APP_VERSION
to detect a newer release; with only :latest pushed, it always reported
"No version tags found" and the update pill never appeared. Read the
version from backend/version.py and push it as an extra tag for all
three images.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
Stack actions (start/stop/pull/update/…) now surface a dismissible
status banner on the stack detail page instead of relying on the
transient top-right toast alone. Also enable toast expand mode so
multiple notifications no longer collapse behind each other.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
Image references still pointed at the old server (10.10.6.10:3020/menzelj);
menzelj was never a valid namespace there either, the actual account is
menzeljonas. Also adds .gitea/workflows/ci.yml to build and push
backend, frontend and agent on push to main.
Uploading a backup extracted the tar straight into the NFS mount via
put_archive, and the daemon chowns every entry while extracting — an export
with root_squash refuses that ("failed to Lchown ... for UID 0, GID 0:
operation not permitted"), so the upload died with a docker 500 even though
plain writes to the share work (which is why the destination test passed).
The helper container now unpacks into its own filesystem and copies the file
into the mount with cat, which never chowns. Restores hit the same wall when a
volume or bind folder lives on a squashed mount, so import_path/import_volume
fall back to a copy-through-staging when (and only when) the failure is a chown
denial — local restores keep preserving ownership. NFS file names are validated
against the same safe charset as the subdir parts, since both are interpolated
into the helper's shell commands.
Verified against a real root_squash NFS export: test/upload/list/download/delete
round trip, byte-identical download, restore into an NFS-backed volume via the
fallback, and ownership still preserved (1000:1000, 0600) on a local volume.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shipped default (./data/stacks) guarantees the mismatch that hid stack data
from the file browser, the editor and (before 0.40.0) from backups: compose
resolves ./config against the container path, so the daemon creates the data
directories at /opt/stacks/<stack>/... on the host regardless of where
STACKS_HOST_DIR points. Same change for the agent, plus the reasoning in
.env.example and the README config table. Images unchanged (0.40.1).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_ensure_helper_image moved to stack_assets_service, but backup_destination_service
imports it lazily inside _nfs_run/_nfs_helper, so nothing failed at import time —
every NFS destination operation raised ImportError at runtime instead. The helper
is now a public ensure_helper_image() and the NFS helpers import it from its new
home. Verified: every services/ and routers/ module imports, and both NFS helper
paths run through to a Docker call instead of ImportError.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stack's real state lives in its bind-mounted config directories, and those
were never captured: the backup only tarred the stack folder as this container
sees it. When STACKS_HOST_DIR differs from the container's STACKS_DIR, compose
resolves ./config against the container path and the daemon creates it at that
path on the *host* — invisible here, so the archive held little more than
compose.yaml and .env.
New services/stack_assets_service.py inventories a stack's data (bind sources
merged from container mounts + the compose file, named volumes) and does all
data I/O through a throwaway helper container, i.e. by host path, so unseen
directories are captured anyway. It also detects the host/container stacks-path
mismatch and reports it.
- manifest v2: full inventory, per-asset capture result, skip reasons (v1 still
restores)
- NFS/CIFS-backed volumes are skipped by default and never wiped on restore
- deselected data inside the stack folder no longer sneaks in via compose/
- volume/bind archives stream through temp files instead of RAM
- restore preserves mode, ownership, mtime and symlinks, and writes bind folders
back to their host paths (rewritten when the stack is renamed)
- backup dialog shows the inventory with sizes and per-item checkboxes; restore
gained a "restore bind folders" toggle
- new GET /api/stacks/{id}/backup/inventory (+ agent + proxy), backup endpoints
take include_binds/binds/volumes, restore takes restore_binds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compose is now run with `--progress json` (probed once, falls back to the
plain text stream on older compose/agents). The console folds the event
stream into a weighted progress bar — download bytes per layer, then
container create/start — with a per-image bar and a byte/layer counter,
and renders the raw output one line per layer (updated in place) instead
of a wall of scrolling text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The amber image-update indicator is fed from update_service._CACHE, which
only the background loop refreshed — after a per-stack Update/Pull the
stale digests kept the pill on until the next pass. Now the local digests
are reconciled with the cached remote digests right after a successful
pull/update (local backend, agent lifecycle, auto-update pass), and the
frontend invalidates the stack-updates queries after actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_all never ALTERs an existing table, so installs predating the
backupschedule.agent_id column kept the old schema and any ORM query
naming it failed with "no such column" — which the new fleet dashboard
(and the schedules list / scheduler loop) hit. init_db now diffs each
mapped table against the live schema and ADD COLUMNs the missing
nullable/defaulted ones. Idempotent and self-healing for similar drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fleet endpoint returned a bare 500, so the error banner only showed
"status code 500" with no cause. Wrap the call to log the full traceback
server-side and return the exception type, message and originating
file:line in the HTTP detail, so the dashboard banner pinpoints the
failure for an authenticated user.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cockpit cards gated purely on `fleet.data`, so any failed
/api/dashboard/fleet request (e.g. a stale backend returning 404, or a
500) left the new components stuck on skeletons forever. Render a clear
error banner with the API message and a Retry button when the query
errors with no data, so the actual cause is visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the analytics-style dashboard (stack-health funnel, uptime %,
operations/day grid, AI pill) with an attention-driven fleet cockpit:
- New /api/dashboard/fleet endpoint: server-side fan-out across the local
host and every agent into one payload — a prioritized "needs attention"
list, headline KPIs, an honest stack-status breakdown and a per-host
resource rollup. Each agent uses its own DB session so the fan-out is
concurrency-safe; failures degrade to "offline" instead of stalling.
- New frontend: AttentionStrip, FleetKpiRow, StackStatusBar and
HostResourceTable; Dashboard.tsx rewritten around them.
- Remove the funnel/summary endpoints, the uptime sampler loop and the
ops-activity machinery; delete the now-unused chart components.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folder upload still reported as doing nothing, and without browser access
the failure point is invisible. Make every outcome visible on-screen:
- onChange: if the folder picker returns 0 files, toast an error; otherwise
toast "Starting folder upload: N file(s)…" so it's clear the upload fired
(independent of the progress bar rendering).
- Per-file failures are no longer swallowed: capture the first error and
show it in the result toast ("Uploaded X, Y failed — <path>: <reason>").
This pinpoints whether the picker returns nothing, the upload never starts,
or the requests fail (and why). Frontend-only; all 3 images pushed 0.37.7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uploaded dotfiles (.env) were persisting fine, but "Show hidden" is
component state that reset to off on every reload — so after refreshing,
hidden files disappeared from view and looked lost. Persist the toggle in
localStorage (sp.files.showHidden) so it survives reloads; combined with
0.37.5's auto-reveal, an uploaded .env now stays visible.
Frontend-only; all 3 images rebuilt+pushed 0.37.6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reported "upload doesn't work, file never appears" was two things, both
hit when uploading config files like .env:
1. Single-file upload used overwrite=false and dead-ended on "Already
exists: .env — rename or remove the existing file first." with no way to
replace the file. Now a conflict opens an Overwrite confirmation dialog
(mirroring the copy/paste conflict flow) that retries with overwrite=true.
2. .env (and any dotfile) is hidden, so even a successful upload stayed
invisible unless "Show hidden" was on. After an upload whose name/path
has a dot-segment, "Show hidden" is now auto-enabled so the file shows.
The single-file upload mutation now takes {file, overwrite}; folder upload
(already overwrite=true) also auto-reveals hidden results.
Frontend-only; all 3 images rebuilt+pushed 0.37.5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Upload folder" silently did nothing: the directory-selection attribute
was set on the hidden <input> via a JSX spread
({...{webkitdirectory:"", directory:""}}), which React doesn't reliably
apply to the DOM — and if isAdmin resolves after first render, a one-shot
effect would miss the input mounting entirely. Without the attribute the
picker is a plain file picker where no folder can be selected, so the user
picks nothing and nothing happens.
- Set webkitdirectory/directory/mozdirectory imperatively through a
callback ref, which runs whenever the input mounts. folderInput is now a
MutableRefObject so the callback can populate it.
- Folder upload now shows the progress bar immediately on start (small
files can finish before the browser emits any upload-progress event, so
don't wait for the first one to render feedback).
Frontend-only; all 3 images rebuilt+pushed 0.37.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewed the upload path. Single-file and folder uploads already drove the
progress bar, but folder progress was file-COUNT based ((i + filePct)/total),
which jumps around when a folder mixes tiny files with large ones and gives
no sense of total size.
- Folder upload: progress is now byte-weighted (sum of all file sizes), so
the bar tracks real transfer. Added a detail line "<i> / <n> files ·
<sent> / <total>" and the bar shows the current file name.
- Single file: added the same byte detail ("<sent> / <size>").
- Progress component gained an optional detail sub-line (shared by the
download bar too).
Frontend-only; all 3 images rebuilt+pushed 0.37.3 for tag consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A big folder hit a 504 Gateway Timeout: the zip was built into a temp
file *before* any response was sent, so for large folders the backend
stayed silent past nginx's proxy_read_timeout.
Now the zip is streamed as it's built, end to end:
- file_service.open_archive() returns (filename, byte iterator); _iter_zip
walks the dir and yields zip bytes incrementally via a small drain
buffer, writing each file in 1 MiB chunks (bounded memory, valid CRCs).
Same hardening as before — only real regular files; FIFOs/sockets/
devices/symlinks skipped without open(); per-file read errors skipped.
- /api/files/download and /agent/files/download return a StreamingResponse
(no temp file). The agent proxy streams the agent response straight
through (agent_service.stream_download), pulling the first chunk eagerly
so an offline/bad-token agent still yields a clean status before 200.
- Files page: streamed downloads have no Content-Length, so the progress
bar shows the running downloaded byte count ("Downloading … 12.3 MB")
instead of a percentage, after the initial "Preparing …".
Verified end to end via TestClient (200, application/zip, valid zip,
2 MiB file intact, FIFO skipped, no hang).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues with the 0.37.0 folder zip-download:
1. Hang / server error (reported as 501) on "some folders". archive_dir
tried to zip every entry, including non-regular files. Opening a FIFO
blocks forever (no writer); a unix socket / unreadable file raised an
OSError that aborted the whole archive. Now only real regular files are
zipped — FIFOs, sockets, devices and symlinks are skipped without ever
open()-ing them, and a per-file read error skips just that file instead
of failing the download.
2. No feedback while a large folder is being prepared. The zip is built
server-side before any bytes flow, so the click felt dead. The Files
page now shows an indeterminate "Preparing <name>…" bar from click,
switching to a real percentage during the transfer (Content-Length is
known for the finished zip). filesApi.download forwards onDownloadProgress.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file browser/editor could only download individual files. Add a
recursive directory download that streams the folder as a zip archive,
on the local host and on every remote agent.
- file_service.archive_dir(): zip a directory recursively into a temp
file, preserving the folder name as the archive root and empty
subdirectories; symlinks are skipped (no sandbox escape / loops).
- /api/files/download and /agent/files/download branch on directories
and return application/zip, cleaning up the temp file afterwards.
- Files page: show the download button for folders too (as <name>.zip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add frontend/public/favicon.svg (the TopNav LogoMark glyph as a
standalone SVG) and link it from index.html so the StackPilot logo
shows in browser tabs. Vite copies public/ into dist on build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shows an amber "Update" pill next to a stack's status (and highlights the
inline Update button) when any of the stack's images has a newer digest in
the registry. Reuses the existing background image-update check — a new
update_service.stacks_update_summary() reads the cached digests in a single
container sweep (no extra registry calls), exposed as GET /api/stacks/updates
and proxied per agent at GET /api/agents/{id}/stacks/updates. The Stacks page
and each remote-host section poll it every 60s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an inline "Update (pull latest images & recreate)" action to each
row of the stacks table, next to start/stop/restart/edit — for both the
local host and remote agents. Wires the existing updateImages action and
the agent "update" lifecycle action through StacksTable's new onUpdate prop.
Also bumps backend/version.py to 0.35.0 so it tracks the frontend version
again (it had drifted to 0.33.0 while package.json moved to 0.34.x).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The whole-stack log stream sends service:null on every line, so the
container filter dropdown only ever showed "All containers". docker compose
logs already prefixes each line with the container name (and an RFC3339
timestamp via --timestamps); parse that prefix client-side to recover the
container, populate the filter, and render time + container + message
separately (cleaner than the raw prefixed line).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frontend-only release. Overhauls the stack Logs tab:
- Fix the log panel growing down the page (AppShell <main> has no definite
height, so the page h-full/flex-1 chain collapsed to auto): the scroll
area now uses a fixed h-[65vh] instead of flex-1.
- Filter by container (service <select>) plus a free-text search; the line
count shows filtered / total.
- Dozzle-style per-line severity coloring (error/warn/debug via regex).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New destination type 'nfs' alongside SFTP and S3. The StackPilot container
needs no mount privileges: the Docker daemon mounts the export as a named
volume (stackpilot-nfs-dest-<id>, driver local/type nfs, recreated whenever
server/path/options change) and all file I/O runs through throwaway helper
containers (BACKUP_HELPER_IMAGE) — upload via put_archive, list via stat,
download via get_archive, delete/test via short-lived runs. Config: server,
export path, mount options (default rw), optional subdirectory (sanitized;
shell-safe charset). Mount failures surface as clean destination errors.
Settings UI gains the NFS form + summary; works everywhere destinations are
used (push, restore-from, scheduled backups incl. retention).
Verified live against a real kernel NFS server: test, push (file on the
export), list, restore-from incl. volume data, remote delete, config change
recreates the mount volume, unreachable server fails cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- backup_filename() crashed with NameError (bare now()) since 0.8.0 —
broke every scheduled backup at the upload step, agent backup download
and the central remote-backup/push endpoints. The local manual path
worked only because the router had its own copy (now an alias).
- restore: the manifest stack_id from an uploaded backup is now slugified
too — a crafted '../../...' id could previously escape STACKS_DIR.
- create_backup no longer starts a previously-stopped stack (stop/restart
only when the stack was actually running).
- overwrite-restore wipes the existing volume contents before extracting,
so files created since the backup no longer survive underneath it.
Verified end-to-end: full/config backup contents (compose, .env, .secrets,
bind dirs, extras, volume tars), delete→restore round-trip incl. volume
data, rename restore with volume re-prefixing, 409 conflict + overwrite,
traversal guard, scheduled run + retention prune + restore-from against
real MinIO, and the complete remote-agent cycle (download/push/restore).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- backend/version.py is now the single version source (main.py, agent).
- GET /api/system/update: reads the version tags of the backend's own image
repo (anonymous v2 token flow, https→http fallback for insecure
registries), compares the highest semver tag against APP_VERSION; reports
update_supported from the container's compose labels. 10 min cache.
- POST /api/system/update (admin, audited): spawns a detached helper
container from the current backend image that runs docker compose pull &&
up -d on StackPilot's own compose project (project name, working dir and
config files resolved from its own container labels) — the helper
outlives the backend being recreated. Non-compose installs get a 400.
- /api/health now returns the version so the UI can detect the switchover.
- TopNav version badge: queries the update status on page load; when a
newer release exists an amber pill shows the version — one click (admin)
confirms, triggers the update and overlays a wait screen that polls
/api/health and reloads once the new version answers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Container card now compares compose-only counts across hosts: agents
report compose_running in /agent/system (pre-0.31.1 agents fall back to
the all-containers number); card retitled, ResourceBar stat labelled
'Containers (all)'.
- Uptime is sampled every 5 min (background loop + opportunistic on read)
and charted as daily averages instead of a once-a-day snapshot; no
sample is written when no compose containers exist (was: fake 100%).
Legacy daily entries in uptime.jsonl still count; file pruned at startup.
- Funnel stage 'monitored' is now per-stack and real: stacks with an
enabled local auto-update policy (was: global webhook-exists toggle).
Frontend label renamed to 'Auto-managed'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Templates are now stack-shaped folders (compose.yaml + .env.example +
template.json) instead of DB rows + manifest.json + {{VAR}} rendering.
Pull copies the folder into a new stack; custom templates persist under
DATA_DIR/templates. Adds POST /api/templates/from-stack and a one-time
startup migration for pre-0.31 DB templates (drops the template table).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manage Docker secrets and configs per stack from a new Secrets tab on Stack/
RemoteStackDetail. Content is stored as files inside the stack dir
(.secrets/<name>, .configs/<name>; dir 0700 / file 0600) and referenced from the
compose file with relative `file:` paths, so the daemon reads them without any
HOST_ROOT_PREFIX dependency. Content is write-only — the API only ever returns
metadata (name, kind, size).
- secret_service: write/delete/list (metadata only)/exists/rel_path/attach/detach;
name validation rejects traversal/hidden/separators, content capped at 1 MiB.
- compose_edit_service: add/remove secret and config (top-level defs pruned when
no service still references them).
- routers/secrets.py (admin-only, audit secret.*) + agent endpoints + multi-host
proxy (audit agent.secret.*).
- Frontend SecretsPanel (create/list/delete + per-row attach/detach to a service;
config rows take a mount target), agentId-aware for remote stacks.
Verified: name-sandbox + perms + metadata-only listing unit-tested; compose
add/remove round-trips to clean YAML; py_compile + backend/agent/frontend image
builds + route smoke-test (local/agent/proxy). Live exec check (/run/secrets/<name>
on a deployed stack) and swarm path are hardware-verify debt (swarm dropped: A).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-stack auto-update policy on the stack Overview tab. When the background
image-update check finds a newer registry digest for one of a stack's images,
the stack is pulled + redeployed (or just flagged, "notify only"). Only running
stacks are auto-redeployed; a stopped stack is skipped, never silently started.
- models/auto_update.py: AutoUpdate(stack_id, agent_id, enabled, redeploy,
last_run/status/result) + schemas; registered in models/__init__.py.
- update_service: DB-free stack_images/stack_updates helpers (agent reuses
them); agent GET /agent/stacks/{id}/updates.
- services/auto_update_service.py: run_due/run_policy (local pull+up via
compose_service, remote via agent_service POST /agent/stacks/{id}/update,
notify-only with per-transition dedup); lazy-called from
update_service.background_loop. New stack_auto_updated notify event.
- routers: GET/PUT/run /api/stacks/{id}/auto-update and the
/api/agents/{id}/stacks/{sid}/auto-update variants (policy stored centrally).
- frontend: api/autoUpdate.ts + AutoUpdatePanel (enable, redeploy|notify-only,
Check now, last-run status) on StackDetail + RemoteStackDetail; EVENT_LABELS
gains stack_auto_updated + backup_failed.
Live-verified all four paths (updated / update-available / up-to-date /
skipped) against real compose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Interactive shell into a compose-managed container over WebSocket + xterm.js,
opened from the container card on the stack Overview tab. Admin-only (non-admin
handshake rejected with 4403); only containers with the compose project label
are reachable.
- backend services/exec_service.py: create/start/resize exec + a shared
bidirectional pump_exec (recv/sendall on sock._sock, executor thread,
resize control frames, exit-code frame).
- routers/ws.py: _authorize_admin + /ws/exec/{container_id} and the
/ws/agent-exec/{agent_id}/{container_id} proxy (forwards BOTH directions).
- agent_app.py: /agent/ws/exec/{container_id}.
- frontend: @xterm/xterm + @xterm/addon-fit; ContainerTerminal modal (shell
picker, fit/resize, exit/error handling) + a Terminal button on ContainerCard.
Live-verified (TestClient): local happy/exit/guard/4403/4401, agent happy/4401,
proxy bidirectional round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stack Overview now renders each service as an expandable ContainerCard with a
curated single-container inspect view and admin start/stop/restart buttons,
both for local stacks (GET/POST /api/containers/{id}[/{action}]) and remote
stacks (proxied via /api/agents/{id}/containers/* to the agent's new
/agent/containers/* endpoints). Only compose-managed containers are exposed.
Also bumps version 0.23.0 -> 0.26.0 (the bumps for the already-committed
Phase 18 image-prune / Phase 19 compose-validate were missed) and backfills
README sections for Phase 18/19/20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the live deploy console to remote/agent stacks. New agent WS endpoint
`/agent/ws/deploy/{stack_id}` runs `compose up -d` and streams its output; the
central app proxies it through `/ws/agent-deploy/{agent_id}/{stack_id}` (same
pattern + token URL-encoding as the agent-logs proxy) and records an
`agent.stack.start` audit entry. The editor's remote Deploy path now opens the
DeployConsole (agentId) instead of the blocking `agentsApi.action(start)`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploying a local stack from the editor now opens a console modal that streams
the `docker compose up -d` output (image pulls, container creation) live over a
new `/ws/deploy/{stack_id}` WebSocket, replacing the blind "Deploying…" spinner.
The compose subprocess keeps running server-side if the modal is closed early;
the same audit entry + start/error notification as the REST start path is recorded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stacks overview and detail were doing an N+1 inspect storm: list_stacks
called containers_for_stack AND compute_status (which re-fetched) per
stack, and containers.list(sparse=False) full-inspects every container
plus c.image triggered an image-inspect each. For N stacks that was
~2N*(1 list + M inspects + M image-inspects) sequential socket round
trips (~1s for just 2 stacks, growing linearly).
- compose_service.stack_status_summaries(): one low-level
api.containers(all=True) summary call grouped by compose project label
→ whole list served in a single Docker round-trip (~10x faster).
- compute_status() takes optional pre-fetched containers; get_stack and
_stack_summary no longer double-fetch.
- containers_for_stack() reads the image name from the inspect it already
has instead of c.image (drops the per-container image-inspect).
- Same batching applied to the agent's stack list/detail.
Also: Forgejo (registry + git) moved to 10.10.6.10:3020 — updated image
refs in docker-compose.yml, agent/Dockerfile, agent/docker-compose.yml.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
filesApi.upload now accepts an onProgress callback wired to axios
onUploadProgress; the Files page shows a progress bar with percentage
while uploading. Single-file upload tracks that file's bytes; folder
upload tracks overall progress across the N files (file i/N + current
file's bytes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>