Commit Graph

21 Commits

Author SHA1 Message Date
Anso 10fb93dcb1 feat(scheduler): schedule container restart, stop, and start (#1526)
* feat(scheduler): schedule container restart, stop, and start

Add container as a scheduled-task target type so operators can automate lifecycle actions against standalone containers by node and name, with matching UI pickers, validation, execution on local and remote nodes, and tests.

* fix(scheduler): stack service matching and container picker hygiene

Backfill Service on smartFallback containers so per-service stack restarts work when container_name is set. Match services by compose label and container name in stack routes and scheduled restarts. Exclude Sencho from GET /api/containers lists. Hide the Restart Stack service picker when a stack has only one service.

* test(scheduler): scope service checkbox assertion to Services block

The create dialog also has a Delete after run checkbox. Count checkboxes only inside the Services section so CI does not include unrelated form controls.

* fix(scheduler): narrow closest() result to HTMLElement in schedule test

The service-checkbox assertion passed an Element from closest() into
within(), which requires an HTMLElement, failing tsc -b in the frontend
build and Docker build stages. Use the closest<HTMLElement>() type
argument so the value type-checks without an unsafe cast.

* fix(scheduler): hide Sencho container on remote node picker lists

Remote container lists are proxied from peer Sencho instances, so id-only self filtering missed peers on older builds. Await SelfIdentity init, match ImageID, and drop official saelix/sencho images. Apply the same heuristic in the scheduled-operations UI and when the hub fetches remote containers for scheduled runs.

* test(monitor): add missing DatabaseService mocks for scan history cleanup

* test(scheduler): add missing markStaleScansAsFailed mock

SchedulerService.tick() calls db.markStaleScansAsFailed() to sweep stale
vulnerability scans. The scheduler-service test was missing this method in
its DatabaseService mock, causing TypeError failures during test initialization.

Added mockMarkStaleScansAsFailed to hoisted mocks and DatabaseService mock
object, returning safe default of 0 scans marked as failed.

* test(compose): add missing FileSystemService mocks for getStackContent/getEnvContent

* test(containers-route): mock SelfIdentityService to prevent initialize() crash

The excludeSelfContainers() helper calls SelfIdentityService.initialize(), which tries to access DockerController. Without a proper SelfIdentityService mock, the initialize() call fails silently, causing a 500 error on GET /api/containers.

Added SelfIdentityService mock with initialize(), isOwnContainer(), and isOwnImage() methods to prevent the crash.
2026-07-02 22:31:29 -04:00
Anso 1b9a40f874 fix: align notification unread badge with panel visibility rules (#1520)
Stack success events were hidden from the panel but still counted unread on the bell and dashboard.

Share one visibility helper across badge, panel, and Recent Alerts.

Harden mark-all-read against partial API failures.

Fixes #1513
2026-06-29 18:19:18 -04:00
Anso 5960c1e85e feat: show node and fleet targets on schedule timeline pills (#1480)
Timeline pills and the mobile schedule list now identify what each
scheduled run acts on instead of repeating the task name. Pills stay
compact (firing time plus a category-aware target) and carry the full
detail on hover:

- Stack actions show the stack name.
- Fleet snapshots show "Entire fleet".
- Fleet auto-updates and node-scoped prune/scan show the selected node.
- The hover tooltip adds the action label, task name, and node.

The mobile list resolves node names too, so prune and scan rows name the
node rather than the literal "system". A shared scheduleTargetDescriptor
helper removes the target-label logic that was duplicated across the
desktop and mobile views. The lifecycle lane is renamed "Stack lifecycle"
to match the action-picker category wording.
2026-06-26 22:15:06 -04:00
Anso f1f64ec7f6 feat: show container name in structured log output (#1452)
* feat: show container name in structured log output

Prepend a normalized container name prefix to each line in
ComposeService.streamLogs() so both the structured log viewer
and the raw terminal identify which container produced each entry.

- Backend: prepend displayName (normalized via normalizeContainerName)
  before LogFormatter.process() in sendOutput and flushBuffer.
- LogFormatter: refactor process() to handle both prefix-first and
  timestamp-first input orders via a while-loop; widen PREFIX_REGEX
  to accept dotted service names.
- Frontend: add containerName to LogRow, extract prefix in parseLine,
  render as an inline mono chip in the message column, and include
  the name in downloaded logs (omitting the bracket prefix when null).
- Tests: 14 new tests across log-formatter, compose-service streamLogs,
  and StructuredLogViewer chip rendering + download formatting.

* fix: guard LogFormatter loop to at most one prefix and one timestamp

The while-loop refactored for order-agnostic prefix/timestamp
parsing could continue matching beyond the intended single prefix
and timestamp. A log line like "redis | 2024-...Z api | started"
would falsely colorize "api |" as a second container prefix in
raw terminal output.

Add prefixFound/timestampFound boolean guards so the loop stops
after one prefix and one timestamp, regardless of input order.

* feat: per-service color alternation for log container chips

Add an Appearance setting that lets users switch between unified
cyan and per-service label-token colors for the container name chips
in the structured log viewer.

- Extract HUE_VARS and hashLabel() from NodeLabelPill into a shared
  utility at frontend/src/lib/label-colors.ts.
- Add useLogChipColorMode hook (browser-local localStorage,
  sencho.log-chip-color-mode key, unified by default).
- Add SegmentedControl in Settings > Appearance > Display.
- Apply inline label-token styles via style attribute in per-service
  mode; keep current text-brand/80 bg-brand/10 classes in unified mode.
- 14 new tests across label-colors, hook, and viewer chip rendering.
2026-06-25 14:13:38 -04:00
Anso 7982251dc6 feat(scheduler): add helper text and risk badges to scheduled action picker (#1449)
* feat(scheduler): add helper text and risk badges to scheduled action picker

Add a concise helper text and risk level badge to every scheduled action
in the create/edit modal. The six risk levels (Safe, Read-only, Interruptive,
Runtime change, Removes containers, Destructive) map to the four existing
design-system tones and render as a small dot+label chip next to the helper
text, following the same pattern as SeverityBadge.

Fix an ambiguous mobile label: update + target_type: fleet now resolves
through resolveTaskAction and renders 'update node stacks' instead of the
misleading 'update fleet'.

Add exact helper-text and risk-level assertions for all 10 actions, plus
component tests for default modal state, action-switch scenarios, and
mobile update+fleet rendering.

* docs: update stale scheduled-operations alt text for changed helper text
2026-06-25 02:37:12 -04:00
Anso cc78873e65 feat(scheduler): group schedule action picker by operator intent (#1446)
* feat(scheduler): group schedule action picker by operator intent

Reorganize the New Schedule action picker from a flat dropdown to a
category-grouped list (Lifecycle, Updates, Security, Maintenance, Backups).

- Extend Combobox component with optional group field on ComboboxOption,
  rendering grouped sections with non-interactive headers when groups are
  present. Flat rendering is unchanged for all other callers.
- Reorder SCHEDULED_ACTIONS by category group and update seven action
  labels per the operator-intent spec.
- Add DEFAULT_SCHEDULED_ACTION_ID constant so picker order and form
  defaults are independently controllable.
- Wire grouped actionOptions into ScheduledOperationsView.
- Update all label references in docs and tests.
- Add Combobox grouping tests, registry order test, and default-constant
  test.

* fix(scheduler): correct Combobox grouping for interleaved groups, docs labels

- Replace last-group-append with Map-based group partitioning so
  interleaved or mixed-group options land in the correct group.
- Add interleaved-groups test and restore non-interactivity test.
- Update stale "Start Stack" references to "Start / Bring Up Stack"
  in doc action-label contexts.
- Update action-picker alt text to describe the new grouped order.
2026-06-25 01:40:31 -04:00
Anso bc8c051962 feat(scheduler): consistent action targeting in Scheduled Operations (#1431)
Give every scheduled action an explicit, predictable target model
(Action then Node then Stack then Options then Schedule):

- System Prune now exposes a Node picker and requires a node, so it can
  no longer run silently on the default node.
- Vulnerability Scan and System Prune list local nodes only; both run on
  the hub-local Docker daemon and reject remote nodes on the backend.
- Restart Stack service discovery loads services from the selected node
  via fetchForNode instead of the active or local node.
- Fleet Snapshot shows a read-only "Scope: Entire fleet" summary.

Backend gains a shared local-node guard and prune node validation on
create and update, plus an executor-level remote-node guard, so the
frontend and backend validation now agree for every action.
2026-06-24 21:02:15 -04:00
Anso 0af7ad1df2 refactor(scheduler): drive scheduled-action metadata from a shared registry (#1428)
Scheduled-operation action metadata was duplicated across the backend route
validator, the DatabaseService action union, the desktop action picker, the
Timeline lanes, and the mobile labels/tones. Adding or renaming one action meant
editing all of them.

Introduce one registry per package as the single source within that package:

- backend/src/services/scheduledActionRegistry.ts owns the action list and
  target-type validation; routes/scheduledTasks.ts and DatabaseService import
  from it (BackendScheduledAction type, VALID_ACTIONS, validateActionTarget).
- frontend/src/lib/scheduledActions.ts owns the UI metadata (labels, short
  labels, categories, tones, target/node/stack/service flags, helper text) and
  drives the create-flow picker, the All Tasks label, the Timeline lanes, and
  the mobile schedule view.

Timeline lanes now group by semantic category (Lifecycle, Updates, Security,
Maintenance, Backups) sourced from the registry. The update-fleet UI alias is
made explicit via a backendAction field. Backend validation stays authoritative;
parity tests on each side keep the action sets in lockstep.
2026-06-24 20:09:13 -04:00
Anso 401980ffa3 feat(resources): render anonymous volume names readably in the volume browser (#1429)
Anonymous Docker volumes carry a raw 64-character hex name, which is correct
data but unreadable in the volume browser crumb, sheet title, and table row.

Detect 64-hex names and show a short prefix (e.g. 079dfda49f2c…) in the table
cell and sheet title, with the full name available on hover and an "anonymous"
chip so the truncation is self-explanatory. The volume browser sheet adds a
band that keeps the full hash visible and copyable. Named volumes still display
in full. The name is passed verbatim to the API; only the rendered label changes.
2026-06-24 19:05:40 -04:00
Anso 1c5b2715b8 fix(editor): suppress global hotkeys while the code editor is focused (#1413)
Typing a single-key shortcut (a/h/u/p/b) in the compose editor opened
that shortcut's action instead of inserting the character, but only in
Chrome. The shared isInputFocused() guard recognized inputs, textareas,
and contentEditable elements; in Chromium the Monaco editor uses the
EditContext API whose focused surface is a plain focusable div, so the
guard missed it and the hotkey handlers fired. Safari falls back to a
hidden textarea, which the guard already caught.

Match any focus inside the .monaco-editor container so the guard covers
the EditContext surface, bringing Chrome to parity with Safari for both
global hotkey systems. Add a unit test for the guard.

Closes #1410
2026-06-22 19:37:01 -04:00
Anso 37e6e48b40 feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409)
* perf(files): spool uploads to disk instead of buffering in memory

Switch the stack file-explorer upload from multer memoryStorage to
diskStorage and stream the spooled temp file through the file-root
gateway, so an upload is never held fully in RAM. Authorization and
root resolution now run before multer spools, so an unauthorized or
read-only-root request is rejected without writing a temp file, and the
spool is removed on every exit path. The named-volume helper write
verifies the written byte count, since cat cannot report a short write.

* feat(files): copy and duplicate files in the explorer

Add a copy capability to the stack file explorer: a same-folder
Duplicate (auto-suffixed name) and a "Copy to..." destination picker,
on both filesystem and named-volume roots. Copying is within-root,
symlink-leaf-safe, blocks a directory copy into its own subtree, and
refuses to create a protected name (compose/.env) at the stack root
while still allowing a protected file to be duplicated under a new name.

* feat(files): make the file tree keyboard accessible

Bring the stack file explorer tree to the WCAG tree pattern: rows are
treeitems carrying aria-level, aria-selected, and aria-expanded, with a
single roving tabindex and full keyboard navigation (arrow keys,
Home/End, Enter/Space) over a flattened visible-node list that stays in
lockstep with the rendered rows. A polite live region announces the
selected file. No visual change to the tree.

* feat(files): bulk select, delete, move, and download files

Add multi-select to the stack file explorer (checkboxes plus Shift and
Ctrl/Cmd click over the visible order) driving three bulk actions:
delete, move, and download as a streamed .tar.gz. All run within the
active root on both filesystem and named-volume backends, report
per-item results so partial failures surface (with the failed items
kept selected for retry), normalize ancestor/descendant selections
server-side, and cap the archive entry and byte counts before any
bytes are streamed. Protected compose/.env files are excluded from
bulk delete and move but may still be downloaded.

* docs(files): document copy, bulk actions, and keyboard navigation

Add the copy/duplicate and multi-select bulk delete/move/download
sections to the Files & Volumes page, a keyboard-navigation note for the
tree, an updated context-menu reference, and bulk troubleshooting entries.

* fix(files): inline path-injection barriers at the new file-op sinks

CodeQL js/path-injection does not credit the wrapped isPathWithinBase
containment check, so the new copy/bulk/disk-upload flows tripped the
gate. Inline the canonical path.resolve + startsWith barrier at the
realpath sink in resolveSafePathWithin (covers every user-relPath flow)
and confirm the multer spool path resolves within UPLOAD_TMP_DIR before
unlinking it or streaming it onward. Behavior is unchanged; the paths
were already validated.

* fix(files): guard the ancestor-walk realpath sink too

The first barrier covered realpath(target), but the ENOENT ancestor
walk re-derives the path via path.dirname, which static analysis treats
as a fresh tainted value. Add the same inline containment barrier before
that realpath and resolve the root case via the untainted base, so the
only tainted realpath input is one the startsWith check has cleared.
Behavior is unchanged.

* fix(files): resolve the root case off the taint path in the ancestor walk

The compound guard on existing (the same variable as the startsWith
subject) was not credited as a sanitizer. Handle the root case before
the barrier by resolving the untainted base directly, leaving a plain
canonical startsWith guard on the strictly-within ancestor. Behavior is
unchanged.

* fix(files): harden helper-backend bulk download and uploads

Address three issues found in the named-volume (helper) backend:

- Bulk download could send 200 headers before discovering a file the
  helper download path refuses, tearing the archive mid-stream. The
  prewalk now rejects symlinks, non-regular ("other") entries, and
  files over the per-file download cap before any header (400/413).
  FileEntry gains an 'other' type so non-regular entries stay distinct
  from regular files as they pass through the gateway.
- The helper directory listing was fully buffered before the archive
  entry cap could fire. listDir now accepts a limit; the list script
  stops after limit+1 rows and the gateway reports truncation.
- A stdin pipeline error during a helper upload masked the container's
  real nonzero exit code (and its 4xx mapping) as a generic 500. The
  nonzero exit now wins; the masked stream error is logged.

* feat(files): add a New file toolbar button with server-enforced create-only

The stack file explorer could create a folder from a toolbar button but a
new file only from a folder's right-click menu, so a file could not be
created at the stack root at all. Add a New file toolbar button beside New
folder, targeting the current directory.

Creating a file now routes through a new createEmptyStackFile helper that
posts a zero-byte file through the existing upload endpoint with overwrite
off, so the server's exclusive-create path rejects an existing name instead
of clobbering it. A file collision surfaces inline in the dialog; a folder
collision and other failures surface as a toast.

* fix(files): widen the tree row hit area and add horizontal scroll for long names

Right-clicking a file tree row only opened the Sencho context menu when the
click landed on the filename; the rest of the row fell through to the native
browser menu, and long names were truncated with no way to read them.

Make each row span the full pane width (and grow with its content) so the
whole row is the context-menu trigger, and let the tree scroll horizontally
so a long name is reachable instead of clipped. A new opt-in horizontal prop
on ScrollArea adds the styled horizontal scrollbar without clamping content
width.

* docs(files): document the New file button, full-row right-click, and long-name scrolling

* test(files): cover createEmptyStackFile targeting the stack root

Add an API-layer case for the empty-directory (stack root) create path, the
primary reason the New file toolbar button exists, so a regression in the
root-level URL would be caught at unit speed rather than only in e2e.
2026-06-22 19:33:06 -04:00
Anso 57a0856ffc feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)
* feat(stacks): per-stack environment inventory and secret-safe guardrails

Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.

Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.

The Environment tab is capability-gated so it hides on older remote nodes.

* fix(stacks): harden env-file reader against a stat-then-open race

Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.

* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service

Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.

Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
2026-06-20 11:58:42 -04:00
Anso 888f658a7a feat(files): move files and folders across directories in the stack explorer (#1373)
* feat(files): move files and folders across directories in the stack explorer

Add a cross-directory move to the stack file explorer. Files and folders can
be relocated either through a "Move to..." context-menu item that opens a
folder-picker dialog, or by dragging an entry onto a folder node (or onto the
root area to move it to the stack root).

The backend reuses the existing rename endpoint: renameStackPath now resolves
both ends through the leaf helper, so a symlink moves as the link entry rather
than its target, and it guards against moving a directory into its own subtree.
A cross-filesystem rename surfaces as a clean 409 instead of a 500. Protected
root files (compose / docker-compose / .env) stay put. Moving the open file, or
a folder containing it, deselects the viewer; a move that would discard unsaved
edits is blocked with a clear message.

* fix(files): fold case in move guards and keep the move dialog open on failure

Harden the cross-directory move against case-insensitive filesystems and fix a
dialog dismissal edge:

- Protected root files (compose / docker-compose / .env) were gated by an exact,
  lowercase name match. On a case-insensitive filesystem a request like
  COMPOSE.YAML resolves to the real compose.yaml and slipped past the gate, so a
  protected file could be moved out of the stack root via the API. The gate now
  folds case on case-insensitive platforms; Linux stays case-sensitive, where a
  differently-cased name is a distinct, unprotected file.
- The directory-into-descendant guard compared resolved paths case-sensitively,
  so a source supplied with non-disk casing skipped the guard and fell through to
  an opaque OS error (500) instead of a clean 400. The comparison now folds case
  the same way.
- The move dialog closed after awaiting the move regardless of outcome, so a
  blocked move (unsaved edits) or a failed move dismissed the picker as if it had
  succeeded. The shared handler now reports success and the dialog only closes on
  an actual move.
2026-06-14 21:56:06 -04:00
Anso 3d39d856a3 feat: chart-led Security overview with sortable Images and History tables (#1364)
* feat: chart-led Security overview with sortable Images and History tables

Refine the Security page around the existing design system and add the
data the dashboard needs.

- Overview leads with four charts (30-day risk trend, severity donut, top
  exposed images, findings by type); the signal-rail counts become a
  secondary summary, and the scanner and deploy-enforcement posture follow.
- Images becomes a recessed table with search, a severity filter, sortable
  columns, a last-scan column, and inline scan actions; the findings cell is
  clickable into the scan sheet, and the per-row cursor tooltip is dropped
  where the columns already carry that information.
- Policies puts deploy-enforcement first, collapses the policy packs into an
  accordion, and uses the standard primary button for Add policy.
- Suppressions and acknowledgements move their titles and Add buttons outside
  the cards, matching the Fleet tab layout.
- History switches from the detail sheet to an inline table (search, sortable
  columns, two-scan compare, pagination); the now-unreachable scan-history
  overlay is removed.
- Add GET /api/security/overview/trend, a node-scoped daily critical/high
  rollup backing the risk-trend chart.
- Extract the shared image-scan hook and the severity classifier, and harden
  the overview data fetch so a malformed non-critical response can never read
  as a clean security state.

* fix: treat malformed Security responses as errors, not empty or clean states

Address an independent review of the data-fetch paths so a 200 with an
unexpected shape can never read as a benign "no findings" view.

- SecurityView: validate that the image-summaries body is a scan-summary map; an
  unexpected shape now sets the error state instead of an empty map. Isolate the
  trend fetch in its own self-catching promise so a transport failure on the
  non-critical chart can no longer poison the overview or summaries error state.
- useImageScan: only a "completed" poll counts as success (a malformed or unknown
  status now throws), and a failed post-scan summaries refresh is logged instead
  of silently dropped.
- HistoryTab: a 200 whose body lacks an items array is treated as an error, not
  an empty "no completed scans" list.
2026-06-12 14:35:03 -04:00
Anso 48cebf9501 fix: bind deploy progress, request, and health gate to the captured node (#1357)
* fix: bind deploy progress, request, and health gate to the captured node

A deploy/update/install/git-apply re-read the active node from localStorage
independently at three points: the progress WebSocket at mount, the POST at call
time, and the health-gate poll. If the active node changed between the click and
any of those, the operation, its live output, and its health verdict could
target different nodes, and the socket and POST splitting across nodes broke
output streaming.

Capture the operation's node once when it starts and thread it through every
leg. A new nodeId option on apiFetch overrides the active-node read, the
progress terminal takes a nodeId prop for its socket URL, the health gate polls
on the captured node, and a failed gate records its recovery entry only on the
node it ran on. The surface, the request, and the gate now always agree.

* fix: scope failed-gate recovery to the file list's node and harden node targeting

Addresses review findings on the captured-node binding:

- Track the node the stack file list was fetched for (filesNodeId) and record a
  failed gate's recovery entry only when it matches the gate's node. This closes
  a race where switching back to the gate's node could match a same-named stack
  from the previous node's still-loaded list before the new list lands, keying
  the record to the wrong file and blocking the correct one. refreshStacks now
  carries a sequence token so an out-of-order resolution cannot leave files and
  filesNodeId inconsistent.
- Make an explicit apiFetch nodeId authoritative over a caller-supplied
  x-node-id header.
- Add the missing stack-logs nodeId cases (null, and active-node fallback) to the
  terminal tests.
2026-06-11 14:36:20 -04:00
Anso 739bbf990e feat: flag documentation drift in the stack dossier (#1349)
* feat: flag documentation drift in the stack dossier

Warn in the Dossier tab when a port written into a stack's access_urls is not published by the stack's compose, so operator documentation stays aligned with what Sencho can observe.

The check is deterministic, read-only, and frontend-only: it compares ports parsed from access_urls against the published ports the Anatomy panel already shows, never interprets prose, and stays quiet for port-less URLs, scheme-default ports (:80/:443), and ports published through a variable, to avoid false positives. Community tier, no gating.

* test: pin doc-drift handling of bare hosts and mixed variable ports

Make two deterministic-drift behaviors intentional and regression-proof after review: a scheme-less single-label host (plex:32400) is not checked, since it cannot be told apart from a plain note (add a scheme to opt in), and a stack mixing a variable-published port with fixed ports suppresses the whole check. Adds tests for both, a clarifying code comment, and a docs note with the http:// workaround. No behavior change.
2026-06-10 13:59:10 -04:00
Anso 5dea040ec8 fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* 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.
2026-05-28 20:51:45 -04:00
Anso 4964320f50 fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* 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.
2026-05-24 23:44:24 -04:00
Anso 82a4e94589 chore(stack-files): client-side path-traversal guard in stackFilesApi (#1190)
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.
2026-05-24 15:39:27 -04:00
Anso 2e82eb44fe feat(fleet): multi-mode topology with label grouping and persisted positions (#1054)
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).
2026-05-15 08:59:52 -04:00
Anso 12c2b37510 feat(security): polish scan sheets, fix CVE links, surface policy violations (#721)
* 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.
2026-04-21 08:51:35 -04:00