Commit Graph

1704 Commits

Author SHA1 Message Date
Anso 2a4955f56d feat: add dedicated Security page and policy-pack foundation (#1362)
* feat: add dedicated Security page and policy-pack foundation

Bring vulnerability scanning, scan history, suppressions, Compose risks,
secrets, policy packs, and scanner setup into one node-scoped Security
command center instead of scattering them across Resources and Settings.

- New top-level Security view with Overview, Images, Compose risks,
  Secrets, Policies, Suppressions, History, and Scanner setup tabs
  (status masthead + signal rail; controlled tabs with deep-link support).
- Backend: GET /security/overview rollup and GET /security/policy-packs
  static catalog (auth-only, Community). DatabaseService gains an uncapped
  scan-status count and a node-eligible block-policy count, and
  getImageScanSummaries now projects secret and misconfig counts.
- Reuse existing surfaces: the scan-history sheet, the control-governed
  suppression and acknowledgement panels, and the scan-detail sheet (now
  with an initial-tab prop so it opens on the matching finding type).
- Extract a shared SeverityBadge (from Resources) and a TrivyManager
  (from Settings) so both surfaces render identical controls.
- Resources "Scan history" now links into the Security page History tab.
- Docs for the new Security surface and tests for the new endpoints,
  helpers, nav wiring, and tabs.

* refactor: consolidate scanner and policy management onto the Security page

Remove the Settings "Vulnerability Scanning" section now that the Security
page covers the same ground, with every option preserved:

- Scanner install / update / uninstall / auto-update live on the Scanner setup
  tab (TrivyManager).
- Scan policies, the honor-suppressions toggle, and the replica
  managed-by-control / demote controls move into a new ScanPolicyManager on the
  Policies tab (paid; Community sees only the policy-pack catalog).
- CVE suppressions and acknowledgements remain on the Suppressions tab.

Wiring removed: the registry section and the now-empty Security settings group,
the SectionId, the SettingsSectionContent case and the isPaid prop it was the
sole consumer of, and SecuritySection itself. The dashboard configuration-status
"Vulnerability scanning" row now navigates to the Security page Policies tab.

Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept
to the relevant Security page tabs.

* fix: harden Security page scanner refresh, policy-load errors, and secret-only badges

Address independent-review findings on the Security page:

- Scanner setup now refreshes Trivy state when the active node changes, so the
  displayed scanner status matches the node TrivyManager's actions target (both
  follow x-node-id). Previously, switching nodes on the tab left stale state.
- ScanPolicyManager surfaces an explicit error state on a failed policy fetch
  instead of falling through to a false "No scan policies configured".
- The shared SeverityBadge and the Images findings column no longer label a scan
  "clean" when it has secrets or misconfigurations but no CVE severity
  (highest_severity is derived from vulnerabilities only); they show a "Findings"
  state and the secret/misconfig counts instead.
- The Overview enforcement note points to the Policies tab, not the removed
  Settings section.
- The History tab auto-opens the scan-history sheet only on a deep-link (mount
  with the History tab active), not on every manual tab selection.

Adds tests for the badge secret/misconfig state and the policy-load error state.
2026-06-12 10:41:39 -04:00
Anso 77f1611971 feat: Compose Network Inspector and exposure intent guard (#1360)
* feat: add Compose Network Inspector facts engine

Render a stack's authored effective model and pair it with the live
Docker snapshot to derive per-stack networking facts: project networks
with external and internal flags, service-to-network membership and
aliases, published ports with host-binding scope, network_mode, and
extra_hosts, plus runtime drift (runtime-only attachments, foreign
networks, and declared-but-unused or missing networks).

Extend the effective-model parser with service network membership,
extra_hosts, and label keys (key names only, never values), and add a
key-space normalized network model with adapters from both the rendered
model and the raw declared compose so the Inspector and drift share one
comparison. Expose GET /api/stacks/:stackName/networking: advisory and
read-only, it renders the authored model only and never returns or logs
raw stderr, env values, or label values.

* feat: store and edit per-stack and per-service exposure intent

Add a stack_exposure_intent table (intent values constrained by a CHECK,
unique per node, stack, and service) with DAO methods to read, upsert,
clear one row, and clear all rows for a stack. The classification is
stored independently of the generated networking facts so a later
mismatch stays detectable; service rows are kept separately from the
stack-level row (service '').

Expose GET and PUT /api/stacks/:stackName/exposure: GET requires read
access, PUT requires edit access and validates the intent against the
allowed set. Sending intent null clears that row, returning the scope to
unset so a service inherits the stack intent again. Intent rows are
cleared when the stack is deleted and when the owning node is removed,
so a later same-named stack never picks up stale classification.

* feat: add exposure-aware Compose Doctor findings

Feed the Compose Doctor's effective-model context with the stored
exposure intent (resolved into a stack-level value plus per-service
overrides) and the dossier's documented access-URL ports, read fail-soft
so a metadata read error skips these checks rather than failing the
preflight. Add five deterministic findings on top of that context:

- a service classified internal or same-node that publishes a host port
  (same-node tolerates a loopback bind),
- a sensitive database or admin image published on all interfaces,
- a port-publishing stack with no exposure intent set,
- a published port not reflected in the documented access URLs,
- reverse-proxy labels with no documented URL or reverse-proxy intent.

The rules stay pure functions over the preflight context; the registry
completeness test pins the new rule set.

* feat: detect compose network drift in the drift ledger

Extend the spatial drift engine with two network-level findings: a
running container attached to a stack-owned or foreign network that
compose does not declare (one finding per service), and a declared
network that no running service uses or that is absent from the runtime
(one stack-level finding, every network named by its resolved runtime
name). The comparison reuses the same helper the Network Inspector uses,
so the two surfaces never disagree.

Network drift runs only when the stack has running containers and the
runtime is reachable, preserving the existing missing-runtime,
parse-error, and unreachable behavior. The findings persist through the
existing drift ledger and surface on the Drift tab, which now labels the
two new kinds.

* feat: link a Docker network back to its owning stack

Add a cross-component open-stack event and make the owning-stack badge on
a managed network in Resources a link: clicking it loads that stack on
its node and opens the editor, reusing the existing fleet navigation. A
latest-ref keeps the window listener current without re-subscribing each
render. Image and volume badges are unchanged; only a managed network
opts in via the new optional handler.

* feat: add the Networking tab to the stack detail panel

Add a capability-gated Networking tab that reads the per-stack networking
facts and exposure intent. It shows the project networks (with external,
internal, and created-by-stack flags), per-service network membership and
aliases, published ports with their host-binding scope, network_mode and
extra_hosts, and runtime drift, degrading to the declared model when the
runtime is unavailable. Users can classify the stack and each service
(internal, LAN, reverse proxy, public, and so on) or clear a row to
inherit; the controls are read-only when the user cannot edit, and a
broken exposure response never tears down the facts view.

A new compose-networking capability is added to both registries so older
nodes hide the tab, and the tab cross-links to the Doctor for the deploy
and security findings.

* docs: document the Compose Networking tab

Add a feature page covering the Networking tab: the network facts,
published ports and host bindings, the exposure-intent classification
and inheritance, the exposure-aware Doctor findings, runtime drift, and
a troubleshooting section. Register it in the docs navigation next to
Compose Doctor.

* feat: add a redacted network summary to the Stack Dossier export

Append a network exposure section to the dossier Markdown: the stack and
per-service exposure intents, the networks with their external and
internal flags, and each service's published ports with their binding
scope. It carries only names, intents, port numbers, and scope, never an
env value or a label value.

The summary is fetched only when the user exports (copy or download), so
opening the panel costs nothing, and it degrades to omitting the section
when the data is unavailable. The whole-fleet dossier export collects the
same summary per stack, rethrowing the unauthorized sentinel like the
sibling loaders.

* feat: add a Fleet networking filter for exposure and drift

Add a per-node networking summary that classifies a node's stacks as
exposed (a host port published beyond loopback), unknown-exposure
(publishes ports with no exposure intent set), or network-drift. It
reads each stack's compose with the light dependency parser and one
Docker snapshot, so it stays cheap across a node's full stack set, and
it skips drift when the runtime is unreachable rather than inventing it.

Serve it node-locally at GET /api/networking/summary, and aggregate it
fleet-wide at GET /api/fleet/networking-summary: the hub computes its own
summary in-process and reaches each remote through its node-local route,
degrading an unreachable or older node to a skip. Because the aggregate
lives under the proxy-exempt /api/fleet prefix it is never wrongly
proxied. The Fleet overview gains a networking filter chip backed by that
aggregate, fetched fail-soft and detached so it never gates the grid.

* fix: spin the Networking refresh button while it reloads

The refresh button silently refetched the same data, so a click gave no
feedback. Track a refreshing state and spin the icon while the load is in
flight, disabling the button, matching the Compose Doctor preflight
button.

* fix: apply effective per-service exposure intent to unclassified checks

The "unclassified exposure" decisions only consulted the stack-level intent
row, so a service classified directly (with no stack row) was still reported
as unclassified, and a service explicitly marked unknown over a classified
stack was missed.

Both the exposure-unclassified preflight rule and the networking summary's
unknown-exposure bucket now resolve the effective intent per publishing
service (service row overrides stack row), matching the precedence already
used by the exposure-internal-published rule.

* fix: resolve drift network names via the compose top-level name

When a compose file sets a top-level name:, Docker prefixes resource names
with that project name instead of the stack directory. The light dependency
parser dropped name:, so network-drift normalization compared runtime
networks against directory-prefixed names and reported false
network-undeclared / network-missing findings.

Carry the parsed project name through DeclaredCompose and use it when
normalizing declared networks for drift, while still filtering containers by
the stack directory.
2026-06-12 02:15:11 -04:00
Anso bef51a979f feat(fleet): tidy the Overview toolbar and shorten tab labels (#1361)
- Move the Add node button into the Overview toolbar beside the
  Grid/Topology toggle so it sits with the view it acts on instead of
  showing on every Fleet tab. It stays admin-only.
- Collapse the node search to an icon button that expands to the full
  input on click and collapses again on blur once the query is empty,
  reclaiming toolbar width. An active query keeps it open.
- Match the sort-direction toggle and the sort dropdown to the outlined
  dark fill of the Filters button for a consistent toolbar row.
- Swap the Check Updates icon to the refresh-with-dot glyph.
- Rename two tabs: Fleet Actions to Actions, Dependencies to Map
  (display labels only; internal keys unchanged).

Update the feature docs to the new tab labels.
2026-06-12 02:10:21 -04:00
Anso f253276303 fix: make published port links open reliably (#1359)
* fix: make published port links open reliably

Container published-port links now render as real anchors that open on
desktop and mobile, replacing ad hoc window.open calls. A shared service
URL builder centralizes host resolution (configured host, remote node
API host, or the browser host, with no browser fallback for unreachable
remote nodes), protocol selection (HTTPS for port 443), and known app
sub-paths (Plex opens its web path). The container port mapping itself is
the link, with a Copy URL action beside it. The stack Open App menu and
the anatomy panel footer use the same builder, and the menu only offers
Open App when a reachable URL can be built.

* fix: skip UDP ports and scope known-app paths to the container port

Two follow-ups to the published-port links:

- The known-app path (Plex web sub-path) was borrowed from the published
  host port even when the container port was known and unregistered, so a
  non-Plex service published on host port 32400 wrongly inherited it. The
  container-port lookup now wins when known; the published-port lookup stays
  a fallback for the menu and anatomy footer, which only have the host port.

- UDP ports could surface as HTTP links. The backend now carries the port
  protocol through both container-mapping paths and skips UDP when choosing
  the main web port (extracted as selectMainWebPort), and the container card
  filters UDP before selecting a port to link.
2026-06-11 16:37:53 -04:00
Anso e3944295f2 feat: link container images to registry and source metadata (#1358)
* feat: link container images to registry and source metadata

Turn image references on the stack surfaces into actionable links so an
operator can review what an image ships before approving an update.

A new pure helper maps a reference to its registry page (Docker Hub for
official and namespace images, the owner profile for GitHub Container
Registry) and never guesses a link for unknown or private registries. A
shared dropdown adds copy-image-reference plus source, homepage,
documentation, and revision links read from the image OCI labels via a
local inspect, so they need no internet access and only appear when the
image ships them. Version and non-commit revisions render as plain text.

The menu is wired into the stack header image row, each per-container
health card, and the update-readiness cards.

* fix: invalidate image-source inspect token synchronously on image change

The request token that drops a stale image inspect response was bumped
only in a passive effect cleanup, which runs after paint. A response for a
superseded image could resolve in the gap after the image-id change
committed and still pass the token check, writing stale source labels into
the new menu. Move the invalidation to a layout effect so the token is
bumped during commit, before any network response can interleave.

Add a regression test that holds the first inspect open, changes the image
id, then resolves the stale response and asserts it is discarded.
2026-06-11 14:37:04 -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
dependabot[bot] a2fe58f62f chore(deps): bump @grpc/grpc-js (#1356)
Bumps the all-npm-backend-security group with 1 update in the /backend directory: [@grpc/grpc-js](https://github.com/grpc/grpc-node).


Updates `@grpc/grpc-js` from 1.14.3 to 1.14.4
- [Release notes](https://github.com/grpc/grpc-node/releases)
- [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.14.3...@grpc/grpc-js@1.14.4)

---
updated-dependencies:
- dependency-name: "@grpc/grpc-js"
  dependency-version: 1.14.4
  dependency-type: indirect
  dependency-group: all-npm-backend-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-11 14:02:40 -04:00
Anso e20f1fe415 feat: add an inline deploy-progress style for the stack detail (#1355)
* feat: add an inline deploy-progress style for the stack detail

Deploy progress gains a presentation choice under Settings > Appearance >
Display: Modal (the default centered overlay) or Inline. In Inline style a
compact status band on the stack detail shows the running operation, its
elapsed time, the live phase, the latest output line, and the post-update
health gate result. A "View output" button opens the full log modal on
demand, a dismiss control clears the band, and the band auto-clears a few
seconds after a clean completion.

The live progress socket is lifted to an always-mounted owner so the band
streams without the modal; the default Modal style is unchanged. Operations
carry their node so a band never bleeds onto a same-named stack on another
node.

The stack detail's redundant "CONTAINERS" section heading is removed; the
band reserves that vertical space.

* fix: keep inline deploy progress reachable off the stack detail

Review of the inline presentation found a gap: a failed operation, an App
Store install, or navigating away leaves the inline session with no visible
surface, since the band only renders on the operation's own stack detail.
Restore the minimized pill as the inline fallback, shown only when the band
is not covering the session, so there is always a click-through to the log
without ever overlapping the band. Closing the modal for a failed op now
ends the session (the band has stepped aside) instead of only hiding it.

Also document the unsupported mid-operation style switch, and refresh the
deploy-progress, settings, appearance, and app-store docs for the renamed
"Deploy progress" setting and the Modal/Inline choice.
2026-06-11 10:33:57 -04:00
Anso 38aabe7064 feat: health-gated updates and rollback readiness (#1354)
* feat: classify stack deploy and update failures with suggested next actions

Failed deploy and update responses now carry a failure classification
(cause category, headline, and suggested next step) derived from the
compose error output. The recovery panel and chip render the
classification and include it in copied diagnostics, and gateway-style
failures surface as a node-unreachable cause.

* feat: add update and rollback readiness reports for stacks

Before a manual update, Sencho now shows an advisory readiness verdict
computed from the stored preflight result, open drift findings, live
container health, the pending image change, the rollback backup slot,
and node disk headroom. The Stack Dossier gains a rollback readiness
section that states what a rollback can restore and explicitly
discloses that volume and bind-mounted data are not covered. Toolbar
and sidebar updates now share one update path, and admins can create a
fleet snapshot from the readiness dialog before updating. Nodes that do
not advertise the capability keep the direct update flow.

* feat: observe stack health after updates with a post-deploy health gate

After a deploy or update succeeds, Sencho now watches the stack for a
configurable observation window and records a passed, failed, or
unknown verdict: containers must stay running, healthchecks must report
healthy, and restart loops or disappearing containers fail the gate.
The deploy panel shows the observation live and holds off auto-closing
until the verdict lands, a failed gate surfaces the existing recovery
actions including rollback, and the stack timeline records update
started and gate verdict events. Scheduled, webhook, bulk, and
git-source updates are gated the same way; rollbacks and installs are
deliberately not. The gate is observational only and can be tuned or
disabled per node under host alert settings.

* docs: document health-gated updates and rollback readiness

New operator page covering the update readiness dialog, the post-update
health gate and its settings, the rollback readiness disclosure, and
classified failures, with cross-links from the atomic deployments and
deploy progress pages. The API reference gains the readiness and
health-gate endpoints, the healthGateId success field, and the failure
classification schema on deploy and update error responses.

* feat: withhold the success verdict while the health gate observes

An update used to show a green Succeeded that a failed health gate then
contradicted moments later. The deploy modal now reports Verifying
health while the gate observes, shows success only when the gate
passes, and makes a failed or unknown gate the headline result; success
toasts soften to a verifying message while a gate runs. The mobile
recovery card groups its actions behind one bottom-right Take action
menu so it stays compact on a phone, with the classified cause still
visible on the card. A successful image update now also counts as the
last known-good marker in rollback readiness, and the docs gain
screenshots of the readiness dialog, gate states, dossier section, and
settings.

* fix: harden log format strings and the env existence path check

Log calls that interpolated the stack name into the console format
string now use constant format strings with placeholder arguments, and
envExists validates path containment inline at its filesystem access,
matching the established patterns used elsewhere in the same files.

* test: adapt deploy modal success specs to the post-deploy health gate

The deploy feedback modal now withholds its success verdict while the
health gate observes the new containers, showing "Verifying health"
until the gate passes. The two success-path E2E tests waited for
"Succeeded" within the gate's 90s default window and timed out.

Shorten the observation window to the 15s minimum for these tests via
the settings API, assert the verify-then-succeed sequence the modal
actually renders, and restore the default window afterward so the test
value does not leak into later runs.

* fix: serialize health gate polling and harden gate observation

Address race conditions in the post-update health gate found in review.

Backend: the gate poller used setInterval, so a Docker observe slower
than the 5s tick could overlap the next poll and corrupt the restart and
missing-container accounting, and a wedged socket could leave a poll
pending forever. Polling is now single-flight: each cycle self-schedules
the next only after it settles, and the observe is bounded by an 8s
timeout so a hung probe counts as a poll error and resolves the gate
unknown after three in a row.

Frontend: the gate poller could overlap requests, letting a slow earlier
"observing" response overwrite an already-applied terminal verdict. It is
now single-flight with a terminal latch, so a late response can never
roll the UI back from passed or failed.

Also reject a non-digit nodeId on the snapshot coverage route instead of
letting parseInt coerce it, document that turning off the deploy progress
panel opts out of the live gate UI while the gate still runs server-side,
and add gate-coverage tests for the webhook, git source, and auto-update
apply paths plus the new single-flight, observe-timeout, and recovery
cases.
2026-06-11 00:26:26 -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
dependabot[bot] c2ac15b06a chore(deps): bump github/codeql-action (#1352)
Bumps the all-actions group with 1 update in the / directory: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4.36.1 to 4.36.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anso <dev@saelix.com>
2026-06-10 12:56:38 -04:00
Anso 60d138ac33 chore: raise dev auth rate limit so the full E2E suite is not blocked (#1353)
The Playwright E2E suite logs in per test (fresh context each) and has grown past the 100-attempt/15-min dev cap on the auth limiter, so the last test in a run gets 'Too many attempts' and fails. Raise the non-production cap to 1000 to restore headroom for the suite and local tooling. Production stays at 5 attempts/15min, so brute-force protection is unchanged.
2026-06-10 12:54:23 -04:00
dependabot[bot] c0b83d8326 chore(deps): bump the all-npm-backend group in /backend with 6 updates (#1351)
---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: semver
  dependency-version: 7.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1065.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1065.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:47:15 -04:00
Anso 52ff0725f4 feat: add Compose Doctor preflight checks for stacks (#1348)
* feat: add Compose Doctor preflight checks for stacks

Add an on-demand, advisory preflight that renders a stack's effective
Compose model with `docker compose config` and runs a registry of
deterministic checks before deploy, surfacing findings grouped by
severity (blocker, high, warning, info) with a remediation for each.
Findings cover unset env vars, host-port conflicts on the node, broad
0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket,
privileged and host networking, moving image tags, missing restart
policy and healthcheck, Swarm-only deploy fields, missing external
networks or volumes, and container_name collisions.

The report is node-scoped and stored as the last run per stack, and the
route auto-proxies to the active node so a remote stack is checked on
the node that owns it. A new Doctor tab on the stack detail panel runs
preflight and shows the grouped findings, with a severity dot on the tab
when the last run has blocker or high findings. The tab is gated on a
compose-doctor capability so older nodes hide it.

No environment value is ever stored, returned, or logged: only env key
names and structural facts are read, and render failures surface a
generic message or the missing required-variable names, never raw
stderr.

* fix: scroll the stack tab strip when its tabs overflow

Adding the Doctor tab can push the per-stack Anatomy tab strip past the
panel width on narrower layouts. Make the tab row scroll horizontally
with subtle edge fades that appear only while there is more to scroll in
that direction, so a panel wide enough to show every tab is unchanged.

* fix: add clickable arrows and wheel scroll to the stack tab strip

Hiding the scrollbar left mouse users with no way to scroll the
overflowing tab row: a vertical wheel does not move a horizontal overflow
and native rows do not drag-scroll. Replace the passive edge fades with
clickable chevron arrows shown only when the row overflows that edge, and
translate a vertical wheel over the row into horizontal scroll.

* fix: inline the path-injection barrier in renderConfig

CodeQL's path-injection check does not credit the wrapped isPathWithinBase
helper as a sanitizer, so move the containment check inline at the spawn
cwd sink, matching the canonical barrier used elsewhere in the codebase.
Behavior is unchanged: the resolved stack directory must be contained in
the compose base and may not be the base itself.

* fix: hoist the compose-config spawn into the path-barrier scope

The earlier inline barrier sat in a different scope than the spawn cwd
sink (separated by the Promise-executor closure) and used a compound
guard, so CodeQL did not credit it. Use the exact canonical startsWith
barrier and hoist the spawn into the same scope as the check. Behavior
is unchanged: the executor runs synchronously in the same tick as the
spawn, so handlers still attach before any event can fire.
2026-06-10 11:35:39 -04:00
Anso d369b03a38 feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions

Add a backend idle-output backstop that stops a deploy/update compose step
that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a
hung image pull surfaces a fast failure instead of spinning indefinitely.

Surface failed, timed-out, and stalled operations with recovery actions on
the stack page: a desktop chip plus popover menu and an inline mobile card
offering retry, restart, roll back (when a backup exists), refresh state,
and copy diagnostics, all gated by deploy permission. The streaming
deploy/update progress modal is now on by default and warns when output
goes quiet. Container state is refreshed after a failed or stalled
operation, and the UI never sits in an indefinite spinner.

* fix: harden rollback against policy-blocked file mutation and refine recovery

Address review findings on the stalled-update recovery work:

- The rollback route restored backup files before running the policy gate, so
  a policy-blocked rollback could leave the on-disk config rolled back while the
  deployed containers were unchanged. Snapshot the current files first and
  revert them when the gate blocks; if that revert itself fails, escalate it on
  the persistent alert feed since the 409 is already sent.
- Refresh container state after a successful manual rollback (rollback
  redeploys), without mis-recording a refetch failure as a rollback failure.
- Suppress the stalled-output warning once live progress is unavailable.

* test: mock snapshotStackFiles in the atomic-deploy rollback route tests

The rollback route now snapshots stack files before restoring a backup, so its
FileSystemService mock needs snapshotStackFiles. Without it the mocked call
threw and the route returned 500, failing the success-path rollback assertions.
2026-06-10 10:12:24 -04:00
Anso a3033a848e ci: scope CodeQL e2e tmpfile suppression via paths-ignore (#1346)
The js/insecure-temporary-file rule fires on e2e Playwright specs that
seed fixtures into the backend's COMPOSE_DIR (a fixed /tmp path) so the
API under test can read them back. A randomized mkdtemp cannot apply
there: the backend resolves paths against its own COMPOSE_DIR, so a
fixture written elsewhere would be invisible to it.

The prior suppression used a paths key inside a query-filters exclude,
which CodeQL ignores: query-filters match on query metadata, not source
path. That left the rule firing on every new e2e spec. Move the
exclusion to a top-level paths-ignore, the only mechanism that scopes
analysis by source path, so the e2e specs stop tripping the rule.
2026-06-09 21:52:27 -04:00
sencho-quartermaster[bot] fd10c49ee4 chore(main): release 0.91.1 (#1344)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
Co-authored-by: Anso <dev@saelix.com>
v0.91.1
2026-06-09 21:08:13 -04:00
Anso 05e7db58e4 chore(deps): bump frontend dependencies (#1345)
Apply the queued frontend dependency group update (29 packages, notably
@xyflow/react 12.11.0, radix-ui minors, react 19.2.7, vite 8.0.16, and
vitest 4.1.8) and regenerate the lockfile.

@xyflow/react 12.11.0 retyped OnNodeDrag so the drag event is the DOM
MouseEvent | TouchEvent rather than a React synthetic event. Update the
FleetTopology onNodeDragStop handler signature to match.
2026-06-09 20:55:43 -04:00
Anso e7895c889d fix: base Stack health uptime on container start, not creation (#1341)
The dashboard Stack health UP column counted from each container's
Created timestamp, which never moves on stop/start or restart, so a
restarted container kept reporting its original age. Resolve uptime from
State.StartedAt (via a briefly cached inspect with bounded concurrency,
falling back to Created when inspect is unavailable) so it reflects the
real time since last start.

The current CPU and MEM columns separately summed the latest sample per
container with no recency filter, letting a recently stopped container's
final reading linger in the totals. Drop samples that trail the freshest
sample by more than the stale window so stopped containers leave the sum.
2026-06-09 20:16:04 -04:00
Anso 3dc8199907 ci: publish a dev integration image on every push to main (#1343)
Add docker-dev.yml: on each push to main (and manual dispatch) it builds
the multi-arch image, runs the same Trivy and smoke-test gates as the
release build, and pushes ghcr.io/studio-saelix/sencho-dev with the tags
:dev (moving) and :dev-<short-sha> (immutable), so a maintainer can pull
and test the exact artifact queued for the next release before it can
become a public release.

The integration path is GHCR-only and non-promotable: no Docker Hub, no
latest/semver tags, no cosign signing, no SBOM/VEX, and no GitHub Release.
All of that stays release-only in docker-publish.yml, driven by v* tags.
Least-privilege permissions (contents:read, packages:write) and no
production environment, so dev images publish automatically with no
Docker Hub credentials in scope.

Also add a clarifying header comment to docker-publish.yml noting it is
the release-only path.
2026-06-09 19:58:44 -04:00
Anso 8e4a09f44a chore: drop stale prototype path from mobile-ui comment (#1342)
The mobile primitives comment pointed at a design prototype folder that
no longer exists. Remove the dead path reference; the comment still
describes where the primitives came from.
2026-06-09 19:01:32 -04:00
sencho-quartermaster[bot] 4a619e69f4 chore(main): release 0.91.0 (#1325)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
Co-authored-by: Anso <dev@saelix.com>
v0.91.0
2026-06-08 11:25:51 -04:00
Anso 45c004e1cd docs: reflect Community tier rebalance for scanning, audit log, and registries (#1340)
Update tier mentions for the capabilities now available on Community:
- Trivy managed auto-update, single-scan SBOM export, node labels, and the
  fleet topology layout modes are present-tense Community features.
- The audit log documents a 14-day recent-activity window on every tier, with
  CSV/JSON export, anomaly detection, and configurable retention as Admiral.
- Private registries split Docker Hub / GHCR / custom (every tier) from AWS ECR
  (Admiral). SARIF export and deploy enforcement stay Admiral.

Also removes two fence-spec phrasings (per-tier label cell and 'visible on
Admiral' for the audit tab) in favor of stating each requirement once.
2026-06-08 08:59:58 -04:00
Anso 2298f470bd feat: allow local Docker Hub, GHCR, and custom registry credentials on Community (#1338)
Private registry credentials are no longer paid-only. Community admins can add
and manage Docker Hub, GHCR, and custom/self-hosted registry credentials, stored
locally on the node. AWS ECR (short-lived token refresh, AWS region) stays on the
paid tier.

Backend gates ECR per-type via a single helper applied at create, update (using
the effective type so an existing row cannot be switched to ECR), the per-id
connection test (gated on the stored type), and the stateless test. The admin
role and API-token-scope rejection are unchanged on every route. The frontend
drops the blanket paywall on the Registries section, filters ECR out of the type
selector for Community, and states the ECR requirement inline.
2026-06-08 08:59:27 -04:00
Anso ea1267b32f feat: give Community a 14-day in-app audit log (#1337)
* feat: give Community a 14-day in-app audit log

The audit-log list view is now reachable on the Community tier, scoped to a
rolling 14-day recent-activity window, with the existing filters (actor,
method, search, date range). Full retention, CSV/JSON export, and per-row
anomaly annotation remain on the paid tier.

Backend clamps the list window for unpaid tiers and forces anomaly annotation
off; a non-numeric from query value is normalized so it cannot lift the clamp.
The stats and export endpoints keep their paid gate. The frontend hides the
export control and the anomaly stat tiles for Community and omits the anomaly
request. Audit entries are still captured for every tier, so only read access
changes.

* test: repoint distributed-license stand-in to a still-paid audit route

The distributed-license trust-chain test borrowed GET /api/audit-log as a
paid-gated, DB-reading stand-in. That route is now Community-accessible, so its
six 403 PAID_REQUIRED assertions flipped to 200. Point the stand-in at
GET /api/audit-log/stats, which keeps requirePaid plus the system:audit
permission gate and is satisfied by both token types the test uses.
2026-06-08 08:58:49 -04:00
Anso 54119be0c2 feat: move trivy auto-update, node labels, fleet topology, and single-scan SBOM to Community (#1336)
Rebalance several capabilities from the paid tier to the free Community tier:

- Managed Trivy auto-update toggle is admin-only, no longer tier-gated.
- Node labels (assign, view, manage) are available on every tier; cordon and
  FleetSync anchor reset stay paid.
- Fleet topology layout modes (Hub, Grouped, Free) are available on Community.
- Single-scan SBOM export (SPDX and CycloneDX) is admin-only on Community;
  SARIF export stays paid via a dedicated canExportSarif capability split out
  from the former shared SBOM flag.

Backend route guards and frontend affordances are updated together, with tier
and admin-role tests covering the Community-allowed and still-paid paths.
2026-06-08 08:58:14 -04:00
Anso 710647a44f feat(snapshots): preserve stack dossiers with fleet snapshots (#1339)
* feat(snapshots): preserve stack dossiers with fleet snapshots

Fleet snapshots can now optionally capture each stack's Dossier notes
alongside its compose and .env files, so a recovery restores the
operational knowledge around a stack, not just its configuration.

- Opt-in global setting "snapshot_documentation" (default off), toggled
  from the renamed Fleet settings section.
- Capture reads local dossiers from the database and remote dossiers over
  the Distributed API proxy; only stacks with notes are recorded, and
  secret values are never included.
- Captured notes are stored encrypted at rest in a new fleet_snapshots
  column and surfaced in the snapshot detail view behind a badge.
- Cloud and downloaded archives gain a documentation.json (archive_version 2).
- Restore stays conservative: dossier notes are written back only when the
  operator explicitly opts in, on both single-stack and restore-all paths.
- Existing snapshots and archives remain valid; behavior is unchanged when
  the setting is off.

* fix(snapshots): harden dossier-notes restore against bad input and partial failures

Address review findings on the documentation-snapshots restore path:

- Parse `restoreNotes` strictly (=== true) on single-stack restore, matching
  restore-all, so a stray non-boolean can never opt in to overwriting notes.
- Guard findSnapshotDossier: require an array of stacks and real dossier
  content, so a malformed or all-blank entry can't clobber current notes.
- Make the dossier-notes write non-fatal relative to the file restore: a notes
  failure (e.g. a remote dossier PUT) is caught, reported via `notesError`, and
  no longer 500s the single restore or fails the stack in restore-all once the
  files are already written.
- Surface the partial outcome in the UI: a warning toast on single restore, a
  summary note on restore-all, and gate the "Documentation captured" badge and
  restore-all notes control on captured stacks while rendering capture warnings.

Adds tests for strict parsing, malformed/blank blobs, remote notes restore
(success + non-fatal failure, single and bulk), and scheduled capture-on.

* fix(snapshots): drop unused binding in restore-all remote notes test

The restore-all remote notes test destructured a node id it never uses
(restore-all is driven by snapshot id alone), tripping no-unused-vars and
failing the lint step. Bind only the snapshot id.
2026-06-08 08:44:59 -04:00
Anso 842ee7dd0c feat(fleet): export a whole-fleet Markdown dossier (#1334)
* feat(fleet): export a whole-fleet Markdown dossier

Add an admin-only "Export Dossier" action to the Fleet view that walks
every node and stack, pairs each stack's generated Compose anatomy with
its operator notes, and downloads a folder-structured homelab-dossier.zip
(index, per-node and per-stack pages, plus fleet-wide port, volume,
network, env, access-URL, and VLAN/firewall maps).

Reuses the existing stack dossier and anatomy Markdown generators by
extracting the shared Compose parsers into a frontend lib module.
Unreachable nodes are recorded with a reason and never block the export;
only env variable names and counts are ever emitted, never values.

* fix(fleet): unique stack slugs and reproducible dossier archive

Disambiguate stack names on one node that slugify to the same value (e.g.
`Web` and `web` on a case-sensitive host) with a per-node slug map shared by
the node-page links and the file emission, so neither overwrites the other.
Pin a fixed entry timestamp on the zip so the archive bytes are a pure
function of the file map rather than the wall clock.
2026-06-07 21:10:02 -04:00
Anso b21324f97a feat(stacks): persist a drift ledger with temporal source-change detection (#1333)
* feat(stacks): persist a drift ledger with temporal source-change detection

Build on the read-only compose-vs-runtime drift check so a stack's drift is
remembered over time, not just shown at a glance.

- Record a deploy baseline: on a successful deploy, update, or rollback, store
  the deployed compose file's source and rendered-model hashes on the stack so
  the Drift tab can tell whether the file has changed since the last deploy.
- Surface temporal drift in the Drift tab: "matches last deploy", "source
  changed since last deploy" (distinguishing a model change from a
  formatting-only edit), or "no deploy baseline yet".
- Persist findings into a drift ledger: a re-check reconciles the current
  findings, recording newly detected ones and resolving cleared ones, and shows
  a short drift history under the findings. The drift report read stays
  side-effect-free; only an explicit re-check (and a deploy) writes the ledger.
- Write drift detected/resolved events to the stack Activity timeline so the
  provenance sits alongside deploys and restarts.

Node-local and available on the Community tier. Reconciliation is skipped when
a check is not authoritative (Docker unreachable or a compose parse error) so an
open finding is never falsely cleared.

* fix(stacks): record the drift baseline for every deploy path and harden the ledger

Address review feedback on the drift ledger:

- Record the deploy baseline in ComposeService.deployStack/updateStack instead of
  only the manual route, so bulk, Git-source, App Store, scheduler, and webhook
  deploys all capture source/rendered hashes. Reconciliation stays on the explicit
  re-check.
- Store no rendered baseline when the local parser cannot model the compose (for
  example a file over the parse cap) rather than a sentinel that would make a later
  real change read as unchanged.
- Let temporal-overlay failures surface as a 500 instead of being hidden behind a
  neutral "no baseline"; only the compose read stays best-effort.
- Omit the temporal card entirely when a report (for example from an older remote
  node) carries no temporal data, instead of showing a misleading "no baseline".
- Keep drift_detected / drift_resolved history-only by excluding them from the
  routable-category whitelist, so they are never offered as a channel route that
  would never fire.
- Use a JSON separator for the finding identity key so the source file is plain
  text (no embedded control byte).

* fix(stacks): sanitize logged errors in the drift report handlers

The drift report and re-check handlers logged the caught error object
raw alongside the stack name, which a code scan flagged as a
log-injection vector: a crafted stack name surfacing inside an error
message or stack could forge log lines. Route the error through the log
sanitizer so control characters are stripped before writing. Render it
with util.inspect first so the stack trace, cause chain, and underlying
error codes are preserved for debugging.
2026-06-07 20:44:22 -04:00
Anso 421177e4a6 feat(stacks): add compose-vs-runtime drift detection (#1329)
* feat(stacks): add compose-vs-runtime drift engine

Add a read-only engine that compares a stack's on-disk compose model
against the live Docker runtime and reports where the two diverge.

GET /api/stacks/:stackName/drift returns a per-stack report with a
status (in-sync, drifted, missing-runtime, unreachable) and typed,
service-scoped findings: a declared service with no running container,
a running container not declared in compose, an image mismatch, and a
published-port mismatch. The report is computed at request time with no
persistence and is available on every tier.

The check reuses the existing compose parser and Docker dependency
snapshot; the compose parser now also captures each service's declared
image. Boundaries fail closed: an unreadable compose file reports
drifted and an unreachable Docker daemon reports unreachable, never a
false in-sync.

* fix(stacks): keep drift hasContainers accurate on compose parse error

assembleStackDrift hardcoded hasContainers: false on the parse-error
path, contradicting the field's contract when the runtime actually has
running containers. Compute it once from the container set and reuse it
across all return paths.

Also add a route test that exercises the successful 200 path for an
existing stack on the Community tier (stubbing only the Docker boundary),
so a tier gate or handler regression after the existence check is caught.

* feat(stacks): add a drift detection tab to the stack view

Surface the compose-vs-runtime drift report on the per-stack Anatomy
panel as a read-only Drift tab. It shows the stack's status (in sync,
drifted, not running, unreachable) and, when drifted, the specific
service-scoped reasons with the declared and running values side by
side. A re-check action reruns the comparison.

The tab lives in the shared anatomy panel, so it appears on both the
desktop stack view and the mobile stack detail. Available on every tier.

* fix(stacks): sanitize the logged error in the drift report builder

The compose-read and Docker-snapshot catch blocks logged the raw error
object, whose message can embed the user-controlled stack path (e.g. an
ENOENT path). Log the error through the existing sanitizer so a crafted
stack name cannot forge log lines, matching the pattern used elsewhere
in the stacks router.
2026-06-07 15:48:04 -04:00
Anso 8302048bc4 test(mobile): declare per-view mobile treatment and harden the visual gate (#1332)
* test(mobile): single-source map for per-view mobile treatment

Declare how every top-level view behaves on a phone in one place:
MOBILE_TREATMENTS is a Record<ActiveView, ...>, so adding a new view without
classifying it (bespoke / responsive / desktop-only / detail) fails the type
check. BESPOKE_MOBILE_VIEWS is derived from it instead of hand-maintained, and
a unit test keeps the two in lockstep and pins the current bespoke set so a
change is deliberate. EditorLayout consumes the derived set; behavior is
unchanged.

* ci(visual): run the desktop-unchanged gate in its own job

Split the visual-regression spec into a dedicated Playwright "visual" project,
excluded from the default chromium project the functional E2E job runs, so a
missing or platform-mismatched baseline can no longer fail every PR.

Add a Visual Regression workflow: a compare job gates PRs into main against
committed baselines (and skips with a warning until they are seeded), and a
manual seed job regenerates the baselines on the Linux runner and commits them
to a feature branch (refusing main). Baselines are platform-specific, so they
must be produced on the runner rather than locally.

Drop the stack-detail view from the gate: a fresh CI app has no stack to open
and its live log stream is not deterministic; the shell plus the four content
views still catch a desktop base-class regression.
2026-06-07 14:56:49 -04:00
Anso 928a3a8343 feat(mobile): bespoke phone layouts for dashboard, fleet, schedules, and settings (#1330)
* feat(mobile): masthead-led dashboard and 5-tab bottom nav on phones

On phones (below the md breakpoint) the dashboard now renders a bespoke,
masthead-led layout instead of the reflowed desktop workspace:

- A status masthead leads with the overall system-health verdict, the node,
  and a live summary (stack counts, last sync, a "metrics stale" marker when
  polling stops).
- A CPU hero card with a sparkline, then a memory / disk / network strip with
  threshold-colored bars, then a tappable stack-health list.
- The bottom tab bar gains a Home tab (Home / Stacks / Fleet / Sched /
  Settings); the global top bar is dropped on this screen, with notifications
  and a "more" menu rehomed into the masthead.

The health-verdict logic is extracted into a shared helper so the phone
masthead and the desktop health bar read from one source, with unit tests.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged (verified against the desktop snapshot gate).

* feat(mobile): bespoke fleet glance and node detail on phones

On phones (below the md breakpoint) the Fleet view now renders a bespoke,
masthead-led layout instead of the reflowed desktop workspace:

- A fleet masthead leads with the overall fleet-health verdict and a running /
  cpu / mem summary band, then a list of node cards. The local node is marked
  with a cyan rail and a "you are here" tag; offline nodes are dimmed.
- Tapping a node opens a full-screen node detail: state pill, resource bars
  (cpu / mem / disk), the stacks running on that node, and an Inspect action
  that switches to the node. Operators with the right permissions also get a
  Drain (cordon) action.
- The screen polls the fleet overview every 30 seconds; the global top bar is
  dropped here, with notifications and a "more" menu in the masthead.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged.

* feat(mobile): bespoke schedules and settings screens on phones

On phones (below the md breakpoint) Schedules and Settings now render bespoke,
masthead-led layouts instead of the reflowed desktop workspace:

- Schedules: a "next up" glance leading with the next run time and countdown,
  then upcoming runs grouped by day with a per-action status dot and target.
  It is read-only on mobile; creating and editing schedules stays on desktop.
- Settings: a grouped-card list of every reachable section; tapping one opens
  it full-screen with a back affordance and a section masthead. The section
  content itself is the same as on desktop.

The settings section switch, lazy-loaded section chunks, and tier gating are
moved into a shared component so the desktop and mobile screens render the same
section content from one place. The global top bar is dropped on both screens,
with notifications and a "more" menu in the masthead.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged.

* fix(mobile): show notifications and more-menu on the stack detail header

The full-screen stack detail on phones drops the global top bar, but its
header was missing the notifications bell and the "more" navigation menu that
the other mobile screens carry in their masthead, leaving no way to reach
notifications or other destinations while viewing a stack. Render the same
header-actions cluster in the detail header (and the loading placeholder),
next to the back affordance. Desktop is unaffected.
2026-06-07 01:15:16 -04:00
Anso 2072378396 feat(ui): make Fleet, Settings, and the dashboard table usable on mobile (#1331)
* feat(ui): make Fleet, Settings, and the dashboard table usable on mobile

Tier 2 of the mobile pass, all gated below the md breakpoint so desktop
renders identically:
- Fleet: the tab strip scrolls horizontally and the action row (Check
  Updates / Refresh / Add Node) wraps instead of clipping.
- Settings: below md the nav rail is a full-screen list; choosing a section
  pushes it full-screen with a back affordance (master/detail), matching the
  stack flow. Desktop keeps the two-pane layout.
- Dashboard: the fixed stack-health table scrolls horizontally on a phone.

Adds a Playwright desktop visual-regression spec (1280/1440/1920) as a
zero-desktop-change gate; its environment-specific snapshots are gitignored.

* test(ui): harden the desktop visual-regression gate

Mask only the live sidebar ticker and notification count instead of the whole
sidebar / top-bar shell, so the gate now proves that shell unchanged too.
Lower the pixel budget to 1200 (the only residual churn is in-content live
stats); run against seeded / frozen data in CI for a zero-tolerance gate. Adds
a data-testid to the activity ticker so it can be masked precisely.
2026-06-07 01:14:27 -04:00
Anso e8f271f5f6 feat(ui): make the core stack flow usable on mobile (#1327)
* feat(ui): make the core stack flow usable on mobile

Below the md breakpoint the app collapses to a single full-width column:
the stack list is full-screen, tapping a stack opens a full-screen detail
with a Health / Logs / Compose segmented control (Logs first) and a back
button, and a bottom tab bar switches Stacks, Fleet, Schedules, and
Settings. Compose is read-only on a phone with a prompt to edit on desktop.

Desktop (md and up) is unchanged: the mobile shell is gated behind a
useIsMobile hook plus max-md/md variants, and the stack-detail blocks are
shared with the desktop two-pane view so it renders identically.

Also generalizes the unsaved-changes guard so leaving a dirty editor (back,
tab bar, hamburger) prompts before discarding; adds 44px touch targets on
list rows, filter chips, and actions; makes log and shell modals full-screen
on mobile; and offsets toasts and the deploy pill above the bottom tab bar.

* fix(ui): keep mobile nav in sync when opening views from outside the bottom bar

On a phone the sidebar activity actions, the node switcher's Manage Nodes, the
profile Settings entry, and the dashboard configuration links set the active
view without flipping the mobile surface to content, so the user stayed on the
stack list and never saw the destination. Route these through the mobile-aware
navigation and settings helpers (a no-op on desktop).
2026-06-07 01:03:13 -04:00
Anso 57fe430db8 feat(stacks): add Stack Dossier tab with operator notes and Markdown export (#1326)
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export

Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It
shows a read-only summary auto-derived from the stack's Compose anatomy
(services, ports, volumes, network, restart policy, env file, source) plus an
editable form for the context Sencho cannot infer: purpose, owner, access URLs,
static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and
custom notes.

Notes persist per stack and per node in a new stack_dossiers table, reached
transparently through the remote-node proxy so a remote stack's dossier
round-trips to the node that owns it. Reading a dossier needs stack read
permission; saving needs stack edit. The tab exports a single Markdown document
combining the generated facts and the operator notes, with copy-to-clipboard and
download actions; env values are never exported, only variable names and counts.

Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is
removed since the Dossier export supersedes it.

* fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete

A dossier endpoint validated the stack name but not that the stack exists, so an
editor could PUT a dossier for a name with no stack, leaving an orphan row that a
later stack of the same name would inherit. Require the stack to exist (existing
requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise.

Also clear a node's stack_dossiers rows when the node is deleted, alongside the
other node-scoped cleanup, so removing a node leaves no orphan dossiers.

Docs: scope the "no secret exported" statement to the generated facts (which only
ever carry variable names and counts) and clarify that operator notes are
exported exactly as written.
2026-06-06 22:21:30 -04:00
Anso af4083175c feat(fleet): add read-only dependency map tab (#1324)
* feat(fleet): add read-only dependency map tab

Add a fleet-wide Dependencies tab to Fleet view that maps how stacks,
services, networks, volumes, and ports relate, with flags for missing
dependencies, port conflicts, orphaned resources, and cross-stack shared
resources. Read-only; filterable by stack, node, and flag; collapsed by
default with a list-view fallback at scale.

The graph is derived at request time from Docker and compose metadata, so
no new table or persisted state is introduced. A per-node graph endpoint
feeds a hub aggregation endpoint that fans out across the fleet and
degrades gracefully, surfacing unreachable or unparseable nodes inline
while the rest of the map still renders.

* fix(fleet): harden dependency map flag detection and remote merge

Address review findings on the dependency map:
- Port-conflict detection now does pairwise host-scope overlap, so an
  unrelated bind on the same port and protocol but a different specific host
  IP is no longer flagged, and the flag lands on the exact scoped port node.
- A running service's depends_on target is only considered satisfied when it
  is actually running, so a crashed (exited) dependency is surfaced while a
  deliberately stopped stack stays quiet.
- Declared external networks and volumes are reported missing when they do
  not exist on the host instead of being assumed present.
- The hub deep-validates each remote node-graph payload before merging, so a
  reachable-but-malformed remote degrades to a single node error rather than
  failing the whole fleet map, and the validation failure is logged.
- Searching or filtering on a network, volume, or port now also reveals the
  services that claim it and their stacks.
2026-06-06 11:18:36 -04:00
Anso 13b6acab16 feat(stacks): copy stack anatomy as Markdown (#1323)
* feat(stacks): copy stack anatomy as Markdown

Add a "copy md" action to the Stack Anatomy panel that exports the parsed
compose summary as a deterministic Markdown document: services, ports and
volumes tables, restart policy, env file with its variable count, missing
variables, network, and source. Useful for pasting a stack's wiring into a
README, a Git repository, Obsidian, BookStack, or a support thread.

The export carries only env variable names and counts, never the values
stored in .env files.

* fix(stacks): escape backslashes in anatomy Markdown table cells

escapeCell escaped pipes but not the backslash itself, so a backslash in a
cell value (for example a Windows path) could defeat the pipe escaping and
break the rendered table row. Escape the backslash first, then pipes, then
collapse newlines. Adds a regression test.

* fix(stacks): correct git source attribution and CR handling in anatomy export

Gate the Anatomy panel's git source on its owning stack so a slow git-source
response for a previously selected stack can no longer render or be exported
under a different stack. Also collapse lone carriage returns (not just CRLF and
LF) in Markdown table cells so a stray CR cannot break a table row.
2026-06-06 11:02:39 -04:00
sencho-quartermaster[bot] 7df5ffde54 chore(main): release 0.90.0 (#1317)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.90.0
2026-06-05 23:03:29 -04:00
Anso 86bfc108ae fix(security): resolve open CodeQL path-injection and temp-file alerts (#1322)
Re-establish the path-containment barrier inline at the backup readdir
sink in restoreStackFiles. The sink previously built backupDir through
the getBackupDir helper, whose stack-name validation the static analyzer
does not trace, leaving a flagged path-injection sink. The barrier now
resolves backupDir against its root and asserts containment inline, the
same pattern already used in backupStackFiles. Behavior is unchanged for
valid stack names (already validated one line above by resolveStackDir).

Scope the js/insecure-temporary-file rule out of e2e/** in the CodeQL
config. End-to-end fixtures must seed files into the backend's COMPOSE_DIR
so the API under test can read them back; that path is a fixed location
under /tmp in both CI and local dev, which the rule flags. A randomized
temp directory does not apply because the backend resolves against its own
COMPOSE_DIR. Production code is still analyzed.
2026-06-05 23:02:08 -04:00
Anso ce08a593d7 feat(settings): reorganize the settings hub into domain groups (#1321)
* refactor(settings): split System Limits and regroup the hub

System Limits had grown into a grab-bag of host alert thresholds, Docker
cleanup, and mesh data-plane controls under one mislabeled section. Split it
into Host Alerts, Docker & Storage, and Fleet Mesh, and split Developer into
Developer Diagnostics and Data Retention. Reorganize the sidebar into ten
domain groups: Personal, Access, Infrastructure, Monitoring, Notifications,
Automation, Organization, Security, Operations, Help.

Each section now saves only its own keys, so a concurrent edit in one section
no longer clobbers another. Data Retention sends the audit-log window only on
a paid plan, matching the field's existing visibility, so a Community save no
longer fails on a key the operator cannot set. NumberChip moves to a shared
module and the toggle reuses the existing shared component. The /settings API
is unchanged.

* test(settings): cover registry structure and per-section save payloads

Add structural invariants for the ten-group registry (every item maps to a
real group, ids are unique, the System Limits and Developer splits land in the
right groups with the right gates, renamed labels and the Registries paid gate
hold) and per-section payload tests asserting each split section patches only
its own keys, including the Community path where Data Retention omits the paid
audit-log key.

* docs(settings): document the regrouped settings hub

Rewrite the settings reference for the ten-group layout, replace the System
Limits page with Host Alerts, Docker & Storage, and Fleet Mesh, and document
the prune-on-update, reclaimable-space banner, and mesh auto-recreate settings
that were previously undocumented. Update the Settings navigation breadcrumbs
across the feature docs and refresh the affected screenshots.

* fix(settings): show Access sections as instance-global, not operator-scoped

License, Users, SSO, and API Tokens are instance-global settings but the
masthead scope label rendered them as operator-scoped because it keyed off the
old Identity group. Only Personal sections (account, appearance) are
operator/browser-scoped now; everything else reads as global.

Also add a compile-time exhaustiveness guard to the section switch so a future
SectionId added without a matching case fails the build instead of silently
rendering a blank panel.

* docs(settings): remap remaining settings breadcrumbs to the new groups

Update the navigation breadcrumbs that still pointed at the removed Identity,
Alerts, and Advanced groups: API Tokens and Users now sit under Access, Webhooks
under Automation, Labels under Organization, App Store under Infrastructure,
Appearance under Personal, and scan policies under Security > Vulnerability
Scanning. Correct the settings reference scope note so Access reads as global.

* docs(settings): remap renamed-section breadcrumbs across feature docs

Sweep every feature, operations, getting-started, and reference page for
navigation paths that still named the renamed settings sections, and point them
at the current ones: Security becomes Security > Vulnerability Scanning,
Notifications becomes Notifications > Channels, Routing becomes Notifications >
Notification Routing, and Developer becomes Operations > Developer Diagnostics
(with its retention windows under Operations > Data Retention). App Store moves
under Infrastructure and the four-group overview in the getting-started intro is
rewritten to the ten groups. Separators each page already used are preserved.
2026-06-05 23:01:37 -04:00
Anso f7f3afe05a feat(stacks): one-click import for stray compose files (#1320)
* feat(stacks): move discovered import candidates into place

The guided import flow previewed loose and nested compose files but could
not act on them, so it only told the user where to move files by hand. Add
an opt-in "Move into place" action: relocate a loose-root file into its own
<name>/ subfolder, or promote a nested stack directory one level up, so
Sencho's filesystem discovery lists it as a stack. The file stays a plain
compose file on disk; nothing is captured into a store. The move re-derives
the candidate from a fresh scan and matches by location, validates the
destination name and containment, resolves symlinks before the rename, and
never overwrites an existing stack. Backend and frontend both gate the
action on stack:create.

Also fix the rescan flicker: scan results now stay on screen while a rescan
runs (only the Rescan button shows progress) instead of the whole panel
collapsing to a spinner, and an empty rescan surfaces a toast.

* fix(stacks): make import-move destination creation atomic

The loose-root branch created the destination directory with mkdir
recursive after an access() existence precheck. If the destination
appeared between the check and the create, recursive accepted the
existing directory and the following rename could overwrite a
same-named compose file inside it, so the intended conflict response
never fired. Use a non-recursive mkdir so a destination that already
exists raises a conflict instead of being merged into. Add a regression
test that forces the precheck to miss and asserts the existing file is
left intact.

* fix(stacks): only offer not-yet-imported compose files in the import tab

The import tab listed every compose file in the compose directory,
including ones that are already stacks (a top-level subfolder with a
compose file), which just duplicated the sidebar. The scan now skips
those and surfaces only files that still need importing: a compose file
loose at the compose-dir root, or one nested a folder too deep.

Also harden the move-into-place write path that turns a stray file into
a stack: a failed rename after the destination folder is created now
rolls back the empty folder, so a retry is not blocked by a false
"already exists" conflict, and the move switches on an exhaustive set of
placements so a new one cannot silently take the wrong branch. The
sidebar refreshes after a move so the imported stack appears right away,
and the docs describe import as relocating a file, not capturing running
containers.

* fix(stacks): reject a nested import whose compose file escapes the base

The move-into-place path for a nested compose file validated only the
parent directory's real path, not the compose file itself. A directory
that is real and inside the compose base but holds a compose file
symlinked outside the base would survive the directory move and become a
stack whose compose file still points outside the base, which the editor
read path would then follow. The move now resolves the compose file too
and refuses it unless it stays inside the resolved source directory,
matching the loose-root check and the scan's preview reader.

* fix(stacks): satisfy CodeQL path and log analysis in import-move

The import-move write path built its destination directory from the
user-provided stack name through resolveStackDir, whose containment
barrier is wrapped in a helper that static analysis does not credit, so
every filesystem sink on the destination was flagged as path injection.
Re-establish the resolve-against-the-safe-base plus startsWith barrier
inline at the sinks, matching the read and backup paths in the same file,
and route the relocated file path through the same check. The name is
already restricted to an alphanumeric, hyphen, and underscore allowlist,
so the containment can never actually fail; this only makes the existing
safety visible to the analyzer.

Also log the move route's error as a sanitized message rather than the
raw error object, so a name embedded in an error message cannot forge log
lines.
2026-06-05 22:35:04 -04:00
Anso 28ea610e81 refactor(masthead): remove stat-tile hover tooltips (#1319)
Drop the cursor-following hover tooltips from the masthead stat tiles: the RUNNING
tile on the Home dashboard (the managed / external / exited breakdown) and the
CONTAINERS tile on the Fleet view (the running / total split). Both revert to plain
stat tiles, matching the CPU and MEM tiles beside them. The dashboard and fleet docs
are updated to match and the orphaned screenshot is removed.
2026-06-05 21:53:14 -04:00
Anso 308949282c feat(resources): reclaim banner controls and accurate reclaim math (#1318)
* feat(resources): reclaim banner controls and accurate reclaim math

Make the Resources Hub reclaim banner match what it advertises and give
operators control over when it appears.

- "Review & prune" now reclaims every category the banner lists (unused
  images, stopped containers, and dangling volumes) instead of images
  only, so the banner clears in one action. Pruning runs volumes first,
  while stopped containers still reference their named volumes, so a
  stopped stack's data is never cascaded into deletion.
- Add a "Show reclaimable-space banner" toggle under Settings, System,
  Docker hygiene (on by default, per node) and a dismiss control on the
  banner that snoozes it until the reclaimable total grows again.
- Fix the reclaimable-space math: count only containers a prune can
  actually remove (created, exited, dead) and size them by their writable
  layer, so a small, un-prunable remainder no longer keeps the banner up.

* fix(resources): show the reclaim banner when the settings fetch fails

A failed or empty /settings load left the banner's enabled flag at the
previously active node's value, so switching from a node with the banner
turned off to a node whose /settings errored kept the new node's banner
hidden. Set the flag unconditionally after the staleness guard so a
failed fetch falls back to the default-on state for the current node.
2026-06-05 18:59:33 -04:00
Anso 716daf77d0 feat(updates): auto-prune dangling images after updates (#1316)
* feat(updates): auto-prune dangling images after updates

Each update pulls a fresh image and recreates containers, leaving the
replaced image behind as a dangling layer that previously had to be
pruned by hand. A new "Prune dangling images after updates" toggle under
Settings > System > Docker hygiene reclaims these automatically.

The setting is on by default and opt-out. When enabled, a successful
stack update (manual or scheduled) and a Sencho self-update each remove
the dangling image layers they orphaned. Only untagged layers are
touched; tagged images, volumes, and data are never removed. The toggle
requires an admin account and is per node: each instance honors its own
value, so a remote node self-update applies that node's own preference.

A prune failure never affects the update result: on the stack path it is
caught and logged after the update has already succeeded, and on the
self-update path the helper-shell prune runs only after a clean recreate
and cannot change the exit code or the recorded update error.

* security(self-update): shell-quote label-derived values in helper command

Address review feedback on the prune-on-update change:

- The self-update helper command interpolated the compose service name and
  config-file paths (both read from Docker Compose labels) straight into a
  shell string. Shell-quote them via shQuote so a label carrying shell
  metacharacters stays inert data and cannot break the exit-code capture,
  error-file write, or prune guard.
- Correct the settings copy and docs: the prune is a standard dangling-image
  prune, so it reclaims every untagged layer on the node, not only the one the
  current update orphaned. Tagged images, volumes, and data remain untouched.
- Add tests: shell-metacharacter neutralization and prune-output suppression in
  the self-update command, and an atomic-update case asserting a prune failure
  does not trigger a rollback.

* fix(updates): omit the reclaim figure when the daemon reports zero bytes

End-to-end testing on a Docker daemon backed by the containerd image store
showed the post-update prune removing a dangling image while the prune API
returned SpaceReclaimed=0, so the stream printed "reclaimed 0.0 MB" even though
an image was removed. Show the reclaimed figure only when the daemon reports a
non-zero value; otherwise the line reads "=== Pruned dangling images ===". The
overlay2 store still reports real figures and shows them. Add a test covering
both branches.
2026-06-05 18:12:37 -04:00
Anso 622af7e0b3 fix(theme): make sheets and dialogs track the active theme (#1315)
* fix(theme): make sheets and dialogs track the active theme

Portaled panels (scan-history and other sheets, dialogs, confirm modals)
only partially followed the theme: their popover surface was a flat per-theme
literal, so OLED mode never reached true black inside them and the contrast
control did not move their fill. The accent stopped at the thin rail because
the ambient glow was painted only on the main app shell, never inside a
portaled panel, and the overlay scrim was a hardcoded black.

- Derive the popover surface from per-theme lightness and alpha plus the
  contrast spread, the same way the page background is derived, so every
  floating surface tones with the active theme (including OLED true black) and
  deepens with contrast. Dim and Light are unchanged at the default contrast.
- Add an accent wash to sheets and large dialogs so the chosen accent reads
  inside them, matching the page. Small dropdowns and tooltips keep the themed
  tone without the wash.
- Replace the hardcoded modal and sheet scrim with a theme-aware token that
  softens to a light dim in the Light theme.

Tested across Dim, OLED, and Light with multiple accents and contrast levels.

* fix(theme): keep the command palette token-only (no accent wash)

The command palette renders through DialogContent, so it picked up the accent
wash meant for content sheets and dialogs. It is a command menu, so it should
stay token-only like dropdowns and tooltips. Add an optional panelGlow flag to
DialogContent (on by default, so every other dialog is unchanged) and turn it
off for the palette.
2026-06-05 17:34:38 -04:00
Anso 5d508b9416 fix(blueprints): keep the edit sheet body within the sheet width (#1314)
The blueprint detail sheet embeds a code editor in its Edit form. The
sheet body scrolls inside a Radix ScrollArea whose default wrapper sizes
to its content, so the editor's intrinsic width pushed the body past the
sheet edge and the right side (the editor and the Save/Cancel buttons)
was clipped.

Add an opt-in constrainBodyWidth prop to SystemSheet that bounds the
scrolling body to the sheet width and lets a wider child scroll
horizontally, and enable it on the blueprint sheet. The prop defaults
off, so every other sheet is unchanged.
2026-06-05 16:03:45 -04:00
Anso b4cca9b995 fix(blueprints): allow deleting blueprints stuck on awaiting-confirmation deployments (#1313)
* fix(blueprints): allow deleting blueprints stuck on awaiting-confirmation deployments

Withdrawing a stateful deployment removed its row, but the reconciler
re-created it as "Awaiting confirmation" for any node still matching the
selector. The delete guard counted that recreated row as blocking, so a
stateful blueprint could never be deleted: every withdraw came back as a
pending review and delete kept refusing.

The delete guard now blocks only on deployments that have a live stack on
a node (active, drifted, correcting, evict_blocked, or a pending review
that was previously deployed). A never-deployed "Awaiting confirmation"
deployment no longer blocks; the delete path's best-effort withdraw-all
loop and the foreign-key cascade clear those rows. Live stateful
deployments still require explicit withdrawal first, so the
snapshot-vs-destroy choice is always made by the operator.

Updates the delete dialog copy to match and adds delete-guard coverage.

* fix(blueprints): harden delete against destroying unmanaged or live stacks

Address two issues found while auditing the delete guard:

- A never-deployed deployment (for example a reconciler-created pending
  review) no longer blocked delete, but the route's withdraw-all loop
  still ran the withdraw primitive for it. withdrawFromNode proceeds on a
  missing marker, so deleting the blueprint could down and delete an
  unmanaged same-name stack on the node. The loop now withdraws only the
  stacks Sencho deployed and still owns, and skips never-deployed,
  name_conflict, and withdrawn rows.

- A stack that was deployed then failed keeps its last_deployed_at, but
  the guard's status list did not include failed, so it could be deleted
  without the snapshot-vs-destroy choice. The guard now keys on
  last_deployed_at (a stack we deployed and own) rather than a status
  list, so any owned live stack blocks regardless of its current status,
  while never-deployed, name_conflict, and withdrawn rows do not.

Adds delete-guard coverage for these paths, including that withdraw is
never invoked for an unmanaged row and is invoked for an owned one.
2026-06-05 14:29:09 -04:00
sencho-quartermaster[bot] 1f859d26d2 chore(main): release 0.89.0 (#1308)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
Co-authored-by: Anso <dev@saelix.com>
v0.89.0
2026-06-04 23:16:00 -04:00
Anso 37af4a2087 refactor(fleet): standardize empty-state and header layout across Fleet tabs (#1312)
Extract the Secrets tab's header and empty-card markup into shared
primitives (FleetTabHeading, FleetEmptyState, FleetEmptyCard) and adopt
them across Snapshots, Deployments, Routing, Federation, and Secrets so
the Fleet area presents one consistent layout. The empty card sits
vertically centered below a title/subtitle header. Deployments keeps its
onboarding steps inside the shared shell; Federation's sections are
unchanged.
2026-06-04 21:46:33 -04:00
Anso 81858a0bb0 feat(mesh): refine the Routing tab and add per-route removal (#1311)
* feat(mesh): refine the Routing tab and add per-route removal

Tighten the Fleet > Routing tab so its node cards and diagnostics read
honestly for proxy-connected fleets, and remove a couple of dead ends.

- Node cards drop the "pilot connected/offline" line, which was meaningless
  for nodes that connect over the HTTP API proxy. The compact card drops the
  matching agent cell and now reads stacks / aliases / bridge.
- Enabling mesh on a proxy node shows a transient "Connecting" state and
  settles to meshed on its own, instead of flashing "Degraded" with a manual
  refresh while the bridge finishes dialing.
- The alias detail sheet gains a "Remove from mesh" action that opts the
  alias's owning stack out (the confirmation says how many aliases that
  drops), and a "Topology" tab. The standalone topology sheet and its
  opt-in-sheet shortcut are removed in favor of the tab.
- The "Add stack" affordance stays reachable on a meshed node, so a node
  that already has aliases is no longer a dead end.
- Diagnostics and the alias detail sheet show a transport-aware line
  ("local", "API proxy bridge", or "Pilot tunnel" with its state) instead of
  a fixed "Pilot tunnel" label.

Adds unit coverage for the new state derivation, transport descriptor, the
enable auto-converge, and the admin-gated removal, and updates the mesh docs.

* fix(mesh): harden Routing tab toggle and alias sheet against async races

Independent review surfaced two narrow async races in the new code.

- RoutingNodeCard: cancel the converge re-poll batch at the start of any
  toggle (so a slow disable can't let a prior enable's re-polls fire), and
  guard the toast, refresh, timer scheduling, and setToggling behind a mounted
  ref so nothing runs after the card unmounts while the enable POST is still
  in flight.
- MeshRouteDetailSheet: re-check the cancelled flag after reading the
  diagnostic body, not just before it. Reading the body is itself async, so a
  superseded alias's diagnostic could otherwise call setDiag and expose Remove
  for the wrong stack.

Adds tests for unmount-before-enable-resolves and the deferred-diagnostic
alias switch.
2026-06-04 21:45:53 -04:00