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>