* fix(whats-new): backfill entries for unannounced 0.97.0 features
entries.json shipped empty in the What's New scaffolding (#1767), so the
top-bar icon stayed hidden through the 0.97.0 release despite several
notable features going out. Add entries for the ones that read as genuine
news: SSO-only auth, itemized fleet prune plans, the Audit Log, Fleet
Secrets, ntfy notifications, and the resizable file explorer.
* fix(whats-new): add screenshots to the backfilled entries
Each entry now carries a screenshot captured live against a running
Sencho instance, cropped to the relevant panel. Real usernames, the
client IP, and the real ntfy topic were scrubbed before capture.
A remote node returning HTTP 200 with a negative reclaimableBytes value
passed the typeof-only validation in the fleet prune estimate fan-out and
was folded into the per-node sum and the fleet total, silently shrinking
the estimate. Extend the invalid-response check to reject non-finite and
negative values, matching the existing guard for destructive prune plans.
Adds route-level tests covering negative, mixed partial, non-finite
(1e999 to Infinity), and zero-valued remote estimates.
Unreachable nodes rendered their error string in a shrink-0 span with no
width bound, so a verbose daemon or proxy error pushed the row past the
card edge. Bound the error span to 55% and ellipsize, matching the
existing pattern in the label-and-stop card; the full message stays
available via the row title tooltip.
* fix(nodes): return conflict on duplicate rename
PUT /api/nodes/:id left name uniqueness to the SQLite UNIQUE constraint, so a colliding rename surfaced the raw database error as a 500. Add a pre-flight shape guard and collision check (409, matching POST /) plus a catch remap for the concurrent-rename race. Tests cover collision, same-name, case-sensitivity, blank/null, partial updates, and the remap path.
* test(nodes): drop unused variable in case-sensitivity test
Adds a new Tutorials entry (not part of the original 14-page batch)
covering scoped RBAC permissions: create a Viewer account, grant a
Deployer scope on one stack, and verify the boundary from both the
teammate's session and the audit log.
* docs(rbac): refresh Users panel screenshots to current build
Recapture users-list, add-user-form, role-selector, and delete-confirm
against the live app so they reflect the current Community RBAC parity
UI. Update alt text to match.
* docs(sso): refresh SSO settings screenshots and document last-provider lockout guard
Screenshots were captured against a stale build; refresh with current UI.
Also documents the guard that blocks disabling or removing the last
enabled SSO provider while SSO-only mode is active, and corrects the
Recovery settings path (Operations, not System).
* docs(sso): refresh SSO quickstart screenshots to current build
Screenshots were captured against a stale build; recapture the provider
overview and the LDAP test-connection failure state against current UI.
Page text already matched the implementation, so no content changes.
* docs(alerts): refresh Alerts and Notifications for current RBAC and retention behavior
Verified every claim against current backend/frontend code and the live app.
Corrected two factual errors: stack alert rule add/delete visibility now
follows the stack:edit permission (roles renamed since the last refresh,
not an admin-only gate), and the notification_history cap is a 500-row
per-stack / 1000-row per-node-unattached pair enforced on the monitor's
30-second cleanup pass, not a flat 100-row per-node cap evicted on insert.
Added the stack_taken_down category (also part of the built-in bell
quieting rule, previously undocumented) and the seven history-only
categories that are filterable but never dispatched externally. Noted the
two vulnerability-scan retention controls now sharing the Data Retention
page, with a cross-link to their own doc.
Recaptured all eight screenshots against the production node.
* docs(fleet-sync): remove stale Admiral gate claim, refresh replica screenshots
Fleet Sync's replication, anchor, and demote mechanics have been free on
Community since the paid gates were dropped from sync-status and
reset-anchor; the Prerequisites table still claimed the control instance
needed Admiral. Also documents the one-click "Reset anchor on peer"
recovery banner on Settings -> Nodes, verified live against a real
control/replica pair, and replaces all four screenshots with current
captures.
Adds a "Docker socket proxy" section to self-hosting that maps Sencho
features to the Docker Engine API groups a proxy must allow. Three
profiles (monitoring / minimum management / full) with literal flag
values, a feature-to-API reference table, a keep-disabled list, and the
self-update caveat: helpers always mount the host Unix socket, so a
TCP-only proxy does not carry update traffic.
Also adds a production-hardening checklist item pointing at the section,
cross-links from the feature pages whose behavior depends on mutating
Docker access (mesh, file explorer, resources, remote updates, stack
management, scheduled operations), and a pointer from Compose Doctor's
proxy findings to the sizing guidance.
Closes#1796
* docs: scaffold Tutorials tab and write enroll-a-remote-node
Adds the Tutorials tab to docs.json with 15 stub pages across three
groups (Fleet & nodes, Deploy & automate, Secure & integrate), and
writes the first full tutorial: enrolling a remote node via Pilot
Agent mode, verified end to end against a live control instance and
a second host running an existing Jellyfin Compose stack.
* docs: write Schedule an Operation tutorial
* docs: fix MDX parse error in Schedule an Operation tutorial
* docs: write Set Up SSO with Custom OIDC tutorial
Registers an OAuth client in a self-hosted identity provider (Keycloak
worked example), configures Sencho's Custom OIDC settings, tests the
connection, and verifies a real end-to-end login with auto-provisioning
from two independent surfaces.
* docs: drop unused SSO tutorial screenshot
sso-settings-empty.png isn't referenced by the tutorial content.
* docs: write Set Up Fleet Federation tutorial
Migrates a Blueprint-managed workload from one node to another using
pin and cordon, with the confirm-before-mutate rollout in between.
Corrects the published feature page's claim that pin requires the
global admin role; the code gates cordon and pin identically, scoped
to the target node.
* docs: write Create and Approve a Blueprint tutorial
Covers labeling a target node, authoring a stateless Blueprint,
walking through the create-then-approve rollout flow, verifying
from the Deployments tab and the audit log, and recovering from a
port-conflict deploy failure. Cross-links with Move a Blueprint
Deployment to a New Node in both directions.
* docs: write Automatically Patch a Stack With an Auto-Update Label tutorial
* docs: write Configure Auto-Heal Policies tutorial
Adds the full step-by-step content for the Configure Auto-Heal Policies
stub: an nginx+redis scenario stack, adding a service-scoped policy,
and a live verification that breaks a container's healthcheck,
confirms the policy restarts it, and recovers it.
* docs: write Set Up Deploy Enforcement tutorial
Covers configuring a block-on-deploy scan policy against a stack running
a deliberately outdated nginx image, reading the block dialog, and
overriding it as an admin with the bypass confirmed in the audit log.
Includes a stack-pattern mismatch as the most likely first-time failure.
* docs: write Configure Environment Guardrails tutorial
Covers the Block deploy on missing required env vars guardrail end to
end: deploy a Postgres stack with a required password, enable the
guardrail, watch a real update get refused with a named-variable
message, fix it, and verify from the Activity and Environment tabs.
* docs: write Deploy a Stack Automatically From Your CI Pipeline tutorial
* docs: write Catch and Fix a Container That's Drifted From Its Compose File tutorial
Covers reading a real Drift finding after an out-of-band container
change and resolving it by redeploying through Sencho.
* docs: write Connect a Git Source tutorial
* docs: write Push a Shared Environment File to Every Node tutorial
Writes the Fleet Secrets tutorial: create a bundle, target nodes by
label, read the push preview/results, verify via the audit log, and
recover from a stack-name typo. Removes the three unwritten
placeholder stubs (RBAC, Sencho Mesh, private registries) that had no
scheduled content.
* fix(compose-doctor): recognize Docker socket proxy topologies
Classify dedicated socket proxies separately from direct docker.sock mounts so Doctor no longer recommends adopting a proxy the stack already uses. Closes#1790.
* fix(compose-doctor): widen socket proxy detection and flag writable proxy sockets
Close the remaining gaps in socket proxy topology handling: a service that
points at a proxy through a tcp:// endpoint on its command line (how Traefik
and friends do it) now gets the client note, proxy API group flags are read
for any truthy value rather than a literal 1, and underscore or dot separated
proxy names are recognized.
Two cases that previously slipped through now surface: a service classified
as a proxy purely by name or image but mounting docker.sock read-write is
reported as high, and a proxy on the implicit default network or on a network
the rendered model does not describe counts as non-internal. A direct socket
mount alongside an existing proxy now names that proxy in its fix.
* fix(compose-doctor): require corroboration before a service name classifies a socket proxy
A service name is free text the author controls, so on its own it could move a
writable docker.sock mount out of the high direct-mount finding. A known proxy
image is an artifact identity and still stands alone; a proxy-shaped name now
counts only alongside an observable fact, a read-only socket or a scoped API
group key.
* fix(compose-doctor): tighten socket-proxy detection against live upstream behavior
Require proxy API flags to be exactly 1 (matching tecnativa and linuxserver
images), count only those enabled flags when classifying a proxy, extract
tcp hosts from DOCKER_HOST instead of treating key presence as a proxy client,
and correlate each client note to one proxy instance by both name and shared
network. Soften the published-port finding so it claims reachability rather
than Docker API exposure for unrelated ports.
Open all five built-in global roles and stack/node scoped assignments
on Community. Remove paid fences from user role create/update, scoped
assignment CRUD, permission evaluation, and the Users settings UI.
Admiral continues to own extended audit governance, LDAP directory
integration, and other organizational assurance features. Built-in
scoped RBAC is no longer marketed or enforced as paid-only.
* fix(sso): forward RFC 9207 iss parameter in OIDC callback
The Custom OIDC callback only forwarded code and state from the query
string to the token exchange, silently dropping the iss parameter that
issuer-identification-aware providers (Keycloak 22+, and others) add
to the redirect. openid-client rejects the exchange as an invalid
response once discovery advertises support for that parameter, so
login failed for any such provider.
* fix(sso): forward RFC 9207 iss parameter in OIDC callback
The Custom OIDC callback only forwarded code and state from the query
string to the token exchange, silently dropping the iss parameter that
issuer-identification-aware providers add to the redirect. openid-client
rejects the exchange as an invalid response once discovery advertises
support for that parameter (confirmed on Keycloak 26), so login failed
for any such provider.
Also logs the underlying openid-client error cause on callback failure
instead of only the generic message it collapses specific validation
errors into, since that cause carries the actual diagnosis.
* refactor(sso): dedupe iss-forwarding test setup
Extracts the shared callback-with-stubbed-service setup used by both
new regression tests into one helper.
Resolves GHSA-2v37-7h3g-55p8 (nanoid custom generators can loop
indefinitely when size is zero), a newly published advisory for
nanoid <3.3.17. Transitive dev dependency of postcss (via vite)
in both packages:
- backend: nanoid 3.3.16 -> 3.3.18
- frontend: nanoid 3.3.16 -> 3.3.18
npm audit --audit-level=high now passes clean on both packages.
Baseline Fleet Sync already replicates security policy on Community
code paths, but status and anchor recovery still required a paid
entitlement. Drop the residual paid gates while keeping admin and
node:manage authorization boundaries.
release-please stores the release notes in the Release PR body when it opens
that PR, and builds the GitHub Release from that stored body at merge time.
The contributor credit step runs afterwards and only rewrites CHANGELOG.md on
the branch, so credits reached the changelog but never the published notes.
Everything that reads release bodies, including the releases page and the
website changelog, showed uncredited text.
Add a step that re-publishes the notes from CHANGELOG.md after a release is
created. The two are otherwise byte-identical, so the edit is a no-op when
there is nothing to credit.
It runs before the credit step on purpose: a single run can both publish a
release and open the next Release PR, and the credit step checks out that new
branch, which would leave the wrong CHANGELOG.md in the working tree.
* fix(image-updates): explain persistent digest rebuilds after update
When an update completes but a same-tag digest rebuild is still detected,
the generic "update still detected" warning told operators nothing about
why. The digest comparison already knows the remaining updates are
digest-only (no higher tag), so recheckStack now returns a targeted
warning naming the two daemon-side causes: a registry mirror or cache
serving stale content, or a container still pinned to the previous image.
The digest-rebuild badge surfaces (Anatomy banner, Fleet cards, mobile)
now carry a tooltip with the same explanation, and the post-update
warning is added to the pre-update refresh sanitization set.
* fix(image-updates): surface digest warnings on editor and mobile paths
Editor Update discarded recheckWarning, digest hints were hover-only, and
service-scoped rechecks blamed the daemon when only sibling services remained stale.
* fix: keep fleet prune estimate bytes after a target timeout
Accumulate successful per-target reclaimable bytes on both the local
serial loop and the remote fan-out, mark partial nodes, and surface that
state in the Fleet prune card instead of zeroing the whole node.
* test: clarify zero-byte partial estimate card case
Rename the FleetPruneCard assertion so it matches fold semantics:
partial with zero bytes means a successful zero-byte target plus a failure,
not an all-failed node.
* fix: keep fleet prune estimate resilient on init and partial errors
Guard DockerController init so a deleted-node race cannot 500 the whole
fleet estimate, surface partial-node failure text in the card row title,
and pin the generic-rejection partial path with a fast route test.
* test: destroy reverse-route connect-ack sockets on teardown
Unguarded accepted sockets could emit a late ECONNRESET after assertions passed, failing the Backend Vitest job with zero assertion failures.
Open the existing image detail sheet from each container card, between View logs and Monitor, so operators can inspect a container's image without leaving stack details.
* fix: reduce prune estimate work and add managed-scope timeout
estimateSystemReclaim previously called getDiskUsageClassified,
which walks the full classified-resources pipeline (6+ Docker
API calls and filesystem I/O) under the 8 s timeout, but only
reads the three reclaimable* fields that getDiskUsage() (a single
docker.df() call) already provides. Switch to getDiskUsage() so
the timeout actually bounds the work the comment describes.
Additionally, the managed-scope estimateManagedReclaim path had
no timeout on either the remote route or the fleet local path.
Wrap both call sites in withTimeout so a slow daemon surfaces
the actionable 'Docker daemon is busy' message within 8 s instead
of hanging until the hub's 15 s fetch abort fires.
* fix: skip getStacks() for all scope in prune estimate route
The remote handler unconditionally walked the compose directory before
starting the 8 s estimate timer, but for 'all' scope the knownStackNames
parameter is now unused (estimateSystemReclaim uses only docker system df).
Mirror the fleet route's conditional so the walk only happens for managed
scope, where estimateManagedReclaim genuinely needs stack names.
Found during QA: on a Pilot node with real tunnel latency, this unbounded
walk added latency outside the timeout budget.
* fix: raise prune estimate budget to 12s for large image stores
docker.df() cost scales with image-store size: measured ~7.4s on a
34GB / 96-image store, alone nearly exhausting the previous 8s budget
before tunnel transport overhead. A healthy Pilot node could flip to
'Docker daemon is busy' at idle load.
Raise PRUNE_ESTIMATE_TIMEOUT_MS and FLEET_DF_TIMEOUT_MS to 12s, which
sits strictly below the hub's 15s AbortSignal.timeout on the fleet
estimate fetch, keeping the remote 503 the actionable failure. The
MonitorService janitor keeps its own 8s budget for destructive paths.
Found in QA pass 2: single-target estimate failed at ~8.05s on a node
where docker.df() alone takes ~7.4s.
* feat: make the Files explorer tree pane resizable
Operators can drag the Files tab divider to read long or deeply nested names. The width is remembered in this browser; names that still overflow scroll horizontally.
* fix: keep Files tree resize from leaking cursor or stealing row clicks
Unmount and lost-pointer-capture now clear the drag gesture. The stored width stays a preference while layout clamps to the explorer size, and the hit target only expands into the viewer so full-row context menus still work.
* fix: persist last live Files pane width on pointercancel
Cancel events can report clientX 0, which was clamping the tree to 160px and writing that to storage. Commit the last tracked width instead, same as lostpointercapture.
* fix: allow chmod on protected stack files
Identity protection still blocks delete, rename, and copy-onto-reserved-name for compose and .env at the stack root. Permission changes are ordinary edits and must succeed from the explorer.
* fix: gate chmod on compose files during stack ops
Chmod on compose filenames and .blueprint.json now follows the same
stack-op lock as content writes and uploads. Document allowed blueprint
chmod and that content saves reset mode bits.
Stop remounting the file tree on every refresh so expanded folders and
scroll position survive delete, move, rename, and save. Relist open
directories in place and drop stale collapsed caches.
The terminal panel used w-full with mx-6, so width 100% plus horizontal margins overflowed the overflow-hidden modal and clipped the right gutter. Match BashExecModal by dropping w-full and adding min-h-0.
* fix(resources): attribute free images to managed prune by repository family
After a stack update, unused previous tags lost compose labels and dropped out of managed prune. Match free image repositories still used by managed containers (excluding foreign Compose projects), and clarify the fleet dry-run unlock on the prune card footer.
* fix(resources): omit stackName on repo-match managed prune items
Repository sharing is not ownership; the confirm list must not show stack attribution for repo-matched free images. Pin repository-key normalization with exact Set assertions so the duplicated parser cannot drift silently.
* fix(fleet): bind prune fingerprints to image tag sets
RepoTag churn on an already-planned image Id no longer leaves the dry-run fingerprint unchanged, so fleet preflight rejects the whole execute when any node retags. Itemized delete also refuses on reference drift and reports multi-repository refuse without implying a clean no-op.
The nav trigger already stays out of the top bar while entries.json is
empty, but the Settings toggle rendered unconditionally, so About showed
a control whose helper text described a sparkle icon that could not
appear. The Preferences section now follows the same rule as the trigger.
Also gives the toggle an accessible name. Its visible text is only
ON/OFF, so a screen reader previously announced the state without the
setting it belongs to.
* fix: condition --volumes in downStack() on the removeVolumes option
ComposeService.downStack() hardcoded --volumes on every stack delete,
ignoring the "Also remove associated volumes" checkbox and destroying
volumes the operator asked to keep. The sibling Take-down path (runDown)
already conditions --volumes correctly.
- Add options?: { removeVolumes?: boolean } to downStack()
- Default to data-preserving (no --volumes when option absent)
- DeletedStackDeletionService reads the persisted intent flag
- Templates rollback passes removeVolumes: true (clean up failed deploy)
- Blueprint withdraw passes removeVolumes: false (volumes preserved)
* docs: update Delete row to reflect conditional volume removal
The Delete row now describes that volumes are removed only when the
operator opts in, matching the behavior introduced by the downStack fix.
* fix: add capability gate for delete pruneVolumes and fix QA findings
Four P0 issues found in live QA:
P0-1/P0-4 - No capability gate on delete's pruneVolumes:
Add stack-delete-prune-volumes capability so the frontend hides the
"Also remove associated volumes" checkbox on nodes that don't support
conditional volume removal on delete. Without this, an operator on an
old node sees a VOLUMES KEPT promise the old node silently breaks.
Frontend-only gate: no API or proxy gate because the old node's
fallback (always destroy) is correct for the checked case.
P0-2 - Checkbox state leaked across dialogs:
Reset pruneVolumes in onConfirm before calling the parent, so a
previously checked box doesn't appear pre-checked when the dialog
opens for a different stack.
P0-3 - Delete not bound to the active node:
Capture activeNode.id at delete time and pass it as an explicit
nodeId to apiFetch, matching the Take Down pattern. Without this,
switching the active node while the dialog is open silently deletes
the wrong stack on the wrong node.
* fix: update test assertions for nodeId binding and showVolumeOption gate
P0-3 added nodeId to apiFetch DELETE calls — two useStackActions tests
now expect the parameter. P0-1 gated the volume checkbox behind
showVolumeOption — the confirming test now passes the prop.
* fix: gate volume hint on showVolumeOption to prevent false promise
On nodes without stack-delete-prune-volumes, volumes are always
destroyed. Showing VOLUMES KEPT was a lie. Now the hint is hidden
entirely when the capability is absent.
* fix: gate delete against nodes that cannot guarantee volume preservation
Hiding the checkbox and the misleading hint stopped the false promise but
not the data loss: an unchecked delete against a node lacking
stack-delete-prune-volumes still reached that node and its downStack()
still destroyed volumes unconditionally, now with no warning at all.
- remoteNodeProxy.ts: block an unacknowledged DELETE /stacks/:name
(no pruneVolumes=true) to a remote lacking the capability, mirroring
the existing removeVolumes gate on the down route. An explicit
pruneVolumes=true always proxies through since that matches what an
unsupported remote does anyway.
- DeleteStackDialog: rework around a three-state model (supported /
unsupported / unknown) instead of a boolean. A node whose capabilities
have not been confirmed (meta not yet fetched, or a failed probe) is
now treated like a supported node, not forced onto the destructive
path just because its state is unresolved.
- Fix deleteStack's error toast, which surfaced the raw JSON response
body instead of the parsed error message.
- Fix CreateStackDialog's orphan-stack rollback (docker-run import),
which silently no-op'd against a node requiring acknowledgement.
- Update node-compatibility.mdx and stack-management.mdx to describe
the new gate.
* test: advertise stack-delete-prune-volumes on the scoped-evidence fixtures
These mock remotes simulate nodes capable enough to run scoped-stack-auth-evidence
RBAC and were pinned before stack-delete-prune-volumes existed, so the new delete
gate now blocked their unacknowledged DELETE calls before reaching the mock server,
failing the grant-tuple-cleanup assertions the tests actually check.
* feat: add whats-new entries data model
* feat: add useWhatsNewPreference hook
* feat: add whats-new breathing animation
* feat: add WhatsNewTrigger sparkle icon
* feat: add whatsNew slot to TopBar
* feat: add WhatsNewModal
* feat: wire whats-new sparkle icon and modal into EditorLayout
* feat: add What's New opt-out toggle to Settings
* fix: seed What's New watermark for zero-entry releases
A release that ships with entries.json still empty must stamp a
watermark on first run, or an existing install can never distinguish
itself from a genuinely fresh install once a later release adds its
first real entry, silently swallowing that entry's unseen signal.
* fix: constrain WhatsNewModal height and clarify settings copy
Bound the dialog to 85vh as a flex column so ModalBody's fill can
constrain the entry list to scroll while the header and footer stay
pinned, matching ConfirmModal's pattern. Also clarifies the "Show
What's New" helper text in Settings.
* fix: drop What's New screenshots that fail to load
Screenshots are authored by hand alongside the entry, so a typo'd or
not-yet-added filename is a realistic mistake. Previously that left the
browser's broken-image placeholder and alt text inside the card; now the
image is dropped and the title, blurb, and doc link still render.
* style: replace em dash in watermark comment with a comma
* fix: make "Never show again" actually hide What's New
Turning the feature off previously only stopped the breathing animation
and left the sparkle icon in the top bar, which is not what that label
means anywhere else. Opting out now removes the trigger entirely and
closes the modal, and Settings > About is the single way back.
Also brings the trigger in line with its top bar siblings: it now uses
the search trigger's hover treatment and gains a visible keyboard focus
ring, and the modal bounds itself with dvh rather than vh so the footer
cannot sit under a mobile URL bar. The modal is rendered in the desktop
branch only, since the bespoke mobile screens drop the top bar that
carries its trigger.
* test: cover the empty-entries and loader paths of What's New
The shipped state has an empty entries.json, so the branch where there is
no newest entry is the one actually running, yet nothing exercised it.
Adds a sibling hook test mocking that state to pin the empty-string
watermark write, and a loader test for the newest-is-last contract the
watermark depends on plus the malformed-entry filter.
Drops the unreachable array check in the loader and the assertion that
mirrored it: TypeScript types the JSON import, so a non-array file fails
the build rather than reaching that branch.
* refactor: fold the What's New storage writers into one helper
writeEnabled and writeLastSeenId were identical apart from the key and
the value encoding, duplicating the comment explaining why a failed
write is non-fatal. The boolean encoding now sits at its single call
site. Also hoists the reversed entry list to module scope, since the
source array is a module constant, and factors the repeated preference
mock in the trigger test behind a helper.
* chore: keep the What's New icon hidden until an entry exists
With entries.json empty there is nothing to announce, so a permanent
sparkle in the top bar would only ever open a modal reading "Nothing new
to show yet". The trigger now renders nothing in that state, leaving the
modal's empty state as a runtime fallback for entries that fail
validation rather than the shipping experience.
This keeps the scaffolding invisible until the first entry is authored,
which is the change that actually surfaces the feature to users.
* fix: surface NOT_EMPTY error code so DeleteFileConfirm can offer recursive retry
The parseApiError helper discards the backend's machine-readable code field,
returning only the human-readable message. DeleteFileConfirm tried to detect
non-empty directory refusals by matching the substring NOT_EMPTY against the
message text, but the actual server message is "Directory is not empty" (with
spaces, not underscores), so the two-step "Delete all" confirmation flow was
dead code.
Add a NotEmptyError class (mirroring the existing UploadConflictError pattern)
and intercept HTTP 409 responses in deleteStackPath so the code is preserved.
Replace the fragile string match in DeleteFileConfirm with instanceof.
* fix: extend NOT_EMPTY fix to volume roots and fix stale viewer after recursive delete
P0-1: sendFsError's helper/ExecError branch (volume-browser deletes) never
attached a code field to 409 responses, so the frontend NotEmptyError was
never thrown for named-volume non-empty directories. Map ExecError 409s
whose message matches 'not empty' to code: NOT_EMPTY.
P0-2: The context-menu delete onDeleted callback used an exact-match check
(ctxDeletePath === selectedPath) to decide whether to clear the viewer.
When deleting a folder containing the open file, the viewer stayed open
showing now-deleted content. Use the existing openFileAffectedBy helper
(which checks ancestor paths) instead, matching bulk-delete behavior.
Wire ConfirmModal and BusyButton so async confirms lock immediately, show
spinner and progressive labels after duration-base, and block dismiss mid-flight.
Connect stack delete and take-down to the existing stackAction map so the dialog
is not idle until the toast.
* chore: bump brace-expansion and fast-uri via npm audit fix
Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded
intermediate arrays). Both transitive dev dependencies updated:
- brace-expansion 5.0.8 -> 5.0.9
- fast-uri 3.1.4 -> 3.1.5
* chore: also bump frontend deps via npm audit fix
Fixes brace-expansion and postcss in the frontend lockfile so
npm audit --audit-level=high passes on both packages.
* chore: bump ip-address transitive dep via npm audit fix
Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr,
GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior
push and CI run.
* feat: add ntfy notification channel
Add ntfy (https://ntfy.sh) as the fifth notification channel alongside
Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol:
plain-text POST body with Content-Type, Title, Priority, and Tags
headers. Priority maps info/warning/error to ntfy's default/high/urgent.
URL validation allows both HTTP and HTTPS (common for LAN self-hosting)
but rejects embedded credentials, consistent with Apprise. Token auth
via ntfy's documented ?auth= query parameter is supported.
* fix: correct ntfy channel test cases for Linux URL parsing and required type field
- notification-channels.test.ts: replace http:///topic host check with a
cross-platform invalid-URL case (WHATWG parser treats triple-slash
authority differently on Linux vs Windows)
- ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and
inline agents fixtures (required by the expanded ConfigurationAgents
type)
* fix: remove unused import and update 0/4 masthead assertions to 0/5
* ci: exclude NotificationService.ts from js/request-forgery CodeQL rule
Notification channel dispatch methods (Discord, Slack, Webhook, Apprise,
ntfy) all call fetch() with admin-configured URLs and notification bodies
that may embed stack or path data. This matches the trust model already
documented for registry-api.ts: single-tenant self-hosted, admin owns
the server, outbound posting is the intended behavior. The write path is
gated by requireAdmin or requirePermission(node:manage), and every
dispatch runs with a 10s AbortSignal.timeout.
* ci: also exclude NotificationService.ts from js/file-access-to-http
Notification messages may embed stack names, paths, or compose-derived
content. Same trust model as js/request-forgery: admin owns the server
and the configured endpoints, write path is gated.
* fix: correct ntfy channel tab copy and validation error message
The ntfy settings tab was reusing the generic webhook label, helper, and
placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own
copy: label names the server-and-topic URL, helper states plain-text delivery
and the mandatory topic path, placeholder matches the routing section.
Also fix the routing-rule validation toast: the guard correctly exempts ntfy
from the HTTPS check but the error message was not updated alongside it, so
ntfy URLs received a misleading HTTPS-required message.
* fix: strip trailing slash from ntfy topic URL before dispatch
A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips
the trailing slash internally) but was stored and dispatched with the slash
intact, causing the real ntfy server to 404. Normalize before fetch so the
request reaches the correct topic path.
Also add ntfy to the Channels card description in the settings registry.
* chore: bump brace-expansion and fast-uri via npm audit fix
Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded
intermediate arrays). Both transitive dev dependencies updated:
- brace-expansion 5.0.8 -> 5.0.9
- fast-uri 3.1.4 -> 3.1.5
* chore: also bump frontend deps via npm audit fix
Fixes brace-expansion and postcss in the frontend lockfile so
npm audit --audit-level=high passes on both packages.
* chore: bump ip-address transitive dep via npm audit fix
Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr,
GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior
push and CI run.
* feat: surface ZFS ARC reclaimable as dashboard context line
Add arcReclaimable to the HostMemory interface and MemoryWire shape so
the reclaimable ARC amount computed by readReclaimableArc() is exposed
through /api/system/stats and /api/fleet/overview. Show it as a context
line on the dashboard memory tile, matching the balloon pattern.
ARC continues to feed the gauge percentage as before; this is a
display-only addition for operator visibility.
* docs: clarify ARC line requires nonzero reclaimable, not just readable stats
* fix(ui): flatten single-container Update onto the service row
On multi-service stacks, put Update/Rebuild on the container card (left of
image source) when a service has one matching container, and keep the shared
header only for multi-replica services.
* fix(ui): show per-service Update only when an image update is confirmed
Registry services were always showing Update because eligibility checked
declaredImage/hasBuild only. Gate Update on a confirmed pending check so
the button clears after a successful recheck; keep Rebuild for build-backed
services.
* feat(fleet-secrets): graduate encrypted fleet-wide environment bundles to Community
* fix(fleet-secrets): update reachability test for Secrets community graduation
* fix(fleet-secrets): address review findings
* fix(fleet-secrets): add HTTP-level Community admin push/import tests and non-admin tab-hidden test
* feat: validate scheduled task target existence at creation time
Reject POST/PUT /api/scheduled-tasks with 400 when the target stack,
container, or node does not exist. Previously only structural format was
validated; a task targeting a deleted stack would return 201 and fail
forever at execution time with noisy error logs.
Node existence is now validated for every action that requires a node
(previously only scan/prune got this check). Stack and container
existence is validated on local nodes; remote nodes are skipped since
the check would require a proxy call (execution-time validation still
serves as the safety net there).
Permission checks run before existence checks, so unauthorized callers
receive 403 regardless of whether the target exists.
* fix: close node-existence oracle in scheduled task creation
Move validateActionNode (which contains a getNode DB lookup) after the
permission gate in both POST and PUT handlers, so unauthorized callers
always receive 403 regardless of whether a fleet or system target node
exists. Previously a viewer probing a nonexistent fleet node would get
400 ("node not found"), leaking node ID enumeration through the error
code difference.
The stack/container existence check (validateTargetExists) was already
correctly positioned after permission; this fix extends the same
discipline to the node-existence path.
* fix(fleet-secrets): invalidate stale preview when target inputs change
Clear the preview plan and push results whenever a target input
(selectedLabels, labelMode, stackName, or envFile) changes, so the
operator cannot review a diff computed from different inputs.
A version counter in a ref drops in-flight preview/push responses
when inputs change while a request is still pending.
* fix(fleet-secrets): always surface push results, add invalidation test
Remove the version guard from handlePush so a completed secret write
always reports its outcome to the operator, even if target inputs changed
while the push was in flight. The guard remains on handlePreview (read-only).
Add a component test covering: plan cleared on input change, stale
in-flight preview discarded, and push results always surfaced.
Add seedMfaUserWithToken helper that wraps seedMfaUser and returns a
signed JWT with the current token_version claim, removing the manual
sign-and-read-boilerplate from callers.
Add an integration test in the MFA reset describe block that exercises
the full invalidation cycle: a target user's pre-reset JWT is accepted
before reset and rejected with 401 'Session invalidated' after an admin
resets their MFA. The test also confirms a pre-minted admin JWT survives
the reset unchanged.
* feat(recovery): make rollback-recovery image lifecycle visible and controllable
GitHub discussion #1751 asked why Sencho creates sencho-rb/<id>/<service>:hold
images during automatic updates and how to clean them up. That surfaced a real
safety bug alongside the missing visibility: the manual single-image delete
route did not consult the held-image predicate every other deletion path
already honors, so a user could delete a rollback-protected image straight
through the Images tab and silently break automatic recovery for that update.
A short/truncated id also bypassed the predicate's full-id lookup.
Fixes:
- POST /images/delete now resolves the submitted id to its canonical form and
checks the unified held-image predicate before deleting, returning 409
IMAGE_HELD_FOR_ROLLBACK for a protected image.
- The Images tab no longer mislabels a protected image as plain "Unused"; a
fully-synthetic hold image is kept out of the generic inventory entirely and
surfaced instead in a new Resources -> Rollback tab, with an additive
"Rollback protected" badge for images that still carry a normal tag too.
New capability:
- Two settings (Deploy Guardrails): superseded-generation retention (days,
replaces a hardcoded 7) and a cap on retained generations per stack.
- A new Resources -> Rollback tab lists every generation (stack, short id,
state, retention) with an admin-gated manual release action, including
releasing the current generation with an explicit warning that automatic
rollback becomes unavailable until the next successful update. Release is
a single atomic, server-revalidated transition so a stale UI read can never
release a row that has since become ineligible.
Also consolidated three near-duplicate implementations of the held-image
predicate (two of which relied on a require() of a sibling .ts file that
silently failed to resolve under the test runner and was never actually
exercised by a real test before this change) into one shared module.
Known follow-up, not fixed here: an orphaned sencho-rb tag whose recovery row
no longer exists (DB restore, node re-add) is invisible in both the Images
and Rollback tabs with no UI path to reclaim it.
* fix(audit): add summary mapping for rollback generation release
* fix(security): sanitize prune target in log sinks and cover release RBAC
Closes two open js/log-injection findings on the system prune route by
applying the same inline sanitizeForLog barrier the rest of the file
already uses. The prune target is validated against an enum by
parsePruneTargets before reaching these sinks, so the findings were false
positives, but the barrier is cheap and removes the standing alerts on a
file this change already touches. Also wraps the generation id in the
release log line for consistency with the stack name beside it.
Adds coverage for gaps a QA pass identified:
- Release endpoint refuses a viewer and a deployer (Admin-only), leaving
the generation and its artifacts untouched.
- Viewer can still read the generations list, matching the sibling
Resources routes.
- The predicate the prune routes build reports full-stack rollback holds,
not just service-scoped ones, and re-reads per call so a hold taken
between plan and delete still gates the delete.
- After releasing the current generation, no rollback point is claimed
for the stack through any consumer of the current-generation lookup.
* feat(fleet): add Node details sheet to the node card kebab
Every Fleet node card now carries a "Node details" kebab item, open to
any role that can see the card (previously the kebab only rendered
for users with node-manage permissions, so plain viewers had none).
The sheet shows connectivity, live capacity, Compose workload,
version/capability compatibility, and governance info (labels, cordon
reason and date, default-node flag, Compose directory, registration
date) using data the Fleet page already fetches, plus one lazy call
to the existing node meta endpoint for capabilities. Wired into both
the desktop card and the mobile bespoke Fleet screen.
* fix(fleet): correct Node details sheet timestamp units and update-status fallback
QA against a live 3-node fleet found that last_successful_contact and
pilot_last_seen come back from the fleet-overview endpoint in Unix
seconds, but the sheet passed them straight into a milliseconds-only
formatter, rendering values like "20647d ago" instead of "just now".
Both are now converted before formatting.
The Compatibility section's update-status badge also fell through to
a confident "Up to date" whenever updateStatus was absent (e.g. on
mobile, which doesn't poll update status) instead of reflecting that
there was no data to back the claim; it now renders "Unknown" in that
case. The local node no longer shows a misleading "Last successful
contact: Never". Reworded the "read-only sheet" language in the docs
page to describe the sheet accurately, since the Governance section's
label picker stays editable for node managers by design.
acceptReverseLocal swapped a pre-connect error handler for permanent
close/error handlers inside the socket's 'connect' callback, briefly
leaving 'error' with the old handler removed and the new ones not yet
attached. A Node EventEmitter 'error' event with zero listeners
throws instead of being swallowed, and CI's runner timing hit this
window intermittently (backend-tests-pilot-tunnel-bridge-reverse-
route-events.test.ts's post-handshake-close test), surfacing as an
unhandled ECONNRESET exception even though every assertion passed.
Replace the swap with a single 'error' listener attached at socket
creation that branches on a connected flag, so the socket is never
without an error listener at any point in its lifecycle.
* feat: account for VM memory ballooning in host memory reporting
Extend hostMemory.ts with a readBalloonedMemory() function that parses the
Balloon: field from /proc/meminfo, following the same fail-open pattern as
the ZFS ARC integration. When a nonzero balloon is detected, effective
memory fields (effectiveUsed, effectiveFree, effectiveUsagePercent) are
computed and exposed through /api/system/stats and /api/fleet/overview.
All consumers that derive meaning from host memory now prefer effective
values when present: the dashboard gauge, Fleet card RAM bar, mobile
views, health verdict, health status bar stat tile, and host RAM alerts.
Backward compatible: missing /proc/meminfo or absent Balloon: line
preserves exact current behavior. Old remote nodes without the new fields
continue rendering normally.
* refactor: extract shared helpers for balloon memory wiring
Extract readCandidateFile() and logSelectedPath() in hostMemory.ts to
deduplicate ARC and balloon file-read logic. Add memoryToWire() to
centralize the optional-field spread used by /api/system/stats and
/api/fleet/overview. Add getNodeMemUsed()/getNodeMemTotal() helpers
in nodeUtils.ts for frontend byte-text consumers.
* fix: make desktop fleet masthead aggregate balloon-aware
The desktop fleet overview's memory aggregate in useFleetOverview.ts still
summed raw memory.used, while the mobile fleet aggregate and per-node cards
already used effective values. Update to use getNodeMemUsed/getNodeMemTotal
helpers.
* fix: revert balloon adjustment from alerting and health decisions
Ballooned memory is host-reclaimed (unlike ZFS ARC, which the guest can
reclaim on demand). The guest cannot get ballooned pages back until the
hypervisor deflates them, so treating ballooned memory as available for
alerting or health can mask real memory pressure.
Keep balloon parsing, wire fields, and the dashboard context line as
informational-only. The memory gauge, health verdict, and host RAM alerts
now use the standard ARC-adjusted working-set percentage regardless of
balloon. Updated configuration.mdx and dashboard.mdx to document that
balloon data is informational and does not influence alerting.