A session token is the wrong credential for automation. It expires in an hour,
it is minted by typing a password, and revoking it signs every one of that
person's devices out. So automation gets its own credential, revocable on its
own, and showing up in the audit log as itself.
Three decisions worth recording, because each one is a place this could have
been built wrong.
**Only a hash is stored.** This is the opposite call from registry passwords one
release ago, and for a concrete reason: a registry password has to be handed
back to the registry, so it must be recoverable and is encrypted. A token is
only ever compared against, so it does not need to be — and not keeping it is
the difference between leaking the database and leaking everything the database
protects. It is shown once and cannot be recovered; a readable prefix is kept so
rows are still identifiable in the UI and the audit log. The hash is SHA-256,
deliberately not bcrypt: bcrypt is slow to make guessing low-entropy human
passwords expensive, and a token is 256 bits of secrets output, so the cost
would buy nothing and would land on every single API request.
**The scope is not folded into the User object.** get_current_user returns a
session-attached row; downgrading its role in place to represent a read-only
token would be written back to the database the next time anything committed
that user — logout-everywhere does exactly that. So the token row is stashed on
request.state and require_admin consults it, leaving the User untouched. The
same lookup caps a token at its owner's authority rather than trusting the scope
alone, so a demoted admin's token drops to read-only with them and a disabled
account's tokens stop working.
**A token cannot make itself permanent.** Creating tokens and creating users now
require a signed-in session, via a require_session dependency that rejects
token-authenticated requests. Without it, a leaked CI credential could mint a
second one and survive its own revocation — the failure mode where revoking the
leak does nothing. This is the one behaviour change for existing installs:
scripted user creation now needs a login.
The WebSocket routes still take JWTs only. They carry logs, the terminal and the
deploy console, which a CI job has no use for, and leaving them alone keeps the
token surface to the REST API.
19 tests, covering what is stored, that a read token really is read-only while
its owner is an admin, that demoting and disabling the owner both take effect,
expiry, tampering, the throttle on last-used writes, and that a token can
neither mint another nor create a user. Verified end to end against a running
app: two tokens, both scopes, revocation, and no plaintext anywhere in the
database or the list response.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was on the gap list as a missing feature, but it was a bug first. The
update checker asks the registry for a tag's digest over HTTP itself, and could
only do it anonymously. A private repository answers 401, remote_digest returned
None, and None already meant "could not reach registry" — so a private image was
indistinguishable from a network blip. The Images page showed nothing and a
stack pinned to a six-month-old image looked up to date indefinitely.
So AuthRequired is now its own exception, separate from unreachable, and the
error names the registry and which of the two problems it is: "ghcr.io needs
credentials" when there are none, "ghcr.io rejected the stored credentials" when
there are and they are wrong. Those are different fixes, and the message should
say which one you need. The plain unreachable message survives unchanged, with a
test pinning it, because not every failure is an auth failure.
Two consumers need the credentials and they need them in completely different
shapes, which is why this is its own service rather than a field on something
else. StackPilot's own checker wants (user, password) inside async code that has
no database session, so the rows are mirrored into an in-memory cache that
reload() refills on startup and after every write. The Docker CLI wants a
config.json, so reload() writes one into ${DATA_DIR}/docker and compose runs with
DOCKER_CONFIG pointed at it. Generating it from the database every time is what
makes deletion real: removing a registry in the UI revokes the CLI's login
instead of leaving a stale one in ~/.docker.
Host normalization is the join that makes any of it work, and it is easy to
underestimate. parse_ref only ever produces registry-1.docker.io, nobody types
that, and the CLI wants the whole thing under https://index.docker.io/v1/ — three
spellings of one registry across three layers. canonical_host settles on what
parse_ref produces, the config writer translates on the way out, and a bare
nginx:alpine finds credentials entered as "docker.io". Verified end to end:
typed as the v1 URL, stored as registry-1.docker.io, written as the v1 URL.
The password is encrypted at rest with the same key as backup destinations and
never leaves the server, not even masked — the API returns has_password, which
is all the form needs to offer "leave blank to keep". A row that cannot be
decrypted after a SECRET_KEY change is skipped with a warning rather than taking
every other registry down with it. Everything here is admin-only including the
reads, because even masked the rows say which registries this install talks to
and under what account.
The Test button asks the registry rather than validating a string, following the
Bearer challenge with credentials attached the way a real client does. Only an
outright 401 counts as wrong credentials; anything else means reachable and
talking, which is as much as a credentials check can honestly claim. Checked
against the live Docker Hub token endpoint with deliberately wrong credentials.
33 tests: the normalization table, the cache, the generated config.json down to
its 0600 mode and the Docker Hub key, encryption at rest, that no password field
appears in any response, and the 401-is-reported behaviour that started this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.51.0 gave every stack an icon, but a generic one: jellyfin got a clapperboard,
not the Jellyfin logo. Glyphs make a list readable; they do not make a stack
recognisable, which was the point. This resolves stacks against the selfh.st
icon catalog (~2900 self-hosted apps, the set Homarr and Homepage draw on), so
the row shows the thing people already recognise. All 83 bundled templates
resolve to their own logo.
The whole design question was *who* talks to the CDN. If the <img> points at
jsdelivr, then every client needs internet, every page load leaks the names of
somebody's stacks to a third party, and an air-gapped box gets nothing. So the
backend does it: the catalog on startup and weekly after, each logo once on
first use, both into ${DATA_DIR}/stack-icons/. Browsers keep reading icons from
the authenticated endpoint that already existed for uploads, and after the first
fetch the feature is fully offline. Logos are cached per *app*, not per stack —
verified: two stacks resolving to jellyfin produce one download.
Nothing here can fail loudly. Every entry point returns None rather than raising
when the network is absent, the catalog refresh is a task the lifespan does not
await, and an install with no outbound internet simply keeps 0.51.0's glyphs.
That fallback is also what covers a name the catalog does not know
("Mediaserver Wohnzimmer" is still a clapperboard), and the seconds after a
fresh install before the catalog lands. The glyph is derived even for stacks
that *do* have a logo, so an image that cannot be fetched degrades to something
meaningful instead of a box.
Matching gained a second source that turned out to matter more than expected:
the compose images. A stack called "medienserver" says nothing, but it pulls
lscr.io/linuxserver/jellyfin — strip the registry, the vendor and the tag and
the app is right there. Name first, then the longest run of words inside it,
then the images. It is deliberately cautious: a single word shorter than four
characters never claims a logo, because "web", "app" and "db" are all catalog
entries and a *wrong* logo is worse than a neutral glyph. A short alias table
covers what the catalog spells differently from Docker Hub (postgres →
postgresql, pihole → pi-hole, wg-easy → wireguard).
A slug arrives from the database and from query strings and then becomes a
filename, so it is pattern-checked before it is ever joined to a path, catalog
entries that are not slug-shaped are dropped on load, and a downloaded logo is
verified to start with the PNG magic bytes before being cached.
The picker searches the catalog too — pre-seeded with the stack's own name, so
opening it on "jellyfin" offers the Jellyfin logo first — which is how a wrong
match gets corrected, and how a stack can be given any app's logo on purpose.
Verified end to end against the live catalog and real downloads: list rows carry
the resolved logo, the icon endpoint serves real PNG bytes, an unmatched stack
404s (and falls through to its glyph), a hand-picked logo round-trips, reset
clears it, and a traversal slug 404s. 30 new backend tests and 12 new frontend
ones run without any network at all.
0.52.0 rather than amending 0.51.0: those images are already in the registry,
and rebuilding a published version tag with different content is exactly what
breaks the self-update checker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
StackPilot now manages exactly one Docker host: the one it runs on. The
stackpilot-agent sidecar and everything that proxied to it are gone — 4721
lines deleted against 657 added.
Deleted outright: agent/ (image, compose, env), agent_app.py, models/agent.py,
routers/agents.py (1200 lines), services/agent_service.py, the agent API client,
RemoteStackDetail, the host components and AgentStacksSection. That removes 57
API routes and the three /ws/agent-* proxies.
Threaded out everywhere else, which was the bulk of the work. Every API module
carried an optional agentId that switched the base path; every page that listed
Docker objects rendered one section per host behind a HostHeader; Files had a
host switcher; the New Stack editor and the template dialog had host selectors;
schedules, auto-update policies and stack summaries carried agent_id. All of it
is gone, and the typechecker drove the sweep — 85 files touched, tsc and the
build clean.
Two things the removal exposed as dead weight rather than merely unused:
compose_service kept an in-process busy set purely because the agent needed a
lock and has no database. With the agent gone that was a second source of truth
next to the real DB lock, so it is deleted; compute_status now reports only what
the containers say and the two callers that want "updating" overlay the lock.
StacksTable's linkBase prop only ever existed to point at /hosts/{id}/stacks.
The dashboard's "Hosts 1/1 online" KPI can no longer say anything else, so the
tile and the KPIs behind it are gone and the row is five wide.
Upgrading matters here. An existing install still has an agent table holding
each remote host's URL and bearer token — full Docker control of that host,
sitting in the database with nothing left to use it. _drop_removed_schema drops
it on first start, and drops the agent_id columns where the SQLite build
supports DROP COLUMN. Each statement runs in its own transaction on purpose: a
failed DDL poisons the transaction it is in, so sharing one would let an
unsupported column drop take the table drop down with it. test_agent_removal
covers both branches plus the fresh-install and idempotent cases, and an
end-to-end run against a seeded pre-0.48 database confirms the table is gone and
every /api/agents route answers 404.
Docstrings that justified a design by "shared with the agent, which has no
database" were rewritten rather than left lying: update_service's persistence
callback and image_status_store are still the right split (registry logic stays
testable without a database), but for that reason now, not the old one. The
README's multi-host sections are removed and an upgrade note explains what to do
with running agent containers; ROADMAP keeps its history behind a note saying
the feature it describes no longer exists.
CI no longer builds or pushes stackpilot-agent.
735 tests pass, ruff and tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
F7 — Nothing stopped two compose operations landing on the same stack. There
was a busy flag, but is_busy() was only ever read to colour the status column;
no lifecycle handler consulted it before acting. Two tabs, or auto-update
picking up a stack somebody had just clicked, both ran pull + up -d against the
same project and raced over recreating containers.
Lifecycle calls, the two deploy WebSockets and the auto-update pass now take a
real lock; a second caller gets 409 (or an error frame and close 4409) and
auto-update skips and retries next cycle. The lock is a row rather than a set
in one worker's memory, so it holds across workers and across a restart, and it
carries an expiry — a worker killed mid-deploy would otherwise strand the stack
with no fix short of editing the database.
F10 — /api/stacks/stats sampled every running container on every call, one
blocking daemon request each, and both the dashboard and the stacks list poll
it every five seconds. Two tabs on a 40-container host meant a sustained ~16
samples a second. Cached for 4s behind a lock so concurrent callers share one
sweep, the same shape dashboard_service already used for its fleet aggregate.
F11 — Three module dicts assumed exactly one uvicorn worker without saying so
and were lost on restart. The busy set is the lock above. The image update
cache is now mirrored to SQLite, so a restart shows the badges immediately
instead of blanking them for up to an hour, and the already-notified marks come
back with them rather than re-announcing the same updates. The login rate
limiter is a table, so it cannot be cleared by getting the process to restart
and no longer multiplies by the worker count.
The constraint that shaped this: compose_service and update_service are shared
with the agent, which has no database. Neither may import one. So the lock is a
separate service the central app enforces at its own entry points, and update
persistence is an opt-in callback the central app registers in its lifespan —
the agent registers nothing and behaves exactly as before. A test asserts
update_service never imports the database, since that is the kind of thing a
later change breaks silently.
Both new nets were checked by reverting the fix: dropping the lock from
_lifecycle fails six tests, removing the stats cache fails the one that names
the behaviour.
Also wires up cache pruning in the same sweep — without it both the dict and
the table grew one entry per image tag ever run, for the life of the install.
31 new tests (729 total).
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
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>
- 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>
The Stacks tab now lists stacks in the shared StacksTable (status, CPU
and memory meters, inline start/stop/restart) instead of cards, for both
the local host and per-agent sections — same look as the dashboard.
StacksTable gained optional showEdit/showDelete props so the management
surface keeps the Edit link and local Delete (with confirm). Search and
sort are unchanged. Removed the now-unused StackCard component.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Dashboard's stacks-usage table (CPU/mem meters + inline
start/stop/restart) is now a reusable components/stacks/StacksTable.tsx
used by both the local and per-agent host sections. Removes the
duplicate inline definition that was left behind by the half-finished
extraction (which broke the build: redeclared StacksTable + dangling
imports). No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port-conflict check now matches the stack's own containers via the
compose project label instead of a fragile container-name prefix, so
editing + deploying a running stack no longer reports false conflicts
(explicit container_name or '_' name separator).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-host resource bar gained a "Volumes" stat showing the total size of
that host's Docker volumes. It reuses the existing cached /volumes/sizes lookup
(docker system df, ~60s TTL) summed client-side, polled every 60s per host so
the slow df walk never blocks the fast system-info poll.
Frontend-only. Fixed sumSizes to reduce<number> so the value is number|undefined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-host resource bar gained a Disk stat (used / total). The agent now
reports disk_total/disk_used via shutil.disk_usage on its stacks dir (a host
bind-mount), alongside the existing cpu/mem/containers fields; the local host
uses the existing system info disk data.
- agent_app.py: _disk_info() + disk_total/disk_used in _system_info().
- Frontend: ResourceBar gained diskUsed/diskTotal (5-column grid); AgentSystem
type gained disk_total/disk_used.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard resource bar (CPU cores, memory used/total, containers, Docker
version) is now rendered per host instead of once for the local host — each
host section (local + each agent) shows its own bar above its stacks table.
- agent_app.py: _system_info() now also returns mem_used (from meminfo
available), alongside the cpu_cores/mem_total added in 0.19.0.
- Frontend: extracted a ResourceBar component used by the local section and each
AgentDashboardSection; AgentSystem type gained mem_used.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard now renders a stacks-with-usage table per host: the local host
plus a section for each registered agent (online dot + offline notice), reusing
the same CPU/memory meters and inline start/stop/restart actions.
- agent_app.py: GET /agent/stacks/stats (reuses stats_service); /agent/system
now also returns cpu_cores + mem_total for remote meter references.
- routers/agents.py: proxy GET /api/agents/{id}/stacks/stats (declared before
/{agent_id}/stacks/{stack_id}).
- Frontend: agentsApi.system + stackStats; Dashboard refactored into a shared
StacksTable used by the local section and a per-agent AgentDashboardSection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Docker's volume list has no size, so add a "Compute sizes" button that runs
`docker system df` (via client.df()) and shows per-volume size in a new Size
column. The df walk is expensive (seconds), so results are cached ~60s and
loaded on demand instead of on every poll.
- volume_service.volume_sizes(force) with a 60s TTL cache; GET /api/volumes/sizes
+ agent /agent/volumes/sizes + proxy /api/agents/{id}/volumes/sizes.
- Frontend: volumesApi.sizes(force, agentId); Volumes page gained a Size column
and a Compute sizes button (per host) that triggers the lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a dedicated Volumes page (sidebar) with per-host sections (local + each
online agent), matching the Networks/Images layout. Lists volumes with driver,
owning stack, in-use containers and mountpoint; admins can delete (with an
in-use warning + force option) and prune unused, plus an "only unused" filter.
- agent_app.py: /agent/volumes (list/delete with in-use 409 guard/prune)
reusing volume_service.
- routers/agents.py: proxy routes /api/agents/{id}/volumes/* (audit-logged
delete/prune).
- Frontend: volumesApi list/remove/prune take an optional agentId; new
pages/Volumes.tsx (VolumesSection per host) + sidebar entry + /volumes route.
The volume wizard (generate-yaml/host paths) stays local and unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard now lists stacks in a table with live CPU and memory usage per
stack. Usage is sampled from docker stats (one-shot read per running container,
using the daemon-provided precpu for the CPU delta) and aggregated by compose
project.
- services/stats_service.py + GET /api/stacks/stats: per-stack cpu_used (cores),
mem_used (bytes minus reclaimable cache), and the summed assigned cpu/mem
limits (null when none set), read concurrently across containers.
- Dashboard: stacks render as a table with a CPU and a Memory meter. When a
limit is assigned the bar fills toward it (used / limit + %); otherwise it
fills toward the host total. Inline start/stop/restart per row for admins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Files page gained a host switcher: when agents are registered, a Host
dropdown switches the whole browser between the local host and any online agent
(switching resets path + clipboard). Every file operation is sandboxed by the
selected agent's own ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX.
- agent_app.py: /agent/files/* (list/read/download/write/mkdir/touch/rename/
copy/move/delete/upload) reusing file_service + device_service; BrowseError
-> HTTP 400.
- routers/agents.py: proxy routes at /api/agents/{id}/files/* (audit-logged
mutations); download streams via download_to_file, upload via upload_file.
Reuses the WriteBody/NameBody/RenameBody/TransferBody models from routers.files.
- Frontend: filesApi methods take an optional trailing agentId; Files.tsx tracks
a host and threads it through every call, query key, and the editor/dialogs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Networks and Images are now per-host, rendered as a section for the local host
plus one per registered agent (like the Stacks page).
- agent_app.py: new /agent/networks (list/inspect/containers/connect/disconnect/
create/delete/prune) and /agent/images (list/updates/check), reusing
network_service and a new image_service; DockerError mapped to HTTP status
(forbidden -> 400 so the proxy doesn't treat it as a token failure).
- routers/agents.py: proxy routes at /api/agents/{id}/networks/* and
/api/agents/{id}/images/*, audit-logging mutations.
- services/image_service.py: extracted the image-listing logic so the central
router and the agent share it.
- Frontend: networksApi/imagesApi take an optional agentId; Networks/Images
pages render NetworksSection/ImagesSection per host with a shared HostHeader.
Remote "Prune unused" networks resolves the address-pool-exhaustion deploy
error from the UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The agent-logs WebSocket proxy injected the agent token raw into the upstream
query string (?token=<token>). Tokens containing base64/url-special characters
(+, /, =) were then mangled by the query parser on the agent side (e.g. "+"
decoded to a space), so the agent rejected the stream with close code 4401 even
though the same token works for the HTTP API (where it travels in the
Authorization header). URL-encode the token with urllib.parse.quote so it
round-trips intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remote-stack log streaming showed only "disconnected, 0 lines" whenever the
agent log proxy failed, because the LogViewer ignored type:"error" messages and
the proxy swallowed connection errors.
- ws.py: the agent-logs proxy now reports a clear, logged reason on failure —
distinguishes "cannot reach agent <url>" from a handshake rejection (HTTP 404
hints the agent is outdated and lacks live-log support) and forwards abnormal
upstream close codes (e.g. 4401 bad agent token).
- LogViewer: renders type:"error" messages (red) and surfaces a 4401 close as an
authorization error, instead of silently showing "Waiting for log output…".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folder upload: the Files page gained an "Upload folder" picker
(webkitdirectory); each file is sent with its webkitRelativePath and the
backend recreates the directory tree. upload_target now accepts an optional
rel_path, creating intermediate dirs (mkdir -p) inside the sandbox with each
component validated against traversal.
Copy/move: new file_service.copy/move + POST /api/files/{copy,move}
(admin, audit-logged). The UI adds per-row copy/cut actions, a clipboard bar
to paste into the current directory, and an overwrite prompt on conflict.
Both refuse to move/copy a folder into itself or its own subtree and are
sandbox-checked on source and destination.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a full host filesystem browser reachable from the sidebar (/files):
breadcrumb navigation, browse-root chips, show-hidden toggle, and a table
with size/permissions/mtime. Text files open in a Monaco editor (language by
extension); binary/oversized files fall back to download. Admins can create
folders/files, rename, delete (recursive for dirs), upload, and save edits;
download is available to all users. Every mutation is audit-logged.
Backend: new services/file_service.py reuses device_service's sandbox helpers
(confined to ALLOWED_BROWSE_ROOTS, mapped via HOST_ROOT_PREFIX) and rejects
path traversal and deleting a browse root. routers/files.py exposes
/api/files/{list,read,download,write,mkdir,touch,rename,upload,DELETE}
(reads: any user; mutations: admin). device_service.browse entries gained
mtime + symlink (non-breaking).
Deployment: ALLOWED_BROWSE_ROOTS + HOST_ROOT_PREFIX are now env-wired in
docker-compose.yml and .env.example, with a commented /:/host_root mount to
browse/manage the real host filesystem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Published container ports on the stack Overview (local + remote) render as
clickable chips that open the service at the host's address + port in a new
tab. New ContainerPorts component: links to the bound host IP when concrete,
else the host you're viewing from; remote stacks link to the agent host
(derived from the agent URL). http by default, https for 443/8443.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Live remote-stack logs over a WebSocket proxied through the central app to
the agent (/ws/agent-logs/{agent}/{stack}); agent gains a WS log endpoint.
- Deploy to a remote host from the UI: host selector in the New Stack editor
and template dialog; templates instantiate onto an agent via the proxy.
- Network attach/detach: expandable inspect view per network with
connect/disconnect + container picker; GET /{id}/containers, POST connect/disconnect.
- Remove dead pages/Placeholder.tsx.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- gpu_service: detect host render/video group GIDs from /dev/dri node ownership
(render node → render GID, paired card node → video GID); added to GPUInfo +
exposed via /api/system/gpus. inject_dri now emits numeric group_add entries
(e.g. ["991","44"]) when GIDs are known, falling back to names otherwise;
remove_gpu strips those GIDs + LIBVA_DRIVER_NAME; dri_group_gids() for cleanup.
- editor set-gpu passes render_gid/video_gid through; GPUSelector shows detected
GIDs, defaults video group on, and sends them.
Verified: py_compile, unit check (inject→["991","44"] then clean removal),
frontend tsc build, image imports. Live iGPU verify is on the user's hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Networks: network_service (list w/ subnet/containers/in-use/owning-stack,
create bridge/macvlan/ipvlan/overlay + optional subnet/gateway/internal,
delete with default-network guard, prune) + routers/networks.py; real
Networks page replaces the placeholder.
- Fix: local stacks can now be deleted from the UI — Delete button on stack
detail (with optional keep-files-on-disk) and a trash action on stack cards,
via a shared ConfirmDialog. (Backend DELETE existed; no UI surfaced it.)
Verified: py_compile, frontend tsc build, live network list smoke test
(defaults flagged, compose nets + in-use detected); main 104 routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- BackupSchedule model + schedule_service: next-run calc (hourly/daily/weekly,
UTC), background scheduler loop (lifespan), run-one with retention pruning
(keep newest N per stack on the destination), backup_failed notify event.
- routers/schedules.py: schedules CRUD + run-now; registered in main.py.
- Frontend: api/schedules.ts + Settings → Scheduled backups (list with next/last
run + status, enable/disable, run-now, delete; add form with stack/destination/
frequency/time/weekday/retention/volumes).
Rough-verified only (per request): py_compile, frontend tsc build, app import
(95 routes), next-run math sanity. Full live run to be tested after deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- BackupDestination model + backup_destination_service (SFTP via paramiko,
S3-compatible via boto3): upload/list/download/delete/test.
- routers/destinations.py: destinations CRUD (secrets masked, merge-on-update),
test, list/delete remote backups. backups.py: POST /{id}/backup/push and
POST /restore-from (download from a destination + restore, volumes included).
- Frontend: Settings → Backup destinations (SFTP/S3 forms + test); Backup dialog
can push to a destination; Restore dialog can pick a destination + backup.
- deps: paramiko 3.5.0, boto3 1.35.99.
Verified end-to-end against live MinIO + atmoz/sftp: create/test destinations,
push (incl. volumes), list, restore-from to a fresh stack (volume data intact),
delete remote backup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
list[str] settings fed from env were JSON-decoded by pydantic-settings
before the field validator ran, so a plain string like
CORS_ORIGINS=http://host:5009 raised JSONDecodeError on startup.
Annotate list env fields with NoDecode and parse CSV/JSON in the validator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>