* fix(deploy-progress): decouple deploys from the live progress stream
The deploy progress modal streamed compose output over a WebSocket, but
the deploy itself was coupled to that socket in two ways that could break
or silently abort a deploy:
- The deploy request was gated on the progress socket connecting, so any
upgrade failure (a reverse proxy blocking WebSocket upgrades, or the
admin-only stream rejecting a scoped deployer) left the modal stuck on
"Connecting..." and the deploy never fired.
- The backend terminated the running compose process when that socket
closed, so minimizing the modal, navigating away, or a network blip
aborted an in-flight deploy.
Make the progress socket output-only: the deploy is owned by its request
and runs to completion (or the existing command timeout) regardless of
the stream. The modal now degrades to a "Live progress unavailable" state
and still reports success or failure from the request result. Connect
failures, drops, and a connect timeout all release the deploy instead of
blocking it.
Also route progress output per deploy: the frontend sends a correlation
id on both the connectTerminal message and the deploy request header, and
the backend keys progress sockets by that id so concurrent deploys from
different tabs or users no longer cross-stream each other's output.
Cap the in-memory parsed log rows so a very long deploy cannot grow the
modal's state unbounded.
* fix(deploy-progress): generate the deploy session id with a CSPRNG
The per-deploy correlation id keys which WebSocket receives a deploy's
live output, so a guessable id lets one authenticated client register a
victim's id and read its compose output. It was built from Math.random()
plus a timestamp, which is not cryptographically secure.
Generate it with crypto.getRandomValues (128 bits, hex). That is the one
Crypto member available in insecure contexts, so it still works over LAN
HTTP where crypto.randomUUID is unavailable.
* fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal
Address review findings on the progress-stream routing:
- Only an id-less connectTerminal registration may become the id-less
fallback socket. Previously every connectTerminal (including keyed deploy
modals) set the fallback, so a headerless operation (bulk update, rollback,
or a legacy client) resolved via getTerminalWs() into another user's keyed
deploy modal. Keyed sockets are now excluded from the fallback, and a socket
that adopts a session id is removed from it.
- The connect-timeout fallback now also flags the modal as "Live progress
unavailable" instead of leaving it on "Connecting..." while the deploy runs.
- Log only a short prefix of the deploy session id in developer diagnostics,
not the full capability value.
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag
PUT /api/stacks/:name/files/content previously did a blind write; two
operators editing the same script lost one of the saves with no
warning. The compose-file editor already had mtime optimistic
concurrency (PR #1183); this brings the file-explorer write path to
the same shape.
GET /files/content now also returns mtimeMs and sets a weak ETag header
derived from the stat. The matching PUT reads If-Match, asks
FileSystemService.writeStackFileIfUnchanged to compare against the
live mtime, and returns 412 PRECONDITION_FAILED with the current
content and mtime when the stale-write check fails. Successful writes
echo a fresh ETag so the client can pin the next save without re-GET.
readStackFile and writeStackFileIfUnchanged each open the file once
and stat+read through the same handle so the mtime returned to the
client matches the bytes that were sent, even if the file is replaced
between the two operations.
PUT without If-Match still succeeds (backward compatibility with
scripted clients that do not roundtrip the ETag). FileViewer now sends
the loaded mtime on save, updates its local mtime from the success
response, and on FileConflictError adopts the server snapshot as the
new baseline so the user's follow-up edit-and-save does not loop on
the same precondition.
* fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict
Two follow-ups from code review on the prior commit:
- writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is
set and the target has been deleted. The caller was editing a file
that no longer exists; silently writing the buffer to the void is
wrong. The client adopts the empty snapshot as 'file is gone, start
over' and the user keeps control of what to save next.
- The FileViewer conflict handler no longer overwrites the user's
typed buffer with the server snapshot. It updates the baseline so
the next save sends the fresh mtime, then leaves the editor content
alone. The user sees their edits, the Save button stays enabled,
and a follow-up click applies their changes on top of the new
server version without silently destroying what they typed.
* fix(api): preserve default headers when caller supplies a headers field
apiFetch built defaultOptions.headers by merging Content-Type, x-node-id,
and the caller's headers, but then spread the unmodified fetchOptions
over defaultOptions at the outer level. The spread overwrote the merged
headers with the caller's bare headers, silently dropping Content-Type
on every request that supplied any custom header.
This was latent until the file-explorer save path started sending an
If-Match header. The Express body parser refused the PUT without
Content-Type, the route returned 400, the editor showed an error
toast instead of the success toast, and the Playwright save assertion
timed out.
Destructure headers out of fetchOptions before the outer spread so the
already-merged defaultOptions.headers survives. Add api.test.ts with
four regression cases pinning Content-Type, the If-Match merge,
x-node-id presence when active, and localOnly skip.
The backend already rejects path-traversal attempts through
isValidRelativeStackPath, so the server side is safe today. Adding a
client-side mirror is defense-in-depth: it shortens the failure loop
(no wasted round trip) and protects against a future server-side
regression that loosens validation.
Adds isClientSafeRelPath in frontend/src/lib/stackFilesApi.ts mirroring
the backend predicate (rejects absolute paths, drive letters,
backslashes, NUL bytes, double slashes, and any segment that is `.`
or `..`). Wraps every export that accepts a relPath / targetDir /
fromRel / toRel argument with assertSafeRelPath, throwing a clear
Error before the fetch is issued.
12 unit tests cover the predicate (POSIX accepts, traversal rejects,
Windows drive letters, backslashes, NUL bytes, non-string inputs).
Frontend suite stays at 288/288.
The Topology view now offers three layouts so the canvas adapts to how the
fleet is organised, not the other way around:
- Hub: the gateway anchored on the left with remotes radiating right
- Grouped (Skipper+): remotes cluster by their primary node label, with the
local node in its own cluster and unlabeled remotes in an Unlabeled cluster
- Free (Skipper+): drag any node; positions persist per browser via local
storage
Node cards gain label pills (Skipper+), a cordon banner with reason tooltip,
a latency chip for online remotes, and a stale pilot-heartbeat glyph. All
fields are sourced from /api/fleet/overview, which already returns cordon,
latency, and pilot timestamps; no backend changes required.
Community continues to see the single Hub layout (without the toolbar, no
gating cues), matching the visibility principle that paid affordances are
hidden from lower tiers rather than displayed as locked teasers. The
backend /api/node-labels route is already requirePaid, so the data gate is
honoured end to end.
Includes unit tests for the layout module (hub/grouped/free coverage) and
the preferences hook (round-trip plus corrupt-JSON fallback).
* feat(security): polish scan sheets, fix CVE links, surface policy violations
Adds cveUrl helper that rewrites Trivy's 404-ing avd.aquasec.com links to
cve.org for CVE-prefixed IDs (GHSA and misconfig URLs pass through unchanged).
Redesigns both scan sheets with shadow-card-bevel chips, tracked-mono kickers,
severity row tinting with a left accent rail, and tabular-nums timestamps.
Surfaces a destructive policy-violation banner on scans whose policy_evaluation
row flags a block, and fixes the compare sheet's delta ribbon so CRITICAL
net-positive deltas render in destructive (not warning) tone. Backend parses
the JSON policy_evaluation column at the API boundary so the UI receives a
structured object.
* chore(security): suppress CVE-2026-32281 and CVE-2026-32283 in Trivy scan
Both CVEs affect Go stdlib crypto/x509 and TLS in Docker CLI 29.4.0
(Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). No upstream static binary
has been released with the patched Go 1.26.2 or 1.25.9 runtimes yet.
Exposure analysis: the Docker CLI and compose plugin connect to the local
Docker socket (Unix socket, no TLS) and to public registries with well-known
CAs. Neither CVE is exploitable in this configuration. Added alongside
sibling entries already in .trivyignore for the same binary versions.
Revisit on next Docker CLI and Compose upstream release.