* fix: wrap auto-heal policy history in stack monitor sheet
Restructure PolicyRow history entries to stack vertically with break-words
so long container names and reasons stay inside the sheet width.
Fixes#1532
* fix: cap auto-heal policy history to a scrollable region
Recent activity could grow the policy row unbounded, pushing the
add-new-policy form far down the sheet when a policy accumulated many
history entries. Wrap the list in a fixed-height ScrollArea so it
scrolls internally instead.
In inline deploy feedback style, the auto-close countdown label
("closes in Xs") was visible but permanently frozen. canAutoClose
was already false in inline style (the banner owns the lifecycle),
but showCountdown was driven by !gateHoldsOpen, showing the label
even when no countdown timer could ever start.
Change showCountdown to canAutoClose so the label only appears
when the modal is actually eligible to auto-close (modal style,
succeeded, no observing gate). No gate contracts or modal-style
behavior change.
Adds regression tests: inline succeeded hides the label, modal
succeeded shows it.
The New/Edit Scheduled Task modal always showed "Enabled" next to the
task toggle regardless of its on/off state. The label now tracks
formEnabled, matching the existing Enabled/Disabled label convention
used in the Stacks settings section.
PR #1521 added max-height and overflow-y-auto but the card stayed content-
sized because flex items default to min-height:auto and justify-center
clipped overflow while html/body stay overflow:hidden. Add min-h-0 and
my-auto, use dvh, scroll the auth shell, and pin Enter Sencho outside the
preflight scroll region.
Remove redundant nav and label dots, move desktop masthead status to animated left rails, and let AuthCanvas scroll long preflight content on small viewports.
Stack success events were hidden from the panel but still counted unread on the bell and dashboard.
Share one visibility helper across badge, panel, and Recent Alerts.
Harden mark-all-read against partial API failures.
Fixes#1513
Rename "Janitor threshold" to "Reclaimable Docker data threshold" in Settings
and update the unused-Docker-data alert to point users to the Resources view
and the Prune Node Resources scheduled action, which are the real UI surfaces
for reclaiming disk space. Remove "janitor" from settings search keywords.
The internal docker_janitor_gb database key and JANITOR_* constants are
unchanged.
* fix: enforce 1:1 compose path mapping for Pilot agent mounts
Pilot enrollment now generates validated 1:1 bind mounts so every
agent path maps to a unique compose directory. Persisted agent paths
reconcile during startup to catch drift. Unsafe relative-bind redeploys
are blocked before container removal to prevent path escapes.
- Add composePathMapping utility with strict path validation
- Generate COMPOSE_DIR and validated mounts during Pilot enrollment
- Reconcile persisted agent paths during startup bootstrap
- Block redeploy when a relative-bind mount would escape the compose root
- Default Pilot UI path to /opt/docker/sencho
- Update multi-node and pilot-agent documentation
- Add regression tests for enrollment, bootstrap, compose-service,
and environment-check paths
* fix: update E2E enrollment regexes for YAML-quoted token values
On phone widths the volume browser kept its fixed 260px tree column beside the
preview, leaving the preview roughly 54px wide so file content wrapped to one or
two characters per line. Below the md breakpoint the two panels now stack into a
single column (tree above, preview below) with the tree capped at 40% of the
height, so the preview spans the full width. Desktop and tablet layouts are
unchanged.
When a remote node's bulk /stacks/statuses is unavailable or returns the
legacy plain-string format, the sidebar derived a stack's status from its
containers by treating "any container running" as healthy. A stack with one
running and one crashed container was shown green UP and excluded from the
Down filter.
Re-derive status from the per-stack container list in both compatibility
cases, mirroring the backend classifier: a stack with a running container and
a genuinely crashed one (dead, or exited/restarting with a non-zero code) is
now partial, while a cleanly finished one-shot container (exit 0) stays
running. Legacy plain-string bulk responses are routed through this path too,
since they have already collapsed the degraded case.
An older remote node ignores the forwarded actor-role header (running proxied
requests as admin) and ignores the stop-by-label stack allowlist (stopping
every label-matched stack). The control could neither detect nor prevent this
on a mixed-version fleet.
Instances now advertise a cross-node-rbac capability, and the control refuses
to act when a remote lacks it:
- HTTP proxy: a non-admin user's request is not forwarded to a remote that does
not advertise the capability (fails closed when it cannot be determined).
Admins are unaffected.
- Stop-by-label: a real stop bound to a confirmed stack set is not sent to a
remote lacking the capability; the node is reported as needing an upgrade. As
defense in depth, a node whose results name stacks outside the confirmed set
is failed rather than rendered as a clean stop.
Separately, the stop's lock-contention path now reports every confirmed stack
as a contention failure (including one that lost its label), so a confirmed
stack is never silently dropped and the result is never empty.
The fleet overview, configuration, dependency-map, and networking-summary reads
were authentication-only, so a role without node:read (deployer) could read node
names, host stats, and cross-node topology. They now require node:read, matching
the role model where every role except deployer holds it.
For parity, the Fleet nav entry is gated on node:read (hiding it from the top
nav, mobile menu, and command palette), the Fleet view redirects to the
dashboard when reached without it, and the dashboard fleet heartbeat falls back
to the single-node restart map for a role that cannot read fleet data.
A fleet stop re-matched stacks by label name at execution, so a stack that
gained the label between the operator's preview and confirmation could be
stopped even though it never appeared in the confirmation. A confirmed node
that was deleted after the preview also vanished from the results, letting the
remaining successes read as a clean stop.
The confirm flow now sends the exact node and stack list resolved in the
preview. Each node's stop is bound to that set: only stacks that are still
label-matched and confirmed are stopped, and a confirmed node missing from the
registry is reported as an explicit failure rather than dropped.
* fix: address review feedback on motion, fleet band, and snapshots
- Reduced motion now also skips the animate-ui overlay open/close animations
(dialog, sheet, popover, dropdown-menu) via a shared useReducedTransition that
zeroes the transition when reduced motion is active; MotionConfig alone only
neutralized transform/layout, leaving the opacity/blur fade.
- Fleet tab band: flatten the list's own pill band so the tabs sit in a single
full-width band instead of a nested second band.
- Fleet Overview: label the node-update button "Node Update"; the add-node
button becomes a ChartNetwork icon that opens Settings > Nodes (outline style,
matching the node-update button) instead of the add-node modal.
- Blueprint deployments empty state: drive the serif headings from the theme
heading-style token so Calm drops the italic.
- Snapshots: move the per-stack Restore onto the stack header row (right side),
matching the ghost action style.
* feat: standardize the tab band on Security and add a tab hover state
- Apply the Fleet full-width tab band to the Security page (single band, the
list's pill background flattened so the tabs sit directly in it).
- Add a hover highlight to tabs so the band reads as interactive, not flat;
the active tab is unaffected.
* fix: drive reduced-motion transitions from the app setting, not the OS query
useReducedTransition called framer-motion's useReducedMotion(), which only
reflects the OS prefers-reduced-motion media query and ignores our MotionConfig
/ appearance toggle, so dialogs kept animating with the setting on. Read the
app's useReducedMotion selector instead. Verified: with the setting on, the
dialog opacity snaps 0->1 in one frame; with it off, it ramps over the spring.
* feat: standardize stack-detail body text to text-xs and add a log expand toggle
- Bump the anatomy panel body text (rows, field values, warnings) and the
structured log lines from 10/11/12px to text-xs (12px), leaving the uppercase
kicker labels and the log level/source badges as-is.
- Add an expand control next to the log download button that collapses the
Command Center so the logs pane fills the left column; toggles back to restore.
Scan policies, deploy enforcement, the suppression-aware deploy-block
toggle, SARIF export, and OpenVEX export now work on Community, matching
the rest of the vulnerability-scanning surface that was already free.
Backend: drop the tier gate from the seven security routes and from the
dashboard configuration-status scan-policies row, so the Dashboard and
Fleet config cards stop hiding the Vulnerability scanning row. Reading
policies stays auth-only; mutations and exports stay admin-only.
Frontend: always show the Policies tab and panel, the SARIF and VEX
export actions, and the honor-suppressions toggle for admins.
Docs: move scan policies, SARIF, and OpenVEX to every tier across the
feature and API-reference pages; clarify that Fleet Sync's cross-node
replication remains the paid part.
* fix: harden cross-node fleet label actions and guard container reads
Release-stabilization fixes for the Fleet Actions surface:
- Stop-by-label binds execution to the nodes shown in the confirmed
preview. The real stop sends the confirmed node ids and the backend
restricts the fan-out to them, so a node that was unreachable during
preview and reconnects before the stop can no longer enter execution
and have unlisted stacks stopped.
- Bulk label assign validates each remote node's result against the
stacks it was asked to label: a body whose results are empty, partial,
duplicated, or shaped wrong is a per-node failure instead of reading as
a successful zero-stack assign. The card mirrors this, rejecting a
missing or non-array results body and only reporting success when at
least one stack was assigned.
- Bulk label assign re-reads authoritative per-node stacks and labels on
demand via a Refresh control, and the confirmation lists the affected
node and stack names rather than bare counts.
- The stack-specific and fleet container/stack read routes require the
stack:read permission, matching the generic container and stack routes.
Every shipped role already carries stack:read, so reachability is
unchanged; the guard closes the routes that were auth-only.
Adds unit coverage for the assign-result validator, route coverage for
the stop allowlist and assign membership checks, and authorization
coverage for the newly guarded reads.
* test: assert the confirmed node allowlist in the fleet stop-card test
The stop-card component test pinned the real-stop request body to
{ labelName, dryRun } and broke once the stop began carrying the
confirmed-preview node ids. Update it to expect the nodeIds allowlist
derived from the resolved preview, so the test asserts the binding
rather than the pre-fix shape.
A batch of UI/UX polish:
- New independent "Reduced motion" appearance setting (separate from Reduced
effects). Drives framer-motion via MotionConfig and clamps CSS transitions via
data-motion on <html>; toasts are unaffected. Defaults off (OS preference
still honored).
- Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted
word-wrap toggle to the file viewer (default on), and add a fullscreen toggle
that collapses the Command Center + Logs column so the editor fills the width.
- Create Stack > From Git: remove the nested scroll clamp so the deploy toggle
and footer are reachable.
- Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only
Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state
headings (calm drops the italic), and fix the Actions card body overlapping
the action-row divider.
- Snapshots: restyle Restore and Restore all to the ghost button design used by
View / Preview / Download, and right-align the per-stack Restore.
- Settings sidebar: App Store gradient active style and standard font size.
- Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the
findings change, via a shared fingerprint-keyed hook.
- Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet
Snapshots tab (FleetView tabs are now controlled to support the deep link).
Security page UX fixes:
- Stop the CVSS x EPSS scatter chart from painting a full-plot "white
rectangle" cursor on click (cursor disabled), and prevent click-drag
selection on charts.
- Differentiate the overview action links: "fixable" links (masthead primary
action, review-queue blocker, and the Fixable signal tile) now open the
Images tab pre-filtered to fixable findings; the Stale and Failed signal
tiles link to the History tab where those scans are listed; Secrets and
Misconfigs tiles link to their tabs. The Images tab accepts an initialFilter
and exposes a Fixable option in the severity dropdown.
- Fix the "Secrets / misconfigs" option wrapping and misaligning in the
severity dropdown (single-line option labels, wider trigger).
- Add Edit for CVE suppressions and misconfig acknowledgements (reason, scope
pattern, expiry), reusing the existing dialog and the existing PUT endpoints;
the CVE/rule identity stays fixed.
PR #1497 pinned an enabled one-shot's next_run_at on creation, but the chosen
instant did not survive two paths: editing reconstructed the date from the
yearless cron (current year), and a disabled one-shot nulled next_run_at with no
other store, so enabling it later recomputed from the cron. Both moved a
future-year one-shot to a different annual occurrence than the date displayed.
Persist the one-shot's absolute fire time in a dedicated run_at column
(additive, nullable; recurring schedules leave it null). Create and update store
run_at independently of the enabled state; next_run_at is derived from it when
enabled and null while disabled, so a disabled one-shot keeps its run_at and the
enable toggle restores the exact instant from the column rather than the cron.
The editor reconstructs a one-shot's date from the persisted run_at, so opening
and re-saving without changes preserves the originally chosen year.
No behavior change for recurring schedules or fresh installs; the column is added
by an additive migration safe for upgrades from v0.92.0.
Make the Resources Images, Volumes, and Networks tables sortable with a
shared useTableSort hook and SortableTableHead, move the Images scan-history
control into the Images tab header, and keep the network List/Topology toggle
anchored with Create Network visible in both modes.
Rework the dashboard Stack health table: drop the redundant Host column, add
sortable Stack/Up/CPU/Mem headers, and add Source (local/git) and Port columns.
The status endpoint now labels each stack with its git/local source, computed
outside the cache so linking changes show immediately.
Extract a reusable CreateNetworkDialog and add a create-network action to the
stack-detail Networking tab.
Simple "Once" schedules compiled to a 5-field cron, which has no year field, so
the scheduler computed the next run as the next annual occurrence. A date chosen
for a later year ran a year early, and a time already elapsed today ran a year
late, contradicting the UI promise that the task fires on the chosen date.
One-time schedules now send the chosen absolute timestamp (run_at) and the
backend pins next_run_at to it instead of the cron-derived next run, so the run
fires on the exact selected instant including the year. The Simple-mode
validation now compares the full chosen instant against the current time, so a
time earlier today is rejected as past rather than silently deferred a year.
run_at is validated as a finite, future epoch-millisecond timestamp on create
and update. The enable/disable toggle preserves a one-shot's pinned next_run_at
(its yearless cron cannot reconstruct the chosen year), so re-enabling restores
the exact instant. Recurring shapes and Advanced mode are unchanged (cron stays
authoritative).
Opening the New Schedule modal from a stack's Schedule action, or editing
a stack-targeted task, set the node and the stack, but the node-change
effect then ran and cleared the stack, forcing the user to reselect it.
Move the stack-clear out of that effect into the Node picker's change
handler so it only fires on a user-driven node change. A programmatic node
set from prefill or edit now preserves the stack.
The schedule editor exposed only a raw 5-field cron input, which is
unfriendly for the common "daily at 3am" or "one-time next week" cases.
Add a Simple mode (now the default) that builds the cron from a
frequency and time: Once, Hourly, Daily, Weekly, and Monthly. Advanced
mode keeps the raw cron input as an escape hatch.
- Simple mode compiles to the existing cron_expression on save; no
schema or scheduler changes.
- One-time schedules reuse the existing delete-after-run flag: selecting
Once turns it on and locks it so the task runs a single time (cron has
no year field, so without it the task would repeat yearly).
- Editing a task opens in Simple mode when its cron maps to one of the
simple shapes, otherwise in Advanced; switching a custom cron to Simple
warns that it will be replaced.
- Day-of-week uses frosted toggle chips and the time uses hour/minute
selects so the controls match the rest of the editor.
When the advertised version changed and the follow-up release-notes request
failed (non-OK response, network rejection, or unparseable JSON), the previously
loaded notes, version label, and link were never cleared, so the changelog kept
showing the prior version's notes as if they described the new advertised update
and did not retry. The settled-version latch then suppressed an automatic
refetch.
The changelog now drops the loaded notes, version, and link the moment it
commits to fetching for a different advertised version, so any non-matching,
null, or failed result falls through to the empty state with the online
changelog link. The skeleton covers the in-flight gap, and the settled-version
latch is preserved so a failure does not loop.
The changelog recorded every settled release-notes fetch as loaded for the
advertised version and rendered whatever notes came back, without checking that
the returned version matched. Because the version lookup and the release-notes
lookup use independent caches (and the version can fall back to Docker Hub while
notes are GitHub-only), a drifted response could show one version's notes under
a different advertised update.
The changelog now binds strictly: notes render only when the endpoint confirms
they belong to the advertised latest version; a mismatch falls through to the
empty state with the online changelog link. The settled-version latch is kept so
a mismatch does not loop the fetch.
The changelog tab fetched release notes once and held them in component state
without tying them to a version, and the endpoint did not report which release
the notes belonged to. When a newer release surfaced while the sheet stayed
mounted, reopening the changelog could show the previous version's notes, and a
GitHub/Docker Hub fallback or independent cache timing could leave the notes out
of sync with the advertised latest version.
The release-notes endpoint now returns the release version (normalized
tag_name). The changelog keys its loaded notes to the advertised latest version,
refetching when that version changes, and labels the notes with the version they
belong to so the displayed content is always explicit.
Timeline pills and the mobile schedule list now identify what each
scheduled run acts on instead of repeating the task name. Pills stay
compact (firing time plus a category-aware target) and carry the full
detail on hover:
- Stack actions show the stack name.
- Fleet snapshots show "Entire fleet".
- Fleet auto-updates and node-scoped prune/scan show the selected node.
- The hover tooltip adds the action label, task name, and node.
The mobile list resolves node names too, so prune and scan rows name the
node rather than the literal "system". A shared scheduleTargetDescriptor
helper removes the target-label logic that was duplicated across the
desktop and mobile views. The lifecycle lane is renamed "Stack lifecycle"
to match the action-picker category wording.
The scan detail sheet fetched only the first 500 vulnerabilities for its
interactive table, so severity filtering, row inspection, and suppression
management could not reach findings beyond the first page on a scan with more
than 500. The CSV export already paged the complete list, but that is not a
substitute for working with the findings in the table.
The sheet now loads every vulnerability via the same paged helper the CSV uses,
so the table, filter, pagination, inspection, and suppression all operate over
the complete set. The "showing first N of M, export CSV for the complete list"
notice is removed because the table is no longer capped. The CSV export reuses
the already-complete in-memory set rather than refetching.
Secrets and misconfigurations keep their existing per-request cap; they are not
the suppression-managed findings this blocker concerns and rarely exceed it.
The Security overview's top exploit-risk list is built from a query capped at
2000 rows. The query had no ORDER BY, so when a node had more findings than the
cap the rows kept were arbitrary: the list could rank and display a subset that
omitted higher-risk findings, and the frontend discarded the truncated flag the
endpoint already returned, so nothing told the operator the list was partial.
- The query now orders by known-exploited, then EPSS, then CVSS before the cap,
so the rows that survive truncation are the highest-risk ones, matching the
client-side ranking the list applies.
- SecurityView keeps the truncated flag and threads it through to the list,
which now shows a short "more exist than can be listed here" note when the set
was capped.
Also fixes a presentation regression: the list colored every non-Critical
severity dot with the High color, so a Medium or Low known-exploited finding
(now surfaced alongside Critical/High) showed as High. The dot now maps to the
finding's actual severity.
When the pre-deploy gate could not scan or evaluate an image (a compose parse
error, a scan failure, an invalid image reference, or an evaluation error), it
pushed a synthetic violation with zero counts and no reason. The block dialog
then showed "0 critical, 0 high" with no explanation and only Close or admin
bypass, so an operator could not tell why the deploy was blocked or what to fix.
The synthetic violation now carries the failure reason in an error field, which
flows through the existing 409 block payload. The block dialog renders that
reason under a "Could not be scanned" label instead of a misleading zero-count
row, and shows a recovery hint pointing at the fix-and-deploy-again path.
The Changelog tab in the Node Updates sheet rendered GitHub release notes
as raw markdown inside a preformatted block, so headings, bullet lists,
and issue/commit links showed as literal markup and were hard to read.
Render the notes with a small react-markdown wrapper styled to the design
tokens (raw HTML is intentionally not rendered, keeping it safe). Also:
- Replace the bare loading spinner with a content-shaped skeleton.
- Add a graceful empty state when no notes are available, with a link to
the online changelog.
- Add a "View on Sencho" link next to "View on GitHub".
- Stop the release-notes fetch from re-firing on every failure by tracking
whether a fetch has settled, so a null result lands on the empty state
instead of looping; Recheck resets it to force a fresh fetch.
The pre-deploy gate names the inputs that matched a scan policy (a
known-exploited CVE, a fixable Critical/High, or a severity threshold),
but the informational post-scan surfaces still framed every violation as
a severity ceiling. The scan detail banner read "blocks severities at or
above X, highest severity is Y" and the scheduled-scan alert read
"<severity> exceeds <maxSeverity>", which is wrong for a KEV- or
fixable-only policy that never gated on severity.
Persist the matched reasons on the policy evaluation, carry them on the
scheduled-scan violation, and render them on the banner so every policy
surface names the input that actually matched. Evaluations persisted
before this change carry no reasons: the parser defaults the field to an
empty array and the banner falls back to a plain violation notice.
* fix: distinguish failed image-update checks from "up to date"
The image-update detector collapsed every failure (registry unreachable,
missing auth, rate limit, unresolved local digest) into hasUpdate:false and
dropped the captured reason, so a failed check was indistinguishable from a
current image and never raised a notification, even while a manual stack
update still pulled a newer image.
Detection now records a tri-state per stack (ok / partial / failed) with the
failure reason, exposed via a new GET /api/image-updates/detail (the boolean
GET / is unchanged so fleet aggregation is unaffected). A fully-failed check
preserves the last known has_update, so a transient outage neither erases a
real update nor flaps the notification state. The sidebar shows a muted
"couldn't check" indicator with the reason on hover, and the Update board
lists stacks whose check failed in a "could not be checked" advisory.
Detector hardening: the manifest digest lookup issues HEAD first (falling back
to GET) so it no longer draws down Docker Hub's anonymous pull-rate budget, and
local RepoDigest matching is normalized so official library/* images resolve
their digest instead of falling through to a silent "no update".
* fix: preserve confirmed updates through partial checks; tighten failure surfacing
Address review findings on the tri-state image-update detection:
- A partial check (some images errored) no longer erases a previously
confirmed update; only a fully-ok check can lower has_update, so a single
image's registry blip cannot drop the stack's update and re-fire the
notification on recovery. Adds a regression test.
- The image-level catch stores getErrorMessage(e) rather than raw String(e),
since that value surfaces verbatim in the sidebar tooltip and readiness
advisory.
- useImageUpdates and the readiness detail fetch now log unexpected non-ok
responses instead of silently leaving stale state.
- Remove an unused checkFailedCount derivation (the row indicator is driven by
the checkStatus prop).
- Reword the recordStackCheckFailure docstring and the HEAD-first comment.
The scan detail sheet fetches a capped page of vulnerabilities for
display, then told operators to "Export CSV for the complete list".
The CSV writer only serialized the rows already in memory, so for a
scan with more findings than the page cap the CSV silently dropped
everything past the cap: the recovery path the notice promised did not
exist.
Export now pages past the API's per-request cap and serializes every
row when the loaded set is short of the total, falling back to the
in-memory rows when they are already complete. The CSV action shows a
spinner and disables while the export runs.
Vulnerability scan rows were never cleaned up when their image was removed
from Docker or their stack was deleted, so the Security Overview (including
the Top exploit-risk findings card) kept surfacing findings for artifacts that
no longer exist.
Scan results now reflect what is still on the host:
- Deleting a stack immediately purges its stack:<name> compose-config scan.
- A background reconciliation in the monitor janitor removes scans whose image
is gone from the node, or whose stack folder no longer exists. It is
fail-safe: a scan is only removed when its artifact is positively known to be
gone, the Docker image list is read with a timeout (skipped on failure), and
stack scans are reconciled only when the stack list is non-empty.
- An opt-out "Remove scans for deleted images and stacks" setting (on by
default, per-node) lets operators retain scan history for removed artifacts.
Scan deletes remove child findings explicitly, since SQLite foreign-key cascade
is not enabled on the connection.
* feat: add node update alerts with changelog tab and skip-version handling
- Add node_update_available notification category with blue/brand bell dot
- Route node_update_available notifications to Fleet -> Node updates sheet
- Add Changelog tab to NodeUpdatesSheet with GitHub release notes
- Add per-node skip-version persistence (node_update_skips table)
- Skip hides update CTA on node card and sheet; re-surfaces on newer version
- Skipped nodes excluded from Update all backend filter
- Add pulsating dot indicator on Changelog tab when updates available
- Always-visible View changelog action in notification row bottom
- Admin-only for all mutating controls (skip, unskip, update)
- Backend tests for skip-version semantics (15 tests)
- Update fleet-view.mdx, remote-updates.mdx, and OpenAPI spec
* fix: address audit findings - nested button, stale changelog, semver normalization, mobile intent
- Move View changelog button outside routable button (sibling element)
- Fix aria-label for node_update_available notification rows
- Support ?recheck=true on release-notes endpoint
- Invalidate release notes cache on forced recheck
- Store normalized semver (semver.valid strips v prefix)
- Skip fleetUpdatesIntent on mobile (desktop only)
- Add v-prefix normalization test
* fix: restore View changelog on same line as timestamp, opposite sides
The button is always visible at the bottom right of the notification card,
on the same row as the timestamp (just now), using justify-between layout.
* fix: update tests for node_update_available category and release-notes fetch
- Backend: monitor-service tests now expect node_update_available instead of system
- Frontend: NodeUpdatesSheet tests mock release-notes API call to prevent undefined then()
* fix: resolve ci lint failures
Add structured posture reasons derived alongside the posture verb in
securityPosture.ts so the masthead and Overview tab can answer why the
page is red, what to do first, and what clears it.
Backend:
- derivePostureReasons() returns blocker, review, and info reasons from
the same SecurityPostureFacts used by deriveSecurityPosture()
- deriveSecurityPosture() depends on derivePostureReasons() internally
- Exposure split: public exposure with KEV, fixable, or EPSS >= 0.1 is a
blocker; exposure without any of those is a review item
- Fully dismissed exposed images produce no posture reason
- postureReasons and primaryAction returned by the overview endpoint
Frontend:
- ReviewQueueCard on the Overview tab with per-row CTAs for blockers
- Action summary in masthead subtitle and desktop primary CTA button
- Card gated on posture not being Unknown
- Backward compatible with older remote nodes
* feat: split Host Alerts into Host Alerts, Container Alerts, and Stacks guardrails
Move global_crash from Host Alerts to new Monitoring > Container Alerts section.
Move health gate and env deploy guardrails from Host Alerts to
Infrastructure > Stacks > Deploy Guardrails subsection.
Host Alerts now contains only host threshold settings (CPU, RAM, disk,
alert suppression, and the master host_alerts_enabled toggle).
Stacks gains a Deploy Guardrails subsection (node-scoped, admin-gated)
alongside the existing Workflow controls (browser-local).
Dashboard Crash detection row now routes to Container Alerts.
* docs: update crash detection toggle description to match new Container Alerts section
* feat: add cron scheduling mode for image update checks
Adds a cron scheduling mode alongside the existing fixed-interval
dropdown in Settings > Automation > Image update checks. Users can
now set a 5-field cron expression (e.g. "0 3 * * 1") for precise
time-of-day scheduling of registry polls.
- Backend: ImageUpdateService gains mode/cronExpression fields and
cron-based nextDelayMs() using the existing cron-parser dependency.
PUT /api/image-updates/interval extended with transactional writes
and server-authoritative cron validation matching the Scheduled
Operations contract. Nicknames like @daily are supported.
- Frontend: UpdatesSection gains a SegmentedControl toggle and cron
text input with cronstrue-powered live description. The frontend
does advisory validation only; backend 400s are surfaced inline.
SettingsPrimaryButton used for explicit "Save schedule" action.
- No cron jitter (the user chose a specific time). Interval mode
keeps existing ±10% jitter.
- Tests: 15 new backend tests covering valid cron, invalid cron,
6-field rejection, nickname support, backward compat, runtime
fallback, and transactional writes.
- Docs: auto-update-policies.mdx, alerts-notifications.mdx, and
openapi.yaml updated with new scheduling mode.
* fix: add mode and cronExpression to UpdatesSection test fixtures
The existing tests failed because the mock status object was missing
the new required fields (mode, cronExpression) added with cron
scheduling support. Without them, status.mode was undefined, causing
uiMode to never match 'interval' and the Select combobox to not render.
* fix: prevent SegmentedControl from stretching full-width in SettingsField
The flex-col container defaults items to align-self: stretch, making the
Interval/Cron toggle bar span the full card width. Add self-start so it
sizes to its content.
Apply Monaco built-in ini language mode to the .env editor tab,
matching the syntax highlighting already used by the FileViewer,
diff previews, and Git source diffs for env content.
- EditorView.tsx: change env tab language from plaintext to ini
- Add EditorView.test.tsx: 3 cases asserting ini/yaml/files tabs
- Docs: update editor.mdx to reflect env tab has highlighting
* feat: add per-stack project env file selection for Docker Compose
Allow users to configure an ordered list of env files per stack that serve
as the project environment file(s) for Docker Compose ${VAR} interpolation.
The selected files are passed via repeated --env-file flags during all
compose commands.
Backend:
- Add stack_project_env_files table (node-scoped, ordered)
- Extend authoredComposeEnvFileArgs to emit --env-file for configured files
- Add GET/PUT /stacks/:name/project-env-files and /candidates endpoints
- Update resolveStackEnvSources to use configured files as interpolation source
- Update resolveAllEnvFilePaths to merge injection + interpolation sources
- Add discoverStackLocalEnvFiles for candidate discovery
- Extend backupStackFiles and snapshotStackFiles for project env files
- Add project-env-files capability to CapabilityRegistry
Frontend:
- Add project env file selector to EnvironmentPanel (capability-gated)
- Update EditorView banner to generic "project environment file" language
- Add project-env-files capability to capabilities.ts
Issue: #1454
* fix: add realpath validation, clear all stale backup files, reject nested paths
- authoredComposeEnvFileArgs: use fsPromises.realpath + isPathWithinBase
for symlink escape defense at use time
- backupStackFiles: clear ALL non-marker files from backup slot before
writing, not just PROTECTED_STACK_FILES (handles stale old.env)
- PUT project-env-files: reject paths containing / or \ (root-level
only, matching Compose auto-discovery behavior)
* fix: add getStackProjectEnvFiles to compose-service mock
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles
on the DatabaseService singleton. The compose-service mesh-override
tests mock that singleton without the new method, causing 6 failures.
Add getStackProjectEnvFiles: () => [] (empty = fall back to legacy
behavior, which is what these tests exercise).
* fix: add getStackProjectEnvFiles to remaining service mocks
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles,
which is missing from the mock in compose-images.test.ts (6 failures)
and image-update-service.test.ts (proactive fix).
* fix: apply inline path-injection barrier at fs sink for CodeQL
The PUT project-env-files route resolved paths via isPathWithinBase
before calling fsp.stat, but CodeQL does not credit a containment check
separated from the sink. Apply the canonical inline barrier pattern
(path.resolve + startsWith at the sink) used throughout the codebase.
* fix: resolve stackDir from the same canonical root as safePath
Prevents a containment bypass when the compose base directory is
a symlink: stackDir was previously joined from the unresolved
baseDir while the inline barrier used path.resolve(baseDir),
which could differ for symlinked paths. Now both stackDir and
safePath are resolved from a single canonical root, then each is
containment-checked against it.
* fix: remove unused isPathWithinBase import
The inline path-injection barrier refactor replaced isPathWithinBase
with an inline startsWith check at the fs sink, so the import is now
unused and fails ESLint no-unused-vars.
* feat: add ON/OFF toggle for host threshold alerts
Add host_alerts_enabled setting (default ON) as a master switch for CPU,
RAM, and disk host threshold evaluation. When OFF, the four threshold
controls in Settings > Host Alerts are disabled and MonitorService skips
the systeminformation calls and alert dispatch entirely, while clearing
stale suppression state so re-enabling starts fresh.
The dashboard Configuration Status card shows "Off" when host threshold
alerts are disabled. Crash capture, health gate, deploy guardrails,
stack alert rules, and the Docker janitor are all unaffected.
* fix: exit NumberChip edit mode when externally disabled
When the host threshold alerts master toggle is turned OFF while a
NumberChip is in edit mode, force-exit edit mode so the chip renders
the greyed-out button state consistently with the other chips.
* feat: show container name in structured log output
Prepend a normalized container name prefix to each line in
ComposeService.streamLogs() so both the structured log viewer
and the raw terminal identify which container produced each entry.
- Backend: prepend displayName (normalized via normalizeContainerName)
before LogFormatter.process() in sendOutput and flushBuffer.
- LogFormatter: refactor process() to handle both prefix-first and
timestamp-first input orders via a while-loop; widen PREFIX_REGEX
to accept dotted service names.
- Frontend: add containerName to LogRow, extract prefix in parseLine,
render as an inline mono chip in the message column, and include
the name in downloaded logs (omitting the bracket prefix when null).
- Tests: 14 new tests across log-formatter, compose-service streamLogs,
and StructuredLogViewer chip rendering + download formatting.
* fix: guard LogFormatter loop to at most one prefix and one timestamp
The while-loop refactored for order-agnostic prefix/timestamp
parsing could continue matching beyond the intended single prefix
and timestamp. A log line like "redis | 2024-...Z api | started"
would falsely colorize "api |" as a second container prefix in
raw terminal output.
Add prefixFound/timestampFound boolean guards so the loop stops
after one prefix and one timestamp, regardless of input order.
* feat: per-service color alternation for log container chips
Add an Appearance setting that lets users switch between unified
cyan and per-service label-token colors for the container name chips
in the structured log viewer.
- Extract HUE_VARS and hashLabel() from NodeLabelPill into a shared
utility at frontend/src/lib/label-colors.ts.
- Add useLogChipColorMode hook (browser-local localStorage,
sencho.log-chip-color-mode key, unified by default).
- Add SegmentedControl in Settings > Appearance > Display.
- Apply inline label-token styles via style attribute in per-service
mode; keep current text-brand/80 bg-brand/10 classes in unified mode.
- 14 new tests across label-colors, hook, and viewer chip rendering.
The Anatomy view's interpolation regex did not skip Compose's $$ escape
syntax, so $${VAR} (a literal, not a reference) was incorrectly flagged as
a missing variable. Add the (?<!\$) negative lookbehind that the backend
parser already uses, matching its behaviour.
* feat(scheduler): add helper text and risk badges to scheduled action picker
Add a concise helper text and risk level badge to every scheduled action
in the create/edit modal. The six risk levels (Safe, Read-only, Interruptive,
Runtime change, Removes containers, Destructive) map to the four existing
design-system tones and render as a small dot+label chip next to the helper
text, following the same pattern as SeverityBadge.
Fix an ambiguous mobile label: update + target_type: fleet now resolves
through resolveTaskAction and renders 'update node stacks' instead of the
misleading 'update fleet'.
Add exact helper-text and risk-level assertions for all 10 actions, plus
component tests for default modal state, action-switch scenarios, and
mobile update+fleet rendering.
* docs: update stale scheduled-operations alt text for changed helper text
The structured log viewer accumulated log rows across stack switches
because the useEffect cleanup closed the old WebSocket but never
cleared the committed rows state. Reset rows, row IDs, and auto-follow
at the top of the effect before connecting to the new stack. The level
filter is intentionally preserved across switches.
Closes#1444
* feat(scheduler): group schedule action picker by operator intent
Reorganize the New Schedule action picker from a flat dropdown to a
category-grouped list (Lifecycle, Updates, Security, Maintenance, Backups).
- Extend Combobox component with optional group field on ComboboxOption,
rendering grouped sections with non-interactive headers when groups are
present. Flat rendering is unchanged for all other callers.
- Reorder SCHEDULED_ACTIONS by category group and update seven action
labels per the operator-intent spec.
- Add DEFAULT_SCHEDULED_ACTION_ID constant so picker order and form
defaults are independently controllable.
- Wire grouped actionOptions into ScheduledOperationsView.
- Update all label references in docs and tests.
- Add Combobox grouping tests, registry order test, and default-constant
test.
* fix(scheduler): correct Combobox grouping for interleaved groups, docs labels
- Replace last-group-append with Map-based group partitioning so
interleaved or mixed-group options land in the correct group.
- Add interleaved-groups test and restore non-interactivity test.
- Update stale "Start Stack" references to "Start / Bring Up Stack"
in doc action-label contexts.
- Update action-picker alt text to describe the new grouped order.
* feat(security): surface Compose internet-reachability exposure in posture
Builds a per-stack per-service exposure descriptor from the rendered
effective Compose model, cached at deploy/update time, and joins it into
the Security action posture. A service is publicly exposed when it
publishes a port on a non-loopback host IP or uses host networking.
The exposure cache lives in a new stack_exposure table, refreshed inside
ComposeService.deployStack and updateStack (covering all funneled paths:
manual, scheduler, mesh, templates, labels, App Store, Git, webhooks).
Cleanup runs on stack delete, blueprint withdrawal, and node delete.
The overview route intersects the exposed image set with the existing
per-image suppression-aware Critical/High tally, so a clean public
nginx does not escalate posture. The scan sheet shows a "Published
service" or "Internal only" evidence badge per image.
* fix(test): provide fresh auto-close proc for exposure spawn in stall tests
Two deployStack idle-stall tests used mockSpawn.mockReturnValue(proc)
which returned the same already-closed process for the new config spawn
added by the exposure refresh. The renderConfig promise hung waiting for
a close event that had already fired.
The fix uses mockImplementation to return the controlled proc for the
first spawn (up) and a fresh auto-closing proc for the second spawn
(config via refreshExposureCache).
* fix(security): tighten loopback detection, clarify exposure semantics, drop internal-only badge
- Expand isLoopback to cover full 127.0.0.0/8 range (127.0.0.2 etc)
- Clarify that exposure is configured (Compose model), not live topology
- Remove "Internal only" badge: false is not proof of non-exposure when
other stacks using the same image may lack a cached descriptor
* fix(scheduler): reject 6-field cron in Scheduled Operations
Create and edit validation parsed cron with cron-parser, which accepts both
5- and 6-field expressions, while the form, presets, and docs all describe a
5-field cron. Because the scheduler ticks once per minute, a leading seconds
field can never improve precision, so a 6-field expression was silently
accepted but never honored on its stated schedule.
Add a field-count guard on both sides: the API rejects 6-field input at
create and edit with a clear message, and the form surfaces the same error
inline and disables save. Cron nicknames such as @daily still pass. Document
the five-field requirement in the cron reference.
* chore: merge main into scheduled cron validation
* fix: avoid logging policy bypass actor in debug output