Commit Graph

1782 Commits

Author SHA1 Message Date
Anso dd76b13d55 fix: require node:read for fleet topology reads and hide Fleet without it (#1507)
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.
2026-06-28 16:39:11 -04:00
Anso 1dc12f7da8 fix: bind fleet stop-by-label to the exact confirmed nodes and stacks (#1506)
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.
2026-06-28 16:33:01 -04:00
Anso 78a742fb44 fix: enforce the signed-in user's role on cross-node proxied requests (#1505)
Proxied requests authenticated to a remote node previously ran as admin
regardless of the originating user's role, so a non-admin using the UI
against a remote node could reach admin-only handlers there.

The forwarding primary now asserts the user's role on a trusted header
that the remote honors only for node_proxy/pilot_tunnel bearers (the same
trust model as the license tier header), and the gateway overwrites the
header on every proxied request so a client cannot smuggle it. An absent
header keeps admin for direct instance-to-instance and background service
calls; an unrecognized role fails closed to read-only.
2026-06-28 16:32:50 -04:00
Anso d6ce60d280 fix: make Reduced motion gate overlays, standardize the tab band, and polish fleet/snapshots (#1504)
* 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.
2026-06-28 08:17:34 -04:00
Anso 04e69021e0 feat: make all security features available on every tier (#1502)
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.
2026-06-28 08:14:21 -04:00
Anso 05c483f213 fix: harden cross-node fleet label actions and guard container reads (#1503)
* 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.
2026-06-28 08:13:43 -04:00
Anso b5810a9b55 feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501)
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).
2026-06-28 06:18:02 -04:00
Anso 083442d5ea fix: differentiate security action links and add suppression editing (#1500)
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.
2026-06-28 06:17:47 -04:00
Anso ba57c67048 fix: persist a one-time schedule's run time so edit and disable keep its year (#1499)
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.
2026-06-28 05:23:23 -04:00
Anso 60536aa614 feat: sortable resource tables and richer dashboard stack-health columns (#1498)
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.
2026-06-28 05:06:04 -04:00
Anso cf0db36e78 fix: fire a one-time schedule on the exact chosen date and time (#1497)
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).
2026-06-28 04:31:16 -04:00
Anso a6d431f0d7 fix: keep the schedule's stack selected on prefill and edit (#1496)
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.
2026-06-28 03:18:27 -04:00
Anso 3bece54e72 feat: add a Simple mode to the New Schedule flow (#1495)
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.
2026-06-28 03:18:11 -04:00
Anso ed06900fd4 fix: clear stale node-update changelog notes when a refetch fails (#1494)
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.
2026-06-28 00:03:00 -04:00
Anso dd49ef65aa fix: render node-update changelog only for the advertised release version (#1493)
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.
2026-06-27 23:29:33 -04:00
Anso 4cf04056c9 fix: bind the node-update changelog to the advertised release version (#1492)
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.
2026-06-27 22:38:55 -04:00
Anso 3ad807be45 fix: schedule the first image-update check on the cron cadence after a restart (#1491)
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.
2026-06-27 22:38:31 -04:00
Anso bee0dfd094 fix: contain file-explorer binds that reach dangling symlinked managed paths (#1490)
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.
2026-06-27 14:32:50 -04:00
Anso 349ee1f905 fix: contain file-explorer binds that reach symlinked Sencho-managed paths (#1489)
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.
2026-06-27 00:43:29 -04:00
Anso 3343630171 fix: always reconcile the scan banner with the current policy verdict (#1488)
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.
2026-06-26 23:35:57 -04:00
Anso 3386c63152 fix: contain file-explorer binds into Sencho's temporary and tool directories (#1487)
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.
2026-06-26 23:26:46 -04:00
Anso c2b508e8b1 fix: rank the exploit-risk cap by the same tiers the overview list shows (#1486)
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.
2026-06-26 22:39:48 -04:00
Anso 04e9d18889 fix: keep the scan banner consistent with the deploy gate after suppression changes (#1485)
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.
2026-06-26 22:39:42 -04:00
Anso 73f4bc27c3 fix: contain file-explorer binds into Sencho's system directories (#1484)
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).
2026-06-26 22:39:33 -04:00
Anso 5960c1e85e feat: show node and fleet targets on schedule timeline pills (#1480)
Timeline pills and the mobile schedule list now identify what each
scheduled run acts on instead of repeating the task name. Pills stay
compact (firing time plus a category-aware target) and carry the full
detail on hover:

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

The mobile list resolves node names too, so prune and scan rows name the
node rather than the literal "system". A shared scheduleTargetDescriptor
helper removes the target-label logic that was duplicated across the
desktop and mobile views. The lifecycle lane is renamed "Stack lifecycle"
to match the action-picker category wording.
2026-06-26 22:15:06 -04:00
Anso 1bca75a999 fix: load the full vulnerability list in the scan detail sheet (#1483)
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.
2026-06-26 21:12:18 -04:00
Anso 7c9c640625 fix: rank exploit-risk findings before the cap and disclose truncation (#1482)
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.
2026-06-26 21:12:06 -04:00
Anso 7c12081645 fix: honor suppressions in the informational scan policy evaluation (#1481)
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.
2026-06-26 21:11:54 -04:00
Anso 3e2f0450c2 fix: block file-explorer binds that overlap Sencho's application directory (#1479)
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.
2026-06-26 21:11:37 -04:00
Anso 2911ccfe2b fix: request registry tokens with the target repository scope (#1478)
* 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.
2026-06-26 21:06:40 -04:00
Anso 628400ac19 fix: explain why a failed pre-deploy scan blocks a deploy (#1477)
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.
2026-06-26 19:13:47 -04:00
Anso 000a592388 fix: copy the full finding set when reusing a cached scan for the deploy gate (#1476)
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.
2026-06-26 18:58:54 -04:00
Anso 89a13f51e9 fix: keep security posture accurate for secret-only scans and any-severity KEVs (#1475)
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.
2026-06-26 18:58:05 -04:00
Anso 6256131dc6 fix: render node-update changelog notes as formatted markdown (#1474)
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.
2026-06-26 17:23:19 -04:00
Anso 1de49f8b1a fix: name matched risk inputs in policy scan banner and alerts (#1473)
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.
2026-06-26 16:43:26 -04:00
Anso d9b7911f12 fix: distinguish failed image-update checks from "up to date" (#1470)
* 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.
2026-06-26 16:16:34 -04:00
Anso e3b3c3b857 fix: export the full vulnerability list to CSV (#1472)
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.
2026-06-26 15:34:57 -04:00
Anso ca496c89dc fix: name matched risk inputs in policy block messages (#1471)
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.
2026-06-26 15:34:24 -04:00
Anso 5e2194f4a3 fix(dependency-map): stop flagging env-var bind mounts as missing volumes (#1468)
* 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.
2026-06-26 14:59:35 -04:00
Anso 26d557a701 feat: purge scan data for deleted images and stacks (#1467)
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.
2026-06-26 11:59:37 -04:00
Anso eaf0642d88 fix: reject atomic restore when a checksummed backup file is missing (#1466)
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.
2026-06-26 10:16:32 -04:00
Anso 1c82e3e1d4 fix: contain file-explorer writes and browse reachable out-of-base binds (#1465)
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.
2026-06-26 09:23:50 -04:00
Anso 315e8b6379 feat: add node update alerts with changelog tab and skip-version handling (#1463)
* 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
2026-06-26 00:07:51 -04:00
Anso 0384c47d1e feat: add posture reasons and review queue to Security overview (#1462)
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
2026-06-25 21:56:25 -04:00
Anso e9c262ae6a feat: split Host Alerts into Host Alerts, Container Alerts, and Stacks guardrails (#1461)
* 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
2026-06-25 21:04:55 -04:00
Anso 7320a86579 feat: add cron scheduling mode for image update checks (#1460)
* 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.
2026-06-25 19:47:57 -04:00
Anso 3bf677af6c feat: add syntax highlighting to .env editor tab (#1459)
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
2026-06-25 18:25:53 -04:00
Anso a698aaa926 feat: add per-stack project env file selection for Docker Compose (#1457)
* 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.
2026-06-25 18:03:05 -04:00
Anso b7dd9dc1b0 feat: add ON/OFF toggle for host threshold alerts (#1456)
* 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.
2026-06-25 16:05:16 -04:00
Anso f1f64ec7f6 feat: show container name in structured log output (#1452)
* feat: show container name in structured log output

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

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

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

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

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

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

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

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