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.
In cron mode the service armed the first check for two minutes after boot
regardless of the configured schedule, so a restart triggered an out-of-cadence
check (a weekly cron would run on every boot, then follow cron). Arm the first
check at the next cron fire time instead; interval mode keeps its 2-minute
post-boot delay.
A previous fix canonicalized each Sencho-managed path (compose base, data dir,
application root, OS temp root, upload spool, Trivy binary, Trivy cache) with
fs.realpath so a managed path that is itself a symlink could not be reached through
a bind to its real target. fs.realpath returns ENOENT for a dangling symlink whose
final leaf does not exist yet, so that case fell through to the configured path only
and the implied target was lost. With TRIVY_BIN pointing at a symlink to a not yet
created binary, a bind to the symlink target's existing parent directory was still
classified as browsable and writable. A stack editor could create the leaf there
and have a later pre or post deploy scan execute the attacker supplied binary, which
the default image runs as root. The same gap applied to dangling symlinked upload
spool, Trivy cache, and OS temp roots.
Resolve managed paths with a helper that follows the symlink chain even when the
final leaf is absent: it follows a dangling link through readlink (resolving a
relative target against the link's own directory) and, for a missing leaf, resolves
the longest existing ancestor and re-appends the absent suffix, so the real target
location is always represented in the overlap set. A bounded hop count guards
against a symlink cycle that never surfaces as ELOOP, falling back to the lexical
path with a log. An unexpected resolution failure (EACCES, ELOOP) keeps the
configured path as the containment anchor, continues discovery for the other
managed paths, and is logged.
Tests: a mock-based regression test that fails before the fix, a real-filesystem
dangling-symlink test on Linux and macOS CI, a relative-symlink-target case, and a
non-ENOENT failure case asserting discovery degrades safely.
The file-explorer overlap check canonicalized declared bind sources (it realpaths
the source) but compared them against the Sencho-managed paths (compose base, data
dir, application root, OS temp root, upload spool, Trivy binary, Trivy cache) using
path.resolve only. A managed path that is itself a symlink, for example a relocated
Trivy binary whose configured path links to a real binary elsewhere, was therefore
compared by its symlink path. A bind to the symlink target's real directory did not
register as a managed overlap and became browsable and writable, so a stack editor
could overwrite the real binary a later pre or post deploy scan executes, or reach
transient registry credentials under a symlinked temporary root.
Resolve each managed path to its canonical target and keep both the configured path
and the realpath target in the overlap set, mirroring the bind-side canonicalization.
Containment only ever expands, so existing deployments and fresh installs are
unaffected. A missing managed path (the common fresh-install case) is tolerated: the
configured path still anchors containment, and any non-ENOENT realpath failure is
logged rather than collapsing discovery.
Adds two tests: a regression test that fails when a symlinked managed Trivy path is
compared without canonicalization, and a fresh-install test that an absent managed
path leaves a legitimate external bind browsable.
The scan-detail banner only recomputed its verdict when honor-suppressions was
enabled; otherwise it returned the snapshot stored at scan time. The deploy gate
always re-evaluates current policies, so with honor-suppressions off a policy
lifecycle change drifted the banner from the gate: disabling (or editing) an
enabled policy left the banner claiming a violation the gate would now pass, and
tightening a passing policy left the banner reporting a pass the gate would block.
Recompute the banner verdict unconditionally so it agrees with the gate across
the full policy and suppression lifecycle, regardless of the honor-suppressions
setting. The recompute already reads that setting itself (so a raw or
suppression-filtered verdict is chosen correctly), returns no verdict when no
policy matches (clearing the banner to match a passing gate), stays read-only,
and still falls back to the stored snapshot if it throws.
The file-explorer root containment treated the OS temp root as an ordinary
host path, so a stack author with stack:edit could declare it (for example
/tmp) as a bind source and browse it. Sencho writes short-lived secrets there:
ComposeService and TrivyService stage a docker config.json holding resolved
registry credentials, uploads spool under it, and compose/git/scan runs create
working dirs there. Exposing that directory is a credible path to read
admin-configured registry credentials during a pull or deploy.
The same gap left env-relocatable tool paths outside containment: a Trivy
binary placed at a custom TRIVY_BIN (for example under /opt, which is otherwise
allowed) could be overwritten through a bind and then executed by a privileged
pre-deploy scan.
Treat the OS temp root and the configurable upload spool, Trivy binary, and
Trivy cache as Sencho-managed areas, so a bind overlapping any of them (in
either direction) is never browsable, writable, or chmodable. The managed Trivy
install and cache already sit under the data dir and stay covered. Legitimate
external binds outside these areas remain fully editable.
The exploit-intel query capped its results with "EPSS DESC, then CVSS DESC",
treating an absent EPSS score as the lowest possible value. The overview list
ranks the same findings under the assume-it-is-automatable model, where a
finding with no EPSS evidence outranks one shown unlikely to be exploited
(known-exploited > elevated EPSS > unknown EPSS > known-low EPSS).
With more than the row cap of findings, the two disagreed: the cap could keep a
known-low-EPSS finding and drop a higher-CVSS finding with no EPSS evidence that
the list considers more urgent, hiding it from the dashboard. Rank the capped
query by the same tiers the list uses (reusing the shared EPSS threshold) so the
rows that survive truncation are the ones the list ranks highest.
The policy verdict shown on the scan-detail banner was the snapshot computed
once at scan time. When the deploy gate is set to honor suppressions, the gate
re-reads current suppressions on every deploy, but the stored verdict never
changed: after creating a matching suppression the banner kept claiming a
violation the gate would now pass, and after deleting or expiring a suppression
the banner kept reporting "ok" while the gate would block.
Recompute the banner verdict against current suppressions when serving the scan
detail, so it agrees with the gate across the create, update, delete, and expire
lifecycle. The recompute runs only when honor-suppressions is enabled (otherwise
suppressions affect neither the gate nor the verdict, and the stored snapshot is
returned unchanged), is read-only, and falls back to the stored snapshot if it
fails so an informational banner can never fail the scan-detail request.
The file-explorer root containment treated only kernel and OS-state paths
(/etc, /proc, /sys, /dev, /run, /var/run) as dangerous. System locations
that hold the executables and libraries Sencho's own runtime depends on,
notably /usr (which contains /usr/local/bin/node, the docker CLI, and the
entrypoint) plus /bin, /sbin, /lib, /lib64, /boot and /root, were left
browsable, writable and chmodable.
A stack author with stack:edit could declare one of these as a bind source,
overwrite a binary, and have a later deploy execute it. Add those locations
to the dangerous-root set so such a bind is never browsable or editable; the
boundary check still permits ordinary host paths whose name merely prefixes a
protected root (for example /usrdata).
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.
The pre-deploy gate filters suppressed findings when the honor-suppressions
setting is enabled, but the informational evaluation that drives the scan banner
and the scheduled-scan alert always scored the raw findings. A finding that was
fully suppressed therefore showed a policy violation on the banner even though
the gate would let the deploy through, so the two surfaces disagreed.
evaluateScanAgainstPolicies now mirrors the gate: when honor-suppressions is on
it loads the detail rows and drops suppressed findings before scoring (for the
severity input too, matching how the gate forces the detail path in that mode),
so the banner and alert agree with the gate. With the setting off, both continue
to score the raw findings. The truncation fail-closed rule stays gate-only; the
gate remains authoritative for blocking.
The Files & Volumes explorer derives its browsable roots from a stack's declared
bind mounts. A bind whose source resolves inside Sencho's own application root
(the working directory the image runs from, holding the compiled dist/, the
served public/, and node_modules) was classified accessible, browsable, and
writable. A non-admin with stack edit rights could therefore declare a bind such
as /app/dist into a stack and gain read/write access to Sencho's program files.
The bind-root classifier now treats the application root as a managed area, the
same way it already treats the compose base and the data directory, so a bind
that overlaps it is non-browsable and non-writable and the file routes reject
read and write operations against it. The check is gated on the bind not being
inside the current stack directory, so a legitimate stack-scoped bind under the
compose base (which can sit under the application root) stays browsable.
The application root is resolved dynamically from the process working directory,
mirroring how the data directory is resolved, rather than hardcoding a path.
* fix: request registry tokens with the target repository scope
The image-update detector authenticated to registries by reusing the scope
echoed in the registry's GET /v2/ ping. That ping carries no repository
context, and ghcr.io answers it with a placeholder scope
(repository:user/image:pull), so the token was requested for the wrong
repository and rejected. Every ghcr.io-backed image (including lscr.io, which
delegates auth to ghcr.io) then failed its manifest lookup and was reported as
"Registry unreachable", while Docker Hub and quay.io kept working. Always
request a pull scope for the repository being checked rather than the echoed
placeholder.
Also report the actual failure cause: getRemoteDigestResult now distinguishes
an authentication failure, a rate limit (with retry-after), a missing image, a
registry error, and a genuinely unreachable registry, instead of collapsing
every failure into "Registry unreachable". getRemoteDigest stays a
digest-or-null wrapper so the update-preview path is unchanged, and
listRegistryTags shares the same token path so it now resolves on
ghcr.io/lscr.io too.
* fix: neutralize control characters in the registry digest error log
The error-path console.error in getRemoteDigestResult interpolated the image
ref and the caught error message, both of which originate from compose-authored
input. Route them through sanitizeForLog so a crafted image string or upstream
error text cannot forge multi-line log entries (log injection). The returned
reason and the digest logic are unchanged.
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 pre-deploy gate reuses a cached scan for the same image digest within 24h.
The cache-hit path copied only the first 1000 detail rows while keeping the
cached scan's full aggregate total, so the persisted preflight scan stored fewer
detail rows than its total_vulnerabilities. The gate's integrity check in
evaluateImageRisk treats that mismatch as untrustworthy and fails closed on every
active KEV or fixable input, blocking a deploy with no actual matching finding
and bypassing honored suppressions.
The cache-hit copy now reads the complete detail set (getAllVulnerabilityDetails)
so the persisted scan keeps stored details equal to total_vulnerabilities,
matching a fresh scan and letting the gate evaluate the real findings.
The Security overview and exploit-intel surfaces picked the latest scan per
image without restricting to scans that ran the vulnerability scanner, and
counted known-exploited (KEV) findings only among Critical/High. Two effects:
- A newer secret-only node scan became the latest scan for an image and
clobbered its Critical/High/fixable/KEV posture to zero, which could read a
false Secure state.
- A Medium or Low severity KEV that the pre-deploy gate blocks on produced zero
overview and exploit-intel rows, so the page disagreed with the gate.
Posture queries now select the latest vulnerability-bearing scan per image, the
image summary sources its vulnerability counts from that scan via a LEFT JOIN
while still counting secret and misconfiguration findings from the latest scan
overall, and knownExploited is counted from a dedicated any-severity KEV query
that mirrors the gate.
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.
The auto-update, bulk-label, scheduler, and blueprint deploy block
messages hardcoded "image(s) exceed <max_severity>", which is wrong
under the risk-first policy model: a block can be driven by a
known-exploited (KEV) or fixable Critical/High input while the severity
threshold was never the trigger. In those cases the message named a
severity ceiling the policy did not enforce.
Route all four message paths through a shared summarizeBlockReasons
helper (the same reason text the deploy-gate 409 response and the block
dialog already use), so every surface names the inputs that actually
matched. Falls back to a generic phrase when no reason was recorded.
* fix(dependency-map): stop flagging env-var bind mounts as missing volumes
The Fleet Map "Missing dependencies" anomaly fired false positives for
services whose volumes use env-var-interpolated bind sources such as
${BACKUPS_PATH}:/backups. The compose parser classified the source as a
named volume because the ${VAR} token contains no slash, then the runtime
presence check found no matching volume and flagged it.
A compose named-volume key can never contain $, so any source with an env
var is a bind path whose value is unresolvable at parse time. Exclude it
from named-volume classification.
Closes#1464
* docs(dependency-map): clarify the env-var volume guard comment
Note that the $ check also covers the $$ literal-dollar Compose escape, and
state the named-volume key charset that makes the guard safe. No behavior change.
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.
Restore verification iterated the backup directory listing, so a file
recorded in the .checksums manifest but absent from the backup slot was
never checked. The orphan removal then deleted the live file and the copy
restored nothing, reporting success while leaving the stack unrecoverable.
Walk the manifest instead of the directory listing: a recorded file that
the slot no longer holds now aborts the restore before any file is touched,
alongside the existing corrupt-content check. Also fail backup creation when
a managed file exists but cannot be read (non-ENOENT), instead of silently
omitting it and producing an incomplete backup with the same failure mode.
The file-explorer editor save resolved a dangling symlink leaf to the link
path and wrote through it with a plain writeFile, which followed the link and
created a file outside the bind/stack root. Reject a resolved leaf that is
itself a symlink (mirroring the managed-stack guard) and promote the save
through the atomic stage-and-rename helper, so the editor save matches its
documented atomicity and can never leave a partial file or land outside the
root.
Bind-root discovery reported every source outside the compose base as
unreachable without probing it, so a config directory mounted into both the
app and the Sencho container was wrongly non-browsable. Probe the declared
source as Sencho actually sees it; dangerous host roots, docker-socket mounts,
and managed-area overlaps stay blocked, and the dangerous classification also
reads the literal declared source so it holds across platforms.
* 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
Give every scheduled action an explicit, predictable target model
(Action then Node then Stack then Options then Schedule):
- System Prune now exposes a Node picker and requires a node, so it can
no longer run silently on the default node.
- Vulnerability Scan and System Prune list local nodes only; both run on
the hub-local Docker daemon and reject remote nodes on the backend.
- Restart Stack service discovery loads services from the selected node
via fetchForNode instead of the active or local node.
- Fleet Snapshot shows a read-only "Scope: Entire fleet" summary.
Backend gains a shared local-node guard and prune node validation on
create and update, plus an executor-level remote-node guard, so the
frontend and backend validation now agree for every action.
Scheduled-operation action metadata was duplicated across the backend route
validator, the DatabaseService action union, the desktop action picker, the
Timeline lanes, and the mobile labels/tones. Adding or renaming one action meant
editing all of them.
Introduce one registry per package as the single source within that package:
- backend/src/services/scheduledActionRegistry.ts owns the action list and
target-type validation; routes/scheduledTasks.ts and DatabaseService import
from it (BackendScheduledAction type, VALID_ACTIONS, validateActionTarget).
- frontend/src/lib/scheduledActions.ts owns the UI metadata (labels, short
labels, categories, tones, target/node/stack/service flags, helper text) and
drives the create-flow picker, the All Tasks label, the Timeline lanes, and
the mobile schedule view.
Timeline lanes now group by semantic category (Lifecycle, Updates, Security,
Maintenance, Backups) sourced from the registry. The update-fleet UI alias is
made explicit via a backendAction field. Backend validation stays authoritative;
parity tests on each side keep the action sets in lockstep.
Image scan sheet: make the finding table the single bounded scroll region
(SystemSheet noScroll + flex-fill) so it no longer clips at the bottom on
shorter viewports, and give the tables a phone min-width so they scroll
horizontally instead of cutting off the right-hand columns.
Scan history table: add the same horizontal scroll on phones.
Overview "Top exploit-risk findings": render as a paginated table with column
headers and top-right pagination, modeled on the dashboard stack-health table.
Key the rows by rank position so recurring CVE/scan pairs no longer collide and
duplicate rows when paging through.
Overview severity-by-exploitability chart: label the axes (EPSS exploitability
and CVSS severity) and stop the card from stretching to a taller neighbour,
removing the dead space beneath the chart.
Scan-policy deploy gates can now block on a known-exploited CVE (CISA KEV)
and on a fixable Critical/High finding, in addition to an optional severity
threshold. New policies default risk-first (KEV and fixable on, severity off);
existing policies keep their severity-only behavior. CVSS stays captured for
context but is never the sole basis for a block, and a finding whose
exploitability cannot be confirmed is treated as risky rather than safe
(incomplete scan detail fails closed on KEV/fixable inputs).
The decision logic is shared between the pre-deploy gate and the informational
post-scan banner via a pure helper, so the two never disagree. Block messages
and the block dialog now name the conditions an image matched. Backend and
frontend gates move together, the new inputs replicate across the fleet, and a
blocking policy with no active input is rejected on both sides.