Commit Graph
32 Commits
Author SHA1 Message Date
menzeljandClaude Opus 5 09bed274eb Lock stacks during compose runs, cache stats, persist runtime state (0.47.0)
CI / check (push) Successful in 7m4s
CI / build-and-push (push) Successful in 1m47s
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
2026-08-31 13:43:39 +02:00
menzeljandClaude Opus 5 41a21b5a25 Make tokens revocable and move the refresh token out of localStorage (0.46.0)
CI / check (push) Successful in 7m7s
CI / build-and-push (push) Successful in 1m44s
F5 — A token was valid until it expired, full stop. Resetting a compromised
account's password changed nothing for whoever held its tokens (up to 30 days
for a refresh token), demoting or disabling an account only took effect once
the same clock ran out, and logout was purely client-side.

Every account now has a token_version, every token is minted carrying it, and
every request compares the two. Bumping it is the revoke switch, pulled on the
three changes that alter what an account may do: password, role, active flag.
"Sign out everywhere" in the user menu bumps your own. Plain "Sign out" only
drops the cookie, because signing out on your phone should not kill your
desktop session.

The refresh token left localStorage for an httpOnly cookie (SameSite=Lax,
scoped to /api/auth), and the access token is now held in memory only. A
successful XSS can still act inside the open page but can no longer walk off
with 30 days of access. The cookie is marked Secure only when the request
arrived over HTTPS — request.url.scheme is trustworthy since the F4 fix — so a
plain-HTTP homelab keeps working. Any refresh token an older build left in
localStorage is deleted on first load. Scripted clients that cannot hold a
cookie can still ask for it in the body with ?in_body=true.

F9 comes with it, as predicted: the WebSocket helpers read the role off the
live user instead of the token's claim. /ws/exec is root-equivalent on the
host, and a token minted while the account was an admin stayed syntactically
valid after a demotion.

The sharp edge was the migration, not the feature. _ensure_model_columns emits
ADD COLUMN without a DEFAULT, so SQLite would have filled token_version with
NULL on every existing install, every version check would have failed against
it, and the upgrade would have locked out every user everywhere. The helper now
renders NOT NULL DEFAULT <literal> for scalar defaults; test_schema_migration
builds a genuinely old-shaped user table and asserts the backfill. The version
comparison also tolerates NULL as 1, so a database migrated by some other route
still works.

Writing that test surfaced an undocumented precondition: _ensure_model_columns
does nothing unless `models` has been imported, since SQLModel.metadata is
empty until then. It holds in production because init_db imports first; now it
says so.

The authorization matrix did its job — adding two auth routes failed the suite
until both were classified, which is exactly the review moment it exists for.

30 new tests (698 total). Upgrading signs everyone out once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
2026-08-31 13:31:00 +02:00
menzeljandClaude Opus 5 60a7ccff93 Add a test suite, a linter and a CI gate in front of the build (0.45.0)
CI / check (push) Successful in 7m40s
CI / build-and-push (push) Successful in 1m55s
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
2026-08-31 13:16:41 +02:00
menzeljandClaude Opus 5 54c835b032 Close the read-side privilege escalation and fix proxy-aware IPs (0.44.0)
CI / build-and-push (push) Successful in 3m53s
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
2026-08-31 13:01:53 +02:00
menzeljandClaude Opus 5 b3af0c2109 Grow the bundled template library to 83 homelab apps (0.43.0)
CI / build-and-push (push) Successful in 1m46s
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
2026-08-31 09:12:34 +02:00
menzeljandClaude Opus 5 2afec08c4f Fix "Add variable" doing nothing, and size the editors to the viewport (0.42.2)
CI / build-and-push (push) Successful in 1m45s
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
2026-08-31 00:49:40 +02:00
menzeljandClaude Opus 5 f6f82245f7 Keep the progress bar on one line, and portal modals out of the top bar (0.42.1)
CI / build-and-push (push) Successful in 1m48s
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
2026-08-31 00:43:10 +02:00
menzeljandClaude Opus 5 1e8d4248fd Stream update progress into a bar on the stack's row (0.42.0)
CI / build-and-push (push) Successful in 1m55s
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
2026-08-31 00:33:34 +02:00
menzeljandClaude Opus 5 86c67dfcea Show action status on the stacks list and dashboard, not just detail (0.41.0)
CI / build-and-push (push) Successful in 1m56s
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
2026-08-31 00:20:03 +02:00
menzeljandClaude Opus 5 adfd77a983 Fix NFS uploads against root_squash exports (0.40.2)
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>
2026-08-16 18:53:25 +00:00
menzeljandClaude Opus 5 4c158e9407 Fix NFS backup destinations broken by the 0.40.0 refactor (0.40.1)
_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>
2026-08-16 18:29:10 +00:00
menzeljandClaude Opus 5 5347a36eaf Back up bind-mount data, not just the compose file (0.40.0)
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>
2026-08-16 18:20:19 +00:00
menzeljandClaude Opus 5 ecf780c5e6 Deploy console: real progress bar for image pulls (0.39.0)
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>
2026-08-16 14:19:27 +00:00
menzeljandClaude Fable 5 9119f94536 Clear the stack update pill immediately after a manual/auto update (0.38.4)
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>
2026-07-04 17:52:31 +00:00
menzeljandClaude Opus 4.8 e651029ab2 db: auto-add missing model columns on startup (fix backupschedule.agent_id) (0.38.3)
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>
2026-06-24 11:43:17 +00:00
menzeljandClaude Opus 4.8 d399caadc9 Dashboard: surface the real compute_fleet error in the response (0.38.2)
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>
2026-06-24 11:31:19 +00:00
menzeljandClaude Opus 4.8 0b95d7d4a2 Dashboard: surface fleet load errors instead of infinite skeletons (0.38.1)
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>
2026-06-24 11:17:32 +00:00
menzeljandClaude Opus 4.8 c830d28b65 Dashboard: rebuild into an operator cockpit (0.38.0)
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>
2026-06-24 11:04:57 +00:00
menzeljandClaude Opus 4.8 5c46e40866 0.37.7: surface folder-upload diagnostics (find why it does nothing)
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>
2026-06-21 21:30:46 +00:00
menzeljandClaude Opus 4.8 415ebb733a 0.37.6: remember the "Show hidden" toggle across reloads
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>
2026-06-21 21:26:03 +00:00
menzeljandClaude Opus 4.8 a40dd0de3e 0.37.5: overwrite prompt for file upload + auto-reveal uploaded hidden files
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>
2026-06-21 21:20:15 +00:00
menzeljandClaude Opus 4.8 98d756faf6 0.37.4: fix folder upload doing nothing (set webkitdirectory reliably)
"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>
2026-06-21 20:52:50 +00:00
menzeljandClaude Opus 4.8 a43e6b48f0 0.37.3: byte-accurate upload progress + file counter for folder uploads
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>
2026-06-21 20:41:42 +00:00
menzeljandClaude Opus 4.8 5ac9f15de4 0.37.2: stream folder zip-downloads to fix 504 on large folders
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>
2026-06-21 20:28:49 +00:00
menzeljandClaude Opus 4.8 0dc430bb2a 0.37.1: fix folder-download hang/501 on special files + add download progress
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>
2026-06-21 20:11:50 +00:00
menzeljandClaude Opus 4.8 f1782eca0e 0.37.0: download whole folders (recursive) as a .zip from the file browser
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>
2026-06-21 19:54:50 +00:00
menzeljandClaude Opus 4.8 5bcec06bbd 0.36.1: app logo as browser-tab favicon
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>
2026-06-16 21:24:07 +00:00
menzeljandClaude Opus 4.8 cd15cdc75e 0.36.0: per-stack image-update indicator on the Stacks overview
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>
2026-06-16 18:52:51 +00:00
menzeljandClaude Opus 4.8 a4b1bbcdd1 0.35.0: per-stack Update button on the Stacks page
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>
2026-06-16 18:44:21 +00:00
menzeljandClaude Fable 5 786c346c40 0.33.0: NFS share as backup destination
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>
2026-06-12 12:39:35 +00:00
menzeljandClaude Fable 5 79d82361d8 0.32.1: backup/restore fixes (audit findings, all paths live-verified)
- 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>
2026-06-12 12:10:45 +00:00
menzeljandClaude Fable 5 a0dda120f5 0.32.0: StackPilot self-update (check on page load + one-click update)
- 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>
2026-06-12 09:01:08 +00:00