Commit Graph

62 Commits

Author SHA1 Message Date
Anso d26ab58189 feat(fleet): cross-node bulk label assign with authoritative label discovery (#1389)
* feat(fleet): cross-node bulk label assign with authoritative label discovery

Make Fleet Actions > Bulk label assign work across the fleet. Pick a stack
label that exists anywhere in the fleet, select stacks on one or more nodes,
and the control orchestrates: each target node resolves the label by name,
creating it with the same name and color if missing, then adds it to the
selected stacks while preserving their existing labels. The local node runs
in process; each remote runs its own admin-only local-assign receiver over
the node proxy. Per-node failures (unknown node, no proxy target, unreachable,
mixed-version remote) degrade that node only and are reported per node in the
result. Assignment writes use a transactional INSERT OR IGNORE so the
add-preserve path is idempotent and race-free.

Also make the shared fleet label discovery authoritative: suggestions,
match-preview, and the fleet-stop remote leg now read each node's labels live
over the proxy instead of the control database, which does not mirror remote
labels. A propagated label therefore appears in, and is stoppable by,
Stop-by-label across the fleet, and unreachable nodes are surfaced rather than
silently dropped.

Fleet Actions runs against the unfiltered node list, so overview filters no
longer narrow its scope. The previous node-scoped, replace-by-id bulk-assign
endpoint is removed.

* fix(fleet): treat malformed remote label responses as per-node failures

A 200 response from a remote node whose body is not the expected shape was
treated as a benign empty result, so a malformed remote could read as a clean
zero-stack assign or a "matched, nothing to stop" no-op and even surface a
success toast. Validate the wire shape in the bulk-assign and fleet-stop remote
legs and in the authoritative label discovery fan-out; on a malformed body,
report the node as a per-node failure with the error attributed to its stacks
instead of silently dropping it.

* chore: drop accidentally committed temp file
2026-06-20 11:52:28 -04:00
Anso 02c3b006eb feat(fleet): refine the Fleet Overview toolbar (#1376)
* feat(fleet): tidy the Overview toolbar and shorten tab labels

- Move the Add node button into the Overview toolbar beside the
  Grid/Topology toggle so it sits with the view it acts on instead of
  showing on every Fleet tab. It stays admin-only.
- Collapse the node search to an icon button that expands to the full
  input on click and collapses again on blur once the query is empty,
  reclaiming toolbar width. An active query keeps it open.
- Match the sort-direction toggle and the sort dropdown to the outlined
  dark fill of the Filters button for a consistent toolbar row.
- Swap the Check Updates icon to the refresh-with-dot glyph.
- Rename two tabs: Fleet Actions to Actions, Dependencies to Map
  (display labels only; internal keys unchanged).

Update the feature docs to the new tab labels.

* feat(fleet): move Check Updates into the Overview tab toolbar

Check Updates is an Overview-specific action, but it lived in the shared
Fleet header toolbar that renders across every tab. Move it into the
Overview tab's own toolbar, to the right of the Add node button, so it
only appears where it applies.

The relocated button now spins and disables while a check is in flight,
matching the Refresh button's loading feedback. The shared header keeps
Refresh and Export Dossier.
2026-06-14 22:35:45 -04:00
Anso bef51a979f feat(fleet): tidy the Overview toolbar and shorten tab labels (#1361)
- Move the Add node button into the Overview toolbar beside the
  Grid/Topology toggle so it sits with the view it acts on instead of
  showing on every Fleet tab. It stays admin-only.
- Collapse the node search to an icon button that expands to the full
  input on click and collapses again on blur once the query is empty,
  reclaiming toolbar width. An active query keeps it open.
- Match the sort-direction toggle and the sort dropdown to the outlined
  dark fill of the Filters button for a consistent toolbar row.
- Swap the Check Updates icon to the refresh-with-dot glyph.
- Rename two tabs: Fleet Actions to Actions, Dependencies to Map
  (display labels only; internal keys unchanged).

Update the feature docs to the new tab labels.
2026-06-12 02:10:21 -04:00
Anso 54119be0c2 feat: move trivy auto-update, node labels, fleet topology, and single-scan SBOM to Community (#1336)
Rebalance several capabilities from the paid tier to the free Community tier:

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

Backend route guards and frontend affordances are updated together, with tier
and admin-role tests covering the Community-allowed and still-paid paths.
2026-06-08 08:58:14 -04:00
Anso 842ee7dd0c feat(fleet): export a whole-fleet Markdown dossier (#1334)
* feat(fleet): export a whole-fleet Markdown dossier

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

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

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

Disambiguate stack names on one node that slugify to the same value (e.g.
`Web` and `web` on a case-sensitive host) with a per-node slug map shared by
the node-page links and the file emission, so neither overwrites the other.
Pin a fixed entry timestamp on the zip so the archive bytes are a pure
function of the file map rather than the wall clock.
2026-06-07 21:10:02 -04:00
Anso 2072378396 feat(ui): make Fleet, Settings, and the dashboard table usable on mobile (#1331)
* feat(ui): make Fleet, Settings, and the dashboard table usable on mobile

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

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

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

Mask only the live sidebar ticker and notification count instead of the whole
sidebar / top-bar shell, so the gate now proves that shell unchanged too.
Lower the pixel budget to 1200 (the only residual churn is in-content live
stats); run against seeded / frozen data in CI for a zero-tolerance gate. Adds
a data-testid to the activity ticker so it can be masked precisely.
2026-06-07 01:14:27 -04:00
Anso af4083175c feat(fleet): add read-only dependency map tab (#1324)
* feat(fleet): add read-only dependency map tab

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

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

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

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

Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral)
to two: a generous free Community tier and a single paid Admiral tier. The
Skipper tier is removed.

Now free in Community: auto-heal, auto-update, scheduled operations,
webhooks, notification routing, Fleet Actions and bulk operations, SSO
preset providers (Google / GitHub / Okta), unlimited users with admin and
viewer roles, and deploy safety (atomic deploys, auto-rollback, and
one-click rollback).

Admiral (paid) is focused on running and governing a fleet: blueprints,
Fleet Secrets, deploy enforcement, vulnerability report export, audit log,
host console, private registries, mesh networking, node cordon, managed
cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles
(deployer, node-admin, auditor) with per-resource scoped assignments.

Internally the license variant distinction is removed so tier is binary
(community / paid). License validation still verifies the Lemon Squeezy
store and product before granting paid status.

Docs and the contributor guide are updated to the two-tier model.

* docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording

The licensing docs page kept the old Admiral pricing plus a Founder
Lifetime column and an Enterprise paragraph after the two-tier collapse.
Update it to $12/month or $99/year, drop the lifetime and Enterprise
content, and link to the pricing page for current pricing.

Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title,
and three test comments. Historical CHANGELOG entries and the
retired-Skipper license-guard test are intentionally left as-is.

* docs: align licensing and SSO pages with the two-tier model

Correct the SSO overview so the Google, GitHub, and Okta presets read as
available on every tier, matching the provider table; only LDAP and Active
Directory require Sencho Admiral. Remove the lifetime-plan references from the
licensing, settings, and troubleshooting pages so they reflect subscription-only
Admiral pricing.

* fix(rbac): omit scoped permissions from /me on the Community tier

Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case.

* docs: use custom-pricing wording on the contact page

The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
2026-06-04 17:45:53 -04:00
Anso c82a39c65a fix(mesh): hide node and stack management controls from non-admins (#1284)
* fix(mesh): hide node and stack management controls from non-admins

The Routing tab rendered the per-node mesh enable/disable toggle and the
stack opt-in/opt-out controls for any Admiral-tier user, but those backend
routes require the admin role. A non-admin viewer on an Admiral instance
saw controls that returned 403.

Thread a canManage flag (true only for admins) from the Fleet view into
the Routing tab, its node cards, and the opt-in sheet so non-admins get a
read-only Routing tab: the enable/disable toggle, add-stack, and
opt-in/opt-out controls are hidden, while status, aliases, topology,
activity, diagnostics, and the alias test probe stay available. This
mirrors the Federation tab's existing read-only treatment for non-admins.

Add backend route-gating tests covering the tier and admin-role guards on
every mesh route, and frontend render-gate tests for the node card and the
opt-in sheet in both density layouts.

* refactor(mesh): require canManage on the routing-node-card primitive

Remove the permissive `canManage = true` default on the shared
routing-node-card primitive so a new call site cannot render the
management controls without an explicit decision. Every current caller
already passes the flag; the type now enforces it. Drop the omitted-prop
test, which covered a state the compiler now prevents.
2026-06-02 16:09:25 -04:00
Anso 9fb4ccccff fix(fleet-secrets): restrict bundle management to admin hub sessions (#1274)
* fix(fleet-secrets): restrict bundle management to admin hub sessions

Fleet Secrets exposes decrypted environment-variable values and writes
credentials across the fleet, so every route now requires an admin role,
runs only on the instance you are signed into, and rejects long-lived API
tokens:

- Add an admin-role check to all secrets routes; the frontend Secrets tab
  renders only for admin users so the affordance matches the backend gate.
- Add /api/secrets/ to the hub-only path list so a request carrying a
  remote node id cannot be proxied to read another node's decrypted values.
- Reject API tokens on every secrets route (browser admin sessions only),
  matching how registry credentials are handled.

Also adds lifecycle and developer-mode diagnostic logging (never the secret
values) and tests covering the admin boundary on every endpoint, API-token
rejection, hub-only enforcement, and diagnostic gating.

* fix(fleet-secrets): require a signed-in user session for all secrets routes

The earlier API-token rejection only blocked opaque API tokens. node_proxy
and pilot_tunnel JWTs are mapped to an admin role by the auth middleware
without an API-token scope, so they still passed the admin gate and could
read decrypted bundles via GET /api/secrets/:id.

Replace the API-token check with requireUserSession, which rejects API tokens
and node_proxy / pilot_tunnel machine credentials (userId 0) on every secrets
route, returning SESSION_REQUIRED. The admin role is still enforced after.

Tests now assert SESSION_REQUIRED for a full-admin API token across all nine
routes and for node_proxy and pilot_tunnel JWTs.

* test(fleet-secrets): mint the rejection-test token via the real endpoint

The machine-credential test reconstructed an API token by sha256-hashing a
raw key inline. That duplicated a hashing sink that CodeQL's
js/insufficient-password-hash query flags (a false positive for a 256-bit
random token, but a new occurrence in the diff). Create the token through
POST /api/api-tokens instead, so the hashing stays in the production path
and the test carries none of its own. Behavior and coverage are unchanged.
2026-06-01 19:47:06 -04:00
Anso c11a550b6a fix(fleet-snapshots): gate reads on admin role and encrypt content at rest (#1273)
* fix(fleet-snapshots): gate reads on admin role and encrypt content at rest

Fleet snapshots capture every node's compose.yaml and .env, so the data is
as sensitive as the live stacks. This hardens access and reliability across
the snapshot pipeline.

- Restrict snapshot reads to administrators. GET /api/fleet/snapshots and
  /:id now require the admin role, matching create, restore, and delete; the
  Fleet "Snapshots" tab and its panel render only for admins. Previously any
  authenticated user could enumerate snapshots and read every node's .env.

- Encrypt snapshot file contents at rest with the instance key. Restore and
  cloud-archive paths decrypt on read, so cloud archives stay portable and a
  database copy no longer exposes stack secrets in plaintext. Rows written
  before this change still read back as plaintext.

- Surface partial captures. A stack whose compose file cannot be read or
  fetched, or a file over the 1 MB capture cap, is recorded as a warning and
  shown on the snapshot instead of being silently dropped, so a snapshot is
  never mistaken for complete. Remote .env read errors are now distinguished
  from a genuinely absent .env.

Adds route-authz, capture-warning, and encryption round-trip tests; updates
the Fleet-Wide Backups feature docs.

* fix(fleet-snapshots): gate cloud snapshot reads on admin role

The cloud snapshot read routes were guarded by provider/license only, not by
role, while their write counterparts (upload, delete) already required admin
and the Cloud Backup settings surface is admin-only. Because a downloaded
archive contains plaintext compose and .env files, a non-admin could list and
download cloud snapshots and read every node's secrets, the same exposure the
local snapshot reads were just closed against.

- Require admin on GET /api/cloud-backup/snapshots, /status/:id, and
  /object/:keyB64/download, matching the local snapshot reads and the
  admin-only Cloud Backup settings section.
- When capturing a remote node, treat a 200 response carrying X-Env-Exists:
  false as a stack with no .env (matching the local ENOENT path) instead of
  storing an empty .env that restore would later write back.

Adds non-admin authorization tests for the cloud read routes and a remote
absent-.env capture test.
2026-06-01 17:27:59 -04:00
Anso 0953025036 fix(fleet): gate node update actions to admins and harden update tracking (#1272)
* fix(fleet): gate node update actions to admins and harden update tracking

Node update affordances now render only for admins, matching the admin-only
routes behind them. Previously a non-admin could open the Fleet view and see the
per-node Update button, Update all, retry, dismiss, and Recheck controls, then
get a 403 on click. Those controls are now hidden for non-admins, who still see
read-only update status.

Both update-status clear routes (per-node and bulk) now require admin, and the
bulk recheck throttles its forced "latest published version" lookup so a caller
cannot loop it to hammer the upstream registries; the response reports whether
the refresh actually ran so the UI can surface a "checked recently" note.

Completion detection no longer reports a node as Updated when it merely blips
offline and returns on the same version with an unchanged process start time.
That case stays in progress and is decided by the existing early-fail and
timeout heuristics, so a momentary network glitch is not mistaken for a
successful update. Failed and timed-out updates now emit an operator-visible
warning, and a periodic safety-net sweep bounds in-flight trackers when no
client is polling for status.

* fix(fleet): harden update completion and recheck failure handling

Refinements from review of the node self-update hardening:

- Completion signal 1 now requires a valid version, not merely a different one.
  A node whose /api/meta momentarily omits or mangles its version (online, same
  process) reported version=null, which compared unequal to the previous version
  and falsely marked the update completed. It now stays in progress and is
  decided by the early-fail/timeout heuristics.

- Terminal resolution is atomic: it re-reads the live tracker and transitions
  only if it is still in flight with the same start time, so two concurrent
  status polls cannot both warn or clobber each other's transition.

- The operator warning for a failed or timed-out update now redacts
  secret-shaped text (bearer/basic/token/password, credentialed URLs) from the
  underlying error before logging, in addition to stripping control characters.

- The Recheck button now surfaces an error toast when the request throws
  (network or auth failure), matching the existing non-ok-response path instead
  of only logging to the console.
2026-06-01 17:27:25 -04:00
Anso d41282e352 fix(blueprints): gate Federation pin control on admin role (#1252)
* fix(blueprints): gate Federation pin control on admin role

The Federation tab rendered an editable pin control to any Admiral-tier user, but
PUT /api/blueprints/:id/pin requires admin role, so a non-admin Admiral user saw a
dropdown that returned 403 on use. Thread the admin flag into FederationTab and render
the pin placement read-only (with an administrator-required hint) for non-admins,
matching the existing canEdit pattern in the Deployments tab. The backend guard already
enforced admin; this aligns the UI affordance with it.

Add backend coverage for the tier/role authorization matrix across the blueprint routes,
remote-node deploy/withdraw ordering and failure mapping, edge cases (disable-with-active
409, selector cap, marker drift, cross-blueprint withdraw refusal), service developer-mode
diagnostics, and a frontend render-gate test for both admin and non-admin states.

* fix(blueprints): gate Apply action on admin role in blueprint detail

The blueprint detail sheet rendered an enabled "Apply now" control to any paid user,
but POST /api/blueprints/:id/apply requires admin. Gate the primary action on canEdit
so it matches the already-gated Edit / Disable / Delete actions and the backend guard;
non-admins keep a read-only detail view. Add a render test covering both the admin and
non-admin action bars.

Also strengthen the remote-deploy ordering test to assert global call order across spies
(create < compose < marker < deploy) via invocationCallOrder, not just per-method indices.
2026-05-29 15:12:48 -04:00
Anso 60f893a81f feat(fleet): move bulk Remote OTA updates to Community tier (#1151)
Drops `requirePaid` from `POST /api/fleet/update-all` so Community admins
can dispatch bulk node updates. Per-node OTA was already Community-
reachable (admin-only); this completes the move so the full Remote OTA
surface ships at Community.

Frontend mirrors the backend: removes `canBulkUpdate` from
NodeUpdatesSheet so the "Update all (N)" affordance is purely data-
driven on `updatableRemoteCount > 0`.

Docs realigned to drop fence-spec and Skipper-only phrasing on the
Update all bulk action:
- features/licensing.mdx: Community line now lists Remote OTA (per-node
  and Update all); Skipper Fleet Actions parenthetical drops "bulk
  update all"
- features/remote-updates.mdx: Note rewritten to role-only requirement
- features/fleet-view.mdx: Update all (n) bullet drops the tier clause
- features/overview.mdx: Fleet View and Remote updates blurbs drop the
  Skipper/Admiral fences
- operations/upgrade.mdx: Note rephrased without naming tiers

Test coverage:
- fleet.test.ts: tier-gating spec flipped to assert Community access
- fleet-pilot-update.test.ts: bulk-OTA dispatch suite now spies tier
  to Community so it doubles as a regression guard
2026-05-21 23:54:45 -04:00
Anso 0117556bea fix(fleet): rename Traffic tab label to Routing for consistency (#1119)
The Fleet view sub-tab that renders RoutingTab.tsx was labeled "Traffic"
while every adjacent identifier already used "Routing": the backend
route file (backend/src/routes/mesh.ts), the component path, the
localStorage key (sencho-routing-view-mode), the SegmentedControl aria
label ("Routing view mode"), and the engineering vocabulary across the
codebase. Operators looking for the "Routing tab" could not find it
because the visible label said something else.

Align the user-visible label with the rest of the implementation and
update the two doc references that named the tab by its old label
(docs/features/fleet-actions.mdx, .env.example).
2026-05-19 23:25:30 -04:00
Anso 16774ae515 feat(fleet): add node management actions to Fleet Overview (#1064)
* refactor(nodes): extract node create/edit/delete modals into useNodeActions hook

Pulls the inline Add/Edit/Delete/Pilot-enrollment modal stack out of
NodeManager.tsx and into a reusable useNodeActions() hook in
components/nodes/. Settings continues to consume the same modals via
this hook, with an onTestResult callback used by Settings to render the
existing connection-detail panel after a successful test.

The hook also extends the auto-test-on-save behavior so that saving a
proxy-mode remote node from the Edit dialog re-runs the connection test
when the API URL or token has actually changed (skipped when only name
or compose dir was edited).

* feat(fleet): surface Add/Edit/Delete node actions on Fleet Overview

Adds an admin-only Add node button to the right of Refresh on the Fleet
header, opening the same Add Node dialog used by Settings. Each node
card's three-dot menu now exposes Edit node and Delete node items
(routed through the shared useNodeActions hook) alongside the existing
Cordon item, so operators can manage node lifecycle without leaving the
Fleet page.

The card kebab is shown to admins regardless of tier; Cordon stays
Admiral-only. Delete is hidden on the local default node. After any
Add/Edit/Delete the Fleet overview refetches so the grid reflects the
change immediately.

* docs(fleet): document Add/Edit/Delete node actions on Fleet Overview

Updates the Action buttons table to cover the new Add node entry point
on the Fleet header, and adds a new Node actions menu section
describing the per-card Edit/Delete/Cordon items, their tier and
permission gating, and the auto connection test that fires after
saving a proxy-mode remote.
2026-05-15 19:54:23 -04:00
Anso 2e82eb44fe feat(fleet): multi-mode topology with label grouping and persisted positions (#1054)
The Topology view now offers three layouts so the canvas adapts to how the
fleet is organised, not the other way around:

- Hub: the gateway anchored on the left with remotes radiating right
- Grouped (Skipper+): remotes cluster by their primary node label, with the
  local node in its own cluster and unlabeled remotes in an Unlabeled cluster
- Free (Skipper+): drag any node; positions persist per browser via local
  storage

Node cards gain label pills (Skipper+), a cordon banner with reason tooltip,
a latency chip for online remotes, and a stale pilot-heartbeat glyph. All
fields are sourced from /api/fleet/overview, which already returns cordon,
latency, and pilot timestamps; no backend changes required.

Community continues to see the single Hub layout (without the toolbar, no
gating cues), matching the visibility principle that paid affordances are
hidden from lower tiers rather than displayed as locked teasers. The
backend /api/node-labels route is already requirePaid, so the data gate is
honoured end to end.

Includes unit tests for the layout module (hub/grouped/free coverage) and
the preferences hook (round-trip plus corrupt-JSON fallback).
2026-05-15 08:59:52 -04:00
Anso 0f0b22c51a feat(fleet): Fleet Secrets tab with env-var bundles (v1 MVP) (#965)
* feat(fleet): add Fleet Secrets tab with versioned env-var bundles (Skipper+)

Centralized, encrypted-at-rest secret bundles that can be pushed to labeled
nodes' stacks. Each save bumps a monotonic version; each push records a
per-node-per-version row in `secret_pushes` plus an entry in `audit_log`.
Conflict detection shows added/changed/unchanged/removed (informational)
diffs before write. Overlay merge preserves keys missing from the bundle.

- Adds `secrets`, `secret_versions`, `secret_pushes` tables.
- New `SecretsService` reuses CryptoService for AES-256-GCM, NodeLabelService
  for selectors, and direct fetch + Bearer for outbound calls to remote nodes.
- New `secretsRouter` with 9 endpoints under `/api/secrets`, gated by
  `requirePaid`. Mounted after the auth gate.
- Audit summary patterns added for the new routes.
- New Fleet › Secrets tab with bundle list, editor sheet (key=value rows,
  versions tab), and push wizard (selector, target stack, env file picker,
  per-node diff preview, results pills).
- Documentation: docs/features/fleet-secrets.mdx + docs.json nav entry.
- 26 Vitest cases cover parser, encryption, versioning, push aggregation,
  tier gating.

* fix(fleet): use const for rawValue in env parser

ESLint prefer-const flagged the let declaration as a CI-blocking error;
the variable is never reassigned.
2026-05-07 06:03:39 -04:00
Anso 52b46753af feat(fleet): add Federation tab with cordon and pin policy (Admiral) (#964)
Ships the v1 MVP for the Federation tab as placement control, not
placement automation:

- Cordon a node: marks the node unschedulable so the BlueprintReconciler
  skips it for new placements only. Existing deployments continue to
  drift-check and redeploy on revision changes; cordon never triggers
  withdraw or eviction. Toggle on the NodeCard kebab (Admiral, admin
  role); Cordoned pill renders for all tiers.
- Pin a blueprint to a node: stores blueprints.pinned_node_id, replacing
  the desired set with the pinned node regardless of selector. Pin
  overrides cordon by design. Action lives only in the Federation tab;
  BlueprintDetail and the deployment table show read-only Pinned
  indicators.

Backend: idempotent migrations add nodes.cordoned/cordoned_at/cordoned_reason
and blueprints.pinned_node_id. New routes POST /api/nodes/:id/cordon,
POST /api/nodes/:id/uncordon, PUT /api/blueprints/:id/pin, all gated by
requireAdmiral plus requireAdmin. Audit summaries added so the existing
auditLog middleware records every operator action. deleteNode clears
dangling pins.

Reconciler: pin override evaluated before selector match; cordon filter
applied only to the new-placement branch (deploy/stateReview without an
existing deployment). 11 new Vitest cases cover cordon filter, pin
override, pin-overrides-cordon, missing pin target, pin shrinks
desired set (stateless withdraw + stateful evict_blocked), and pin
clearing on node delete.

Frontend: new FederationTab.tsx with cordoned-nodes summary and
pin-policy table. Federation moved out of the experimental flag into
{isAdmiral && (...)} + AdmiralGate, mirroring the Routing tab pattern.
Secrets stays under experimental.

Tests pass: backend tsc, full Vitest suite (1704 passed), frontend
tsc -b, ESLint (0 errors). Manual verification via the local dev
instance confirmed the tab is hidden at Community, the kebab and pill
render at Admiral, and cordon and pin endpoints round-trip end to end.

Refs cut-line-1.0.md Federation v1 MVP.
2026-05-07 05:55:00 -04:00
Anso 77d5ff58d3 feat(fleet): add Fleet Actions tab for cross-node bulk operations (#963)
* feat(fleet): add Fleet Actions tab for cross-node bulk operations

Introduces a new "Actions" sub-tab in Fleet view with two Skipper+ cards
that fill gaps in the existing surface:

- Stop fleet by label: matches a label name across every node and stops
  every stack assigned to it, reporting per-node and per-stack results.
- Bulk label assign: applies the same label set to many stacks on one
  node in a single round trip.

Other bulk operations stay in their existing homes (sidebar bulk mode,
Schedules, NodeUpdatesSheet) to avoid duplicate surfaces.

Backend:
- POST /api/fleet/labels/fleet-stop (gateway-orchestrated, multi-node)
- POST /api/fleet-actions/labels/bulk-assign (per-node, capped at 1000)
- Tightens /api/fleet proxy-exempt prefix to /api/fleet/ so
  /api/fleet-actions/* is routed through the proxy for per-node calls.
- Exports activeBulkActions from labels.ts so fleet-stop and label-action
  share the per-node lock and cannot double-stop the same containers.
- Extracts containerActionForStack helper from stacks.ts for reuse.

* chore(fleet): rename Actions tab to Fleet Actions and reorder Fleet sub-tabs

- Tab label "Actions" -> "Fleet Actions" so the surface is unambiguous
  alongside Schedules and the sidebar bulk bar.
- Reorder Fleet sub-tabs as Overview / Snapshots / Status | Deployments /
  Traffic / Fleet Actions, with the separator after Status.
- Rename "Traffic · Routing" -> "Traffic" and update Sencho Mesh docs to
  match the shorter label.
- Update Fleet Actions docs to the new tab name and placement.
2026-05-07 05:41:53 -04:00
Anso e2edc6ceb8 chore(frontend): expose Routing and Deployments tabs by default (#955)
Drop the SENCHO_EXPERIMENTAL gate from the Fleet Routing and
Deployments tabs so they ship in the default UI. Both have been
verified production-ready and promoted out of experimental.

Routing trigger and content are now wrapped only by isAdmiral plus
the existing AdmiralGate. Deployments trigger and content are wrapped
by isPaid (Skipper+); Community users no longer see the tab at all,
mirroring the Routing pattern. Federation and Secrets remain inside
the experimental block as dev-only previews.

Removes the SoonBadge component and "Coming soon" pill from the
preview placeholder so the tab bar shows only ready, tier-appropriate
tabs without ambiguous SOON labels.
2026-05-06 21:18:31 -04:00
Anso ecf4dd5d52 feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh

Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)

The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.

Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
2026-05-05 12:54:26 -04:00
Anso 13f0c68723 refactor(frontend): extract useFleetOverview from FleetView (F5-8) (#922) 2026-05-04 22:34:52 -04:00
Anso 71678081f0 refactor(frontend): extract useFleetUpdateStatus + useFleetPolling from FleetView (F5-7) (#921)
Move the update workflow state machine and the polling driver out of the
FleetView shell into two dedicated hooks under FleetView/hooks/. No
behavior change.

useFleetUpdateStatus owns updateStatuses + updatingNodeId + the four
modal/dialog/reconnecting state slots, the synchronously-held
updateStatusesRef, and every callback that touches the update workflow
(fetchUpdateStatus, triggerNodeUpdate, confirmLocalUpdate,
triggerUpdateAll, dismissNodeUpdate, retryNodeUpdate). The inline four-
line "Check Updates" handler collapses into a single checkUpdates()
callback returned from the hook.

useFleetPolling is a pure side-effect hook that owns the initial-mount
fetch, the paid-tier 30s overview + 120s update-status interval pair,
and the 5s fast-poll accelerator gated on hasUpdatingRef. The polling
hook does not know about update semantics; the consumer passes in
updateStatuses and the fetch callbacks.

Shell drops from 523 to 385 LOC. useState calls drop from 13 to 6,
useEffect from 4 to 0, useCallback from 8 to 2, useRef from 2 to 0.

Tested manually in browser: fleet view loads, masthead populates, Check
Updates opens the sheet and fetches statuses, Refresh fires
fetch-overview, no console errors from the refactor (existing
unreachable-remote-node WebSocket failures are unrelated). Dev servers
killed after validation.
2026-05-04 21:31:15 -04:00
Anso f74322021b refactor(frontend): extract useFleetPreferences + useFleetLabels from FleetView (F5-6) (#920)
* refactor(frontend): extract useFleetPreferences + useFleetLabels from FleetView (F5-6)

Move localStorage preferences and label palette/assignment fetching out of the
FleetView shell into dedicated hooks under FleetView/hooks/:

- useFleetPreferences: wraps PREFS_KEY, loadPreferences, savePreferences, and
  the prefs useState + updatePrefs callback. Defaults are merged on load so stale
  stored values cannot produce missing keys. Save is a side-effect-free useEffect
  rather than a setState updater call, consistent with React purity contract.
- useFleetLabels: wraps fleetPalette, fleetStackLabelMap, labelFilters state,
  fetchLabelsForNodes callback, and the onlineNodeKey-gated fetch effect. The
  onlineNodeKey derivation is memoized. labelPaletteKey is exported for the
  shell processedNodes useMemo until F5-8 absorbs it.

Shell useState: 17 to 13. useEffect: 5 to 4. useCallback: 10 to 8.

* fix(frontend): add comments to empty catch blocks in useFleetPreferences

Empty catch blocks trigger the no-empty lint rule. Add explanatory comments
to both catch sites to satisfy the rule while keeping the intent clear.
2026-05-04 19:28:04 -04:00
Anso 4c171b0643 refactor(frontend): extract NodeCard and OverviewTab from FleetView (F5-3+F5-5) (#919)
* refactor(frontend): extract NodeCard and OverviewTab from FleetView (F5-3+F5-5)

Folds F5-3 (NodeCard) and F5-5 (OverviewTab) into a single PR since
NodeCard was never previously extracted.

- Move FleetNodeStats, FleetNodeSystemStats, FleetNode into FleetView/types.ts
- Extract NodeCard (~200 LOC) including UsageBar, ContainerRow, StackSection
  sub-components and getNodeCpu/getNodeMem/getNodeDisk/isCritical helpers
- Extract OverviewTab (~115 LOC); delegates to NodeCard, OverviewToolbar,
  FleetTopology; receives all state as flat props from FleetView
- FleetView.tsx drops from ~1,107 to ~480 LOC (overview inline body gone)
- No logic moved; all state, hooks, and computed values remain in FleetView.tsx
- formatBytes consolidated to @/lib/utils; node helpers exported from NodeCard
- allNodes wrapped in useMemo to prevent unnecessary child re-renders

* fix(frontend): move node utility functions to nodeUtils.ts to fix react-refresh lint error

getNodeCpu, getNodeMem, getNodeDisk, and isCritical were exported from NodeCard.tsx
alongside a React component, violating the react-refresh/only-export-components rule.
Moving them to a dedicated nodeUtils.ts resolves the ESLint error without changing any logic.
2026-05-04 18:15:57 -04:00
Anso ac216d7990 refactor(frontend): extract OverviewToolbar from FleetView (F5-4) (#918)
- Move search, sort, filter popover, label filters, and view-mode toggle
  into FleetView/OverviewToolbar.tsx (~175 LOC removed from shell)
- Extend FleetView/types.ts with ViewMode, SortField, SortDir,
  FilterStatus, FilterType, FleetPreferences, FleetPaletteEntry
- Replace hand-rolled view-mode pill buttons with SegmentedControl
  (proper aria-checked semantics, keyboard navigation)
- Hoist SORT_OPTIONS and renderPaletteOption to module-level constants
- Stabilise palette options with useMemo inside OverviewToolbar
- FleetView.tsx shrinks from ~1,280 to ~1,107 LOC
2026-05-04 17:50:05 -04:00
Anso 8277bda0fa refactor(frontend): convert Node Updates dialog from modal to sheet (#917)
Swaps Dialog for Sheet in NodeUpdatesSheet (renamed from
NodeUpdatesModal). Sheet provides full viewport height, removing the
max-h-[85vh] cap on the container and the max-h-[40vh] cap on the node
list scroll area. Width fixed at 700px.

Also fixes two stat-counter bugs carried over from the original inline
code: completed nodes now count toward the Up to date tile, and the
gateway latest-version label now resolves via the local node entry
rather than relying on array position.

No prop or behavior changes.
2026-05-04 17:03:36 -04:00
Anso ef2f3969e3 refactor(frontend): extract NodeUpdatesModal and LocalUpdateConfirmDialog from FleetView (F5-2) (#916)
Moves the inline Node Updates dialog (~192 LOC), Local Update confirmation
dialog (~19 LOC), UpdateStatusBadge sub-component (~58 LOC), and shared
NodeUpdateStatus type into dedicated files under FleetView/. Shell drops
from 1,556 to 1,280 LOC.

Modal-local state (modalSearch, recheckingUpdates) and the
updatableRemoteCount derived value move into NodeUpdatesModal.
2026-05-04 15:21:39 -04:00
Anso 67d56f8b54 refactor(frontend): extract ReconnectingOverlay from FleetView (F5-1) (#913)
Move the inline ReconnectingOverlay sub-component out of FleetView.tsx
into its own file under FleetView/. Pure file relocation; logic and
rendered output are unchanged.

FleetView.tsx: 1,630 -> 1,556 LOC. First step in the FleetView
decomposition tracker.
2026-05-04 14:20:09 -04:00
Anso 1f8ce773ff feat(ui): hide paid features from community-tier dashboard (#891)
* feat(ui): hide paid features from community-tier dashboard

Community installs render only the features they can use. Tier-locked
sections, lock badges, upsell cards, and "Upgrade" buttons no longer
appear anywhere except the License page in Settings, which is the
single discoverable upgrade path.

Concretely:

- PaidGate and AdmiralGate now render null for non-qualifying tiers
  instead of upsell cards.
- SectionGate (settings) hides tier-locked sections entirely.
- Settings sidebar and command palette filter out items the operator
  cannot reach.
- Configuration Status widget on the dashboard drops the Automation
  section for community and hides any locked rows in remaining
  sections.
- Fleet > Status node cards drop locked summary rows.
- Stack action menu, sidebar bulk bar, file upload / download, scan
  comparison, network topology toggle, node label picker all hide
  for community instead of showing disabled affordances or "Upgrade"
  literal text.
- Removes tierUpsell, TierLockChip, and useDismissalState (no longer
  referenced).

Backend tier guards remain authoritative; this changes UI discovery
only.

* test(e2e): assert upload control is absent in community tier

The community-clean-ui change removes the "Upgrade to unlock upload"
pill from the file explorer. Update the matching e2e assertion to
verify the upload control is not rendered, instead of waiting for a
pill that no longer exists.
2026-05-03 01:17:02 -04:00
Anso d69fb9f1da feat(meta): gate deferred Fleet tabs behind SENCHO_EXPERIMENTAL flag (#886)
Hide the Traffic / Routing, Deployments, Federation and Secrets Fleet
tabs by default. They re-appear when the operator opts in by setting
SENCHO_EXPERIMENTAL=true. Backend routes and database tables are
unchanged; this is a UI discovery gate only.

The /api/meta endpoint now returns experimental as a boolean. A new
useExperimental hook reads it once per page load and feeds the four
tab triggers and tab content panels in FleetView.
2026-05-02 18:01:06 -04:00
Anso e5391e66cb feat(blueprints): add Fleet > Deployments tab UI, node labels, and docs (#861)
Implements the frontend layer for the Blueprint Model feature (backend
landed in PR #860). Fleet > Deployments tab is now live for Skipper+
users; Community users see the existing locked badge.

Key additions:
- blueprintsApi.ts: typed apiFetch wrappers (localOnly: true on all calls)
- BlueprintCatalog: featured hero, filter pills, classification-chipped tile grid
- BlueprintEditor: Monaco YAML editor with debounced live classification,
  label/node selector, three-mode drift radio cards, create/edit modes
- BlueprintDeploymentTable: per-node status rows with action buttons
  (Confirm deploy, Retry, Withdraw/Evict, DATA PINNED HERE for stateful)
- EvictionDialog: dual-affordance (Snapshot then evict / Evict and destroy)
- StateReviewDialog: fresh-deploy acceptance gate for stateful blueprints
- BlueprintClassificationBanner: real-time stateless/stateful/unknown banner
- DeploymentsTab: wires catalog, empty state, and create dialog
- BlueprintDetail: Sheet with Apply/Edit/overflow, themed delete dialog
- NodeLabelPicker + NodeLabelPill: label CRUD per node in NodeManager
- FleetView: gates Deployments tab behind isPaid; mounts DeploymentsTab
- NodeManager: Labels column with NodeLabelPicker (Skipper+ users)
- docs/features/blueprint-model.mdx + docs.json entry + 6 screenshots
2026-05-01 19:47:05 -04:00
Anso f62716f557 refactor(design): align typography, colors, and card surfaces to DESIGN.md (#859)
* refactor(design): align surface tokens to DESIGN.md §2

* refactor(design): canonicalize tracked-mono kickers and display rungs

* refactor(design): collapse to five-slot palette and align card surfaces
2026-05-01 03:01:00 -04:00
Anso 7663f4cd8b feat(fleet): sencho mesh in traffic and routing tab (#858)
* feat(fleet): sencho mesh in traffic and routing tab

Lights up Sencho Mesh: cross-node container forwarding rendered as if the
container next to you were on localhost. Builds on the dormant TCP frame
plumbing from the prior PR (pilot tunnel TCP frames + sencho-mesh sidecar
package) and exposes the Admiral-only orchestrator surface.

Backend
- New mesh_stacks table (per-node opt-ins) + nodes.mesh_enabled column
  via DatabaseService.migrateMeshTables.
- MeshService singleton: sidecar lifecycle via Dockerode, opt-in/out with
  cascading override regeneration, request-based resolver from sidecar
  control WS, cross-node TCP forwarding via PilotTunnelManager (same-node
  fast path included), in-memory 1000-event activity ring buffer with
  durable mirror to audit_log for state-change events, per-node and
  per-route diagnostics, and the Test upstream probe.
- MeshComposeOverride: pure YAML generator that injects extra_hosts using
  host-gateway. The user's docker-compose.yml is never mutated; overrides
  live under DATA_DIR/mesh/overrides.
- ComposeService deploy/update splice the override file when the stack
  is opted in; non-mesh stacks behave identically to today.
- Pilot agent resolveMeshTarget consults the local mesh_stacks table
  (defense in depth) and resolves Compose containers via Dockerode.
- /api/mesh router with 13 Admiral-gated endpoints covering status,
  enable/disable, stack opt-in/out, alias listing, per-route diagnostic,
  Test upstream probe, per-node diagnostic, sidecar restart, activity
  log paginated and SSE.
- meshControl WS slot at /api/mesh/control validates the mesh_sidecar
  JWT minted by MeshService; dispatched as upgrade slot 2 (canonical
  order preserved).

Frontend
- New Traffic Routing tab in FleetView, gated by isAdmiral and wrapped
  in AdmiralGate. Tab uses the cyan brand glyph and italic-serif state
  typography from the audit.
- RoutingTab masthead with mesh activity drawer, per-node card grid
  with TogglePill, alias rows with five-state pill taxonomy
  (healthy / degraded / unreachable / tunnel-down / not-authorized),
  inline Test buttons.
- Four sheets: opt-in picker with port-collision inline error,
  per-route detail with diagnostic + filtered activity, per-node
  diagnostics with active streams + resolver cache + restart action,
  fleet-wide activity log with filters.
- meshRouteState helper centralizes pill-state mapping; pure-function
  tests cover all five states.

Docs
- User docs at /docs/features/sencho-mesh.mdx covering opt-in,
  troubleshooting, security model (4 guarantees + 4 explicit
  non-guarantees), and V1 limitations.
- Internal architecture and runbook pages.
- websocket-dispatch internal doc updated with the new slot.

* fix(mesh): validate stack name before path use; fix test DB lifecycle

Two surgical fixes against the prior PR.

Path-injection (CodeQL js/path-injection): MeshService.optInStack,
optOutStack, ensureStackOverride, and removeStackOverride now validate
stackName via isValidStackName from utils/validation, reject malicious
names at the API boundary, and additionally check isPathWithinBase on
the resolved override file path for defense in depth. The dataflow from
req.params.stackName to fs.writeFile no longer reaches an unsanitized
path expression.

Test DB lifecycle: mesh-service.test.ts used per-test setupTestDb /
cleanupTestDb, which deletes the temp dir while DatabaseService still
holds an open SQLite handle. On Linux CI this raises
SQLITE_READONLY_DBMOVED on the next prepare() because the inode has
been unlinked. Switched to file-scoped beforeAll/afterAll matching
agents-routes.test.ts, with a per-test beforeEach that truncates
mesh_stacks plus non-default nodes and resets the MeshService singleton
in-memory state. Adds a new test case asserting the path-traversal
rejection.

* fix(compose): use discovered compose filename instead of hardcoded docker-compose.yml

composeArgs() hardcoded `-f docker-compose.yml` for every deploy. Sencho
writes its canonical compose file as `compose.yaml`, so any stack created
via the UI failed to deploy with `open ...docker-compose.yml: no such
file or directory`.

When no mesh override applies, drop the explicit `-f` so docker compose's
built-in discovery resolves the actual filename. When an override exists,
look up the real base filename via FileSystemService.getComposeFilename()
and pass both files explicitly.

Also hoist the MeshService import to module top now that the dependency
is known to be acyclic, and revert the matching unit-test assertion.
2026-05-01 01:50:53 -04:00
Anso b8437e8780 feat(fleet): §16 orchestrator tab foundation (Deployments, Federation, Secrets) (#856)
Reserves three navigable but non-functional tab slots on the Fleet page so
each future orchestrator surface can land as a tab content swap rather than
a navigation redesign. Each tab opens a coming-soon placeholder card listing
the planned actions for that surface.
2026-05-01 00:10:56 -04:00
Anso d7d8f9bfe8 feat(dashboard): replace 24h charts with Configuration Status and Recent Activity (#785)
* feat(dashboard): replace 24h charts with Configuration Status and Recent Activity

The 24-hour CPU/Memory area charts summed per-container metrics normalized
to each container's CPU quota, producing numbers that bore no honest
relationship to host load. The live ResourceGauges strip already shows
accurate host-level stats, making the historical charts both inaccurate
and redundant.

This commit replaces that row with two side-by-side cards:

- **Configuration Status**: aggregates every toggleable feature on the
  active node (notification agents, alert rules, routing rules, auto-heal,
  auto-update, webhooks, scheduled tasks, MFA, SSO, vulnerability scanning,
  cloud backup, and alert thresholds) into a single at-a-glance card.
  Tier-locked rows display an upgrade indicator instead of a value.
  Each row is clickable and navigates to the relevant settings section.
  Data refreshes every 60 s and immediately on state-invalidate events.

- **Recent Activity**: lists the ten most recent notification-history events
  for the active node (deployments, image updates, auto-heal actions, scan
  findings, cloud backup events, system notices) with category icons and
  relative timestamps. Refreshes every 30 s.

New backend endpoints:
- GET /api/dashboard/configuration - per-node feature status with locked/
  requiredTier markers so the frontend renders upgrade chips without extra
  calls. The endpoint sits after authGate and before the remote proxy so
  remote-node requests are transparently forwarded.
- GET /api/dashboard/recent-activity?limit=N - thin wrapper over
  DatabaseService.getNotificationHistory.
- GET /api/fleet/configuration - fleet-wide fan-out using the same
  Promise.allSettled dead-node-tolerant pattern as /fleet/overview.
  Exposed as the new "Status" tab on the Fleet page (after Snapshots).

Shared utilities:
- visibilityInterval and formatCount extracted to frontend/src/lib/utils.ts
  so the three polling hooks and two components share a single copy.

* docs(dashboard): fix stale alt text referencing removed historical charts
2026-04-26 18:42:28 -04:00
Anso d95e154aeb feat(fleet): interactive topology with ReactFlow hub-and-spoke layout (#713)
Replace the static SVG fleet topology with a ReactFlow canvas laid out
via dagre. Each node renders as a rack card with status pill, type
badge, CPU/MEM/DISK bars, and stack/running counts. Pan, zoom, drag,
and minimap are enabled; user-dragged positions persist across the
30-second poll so live metric updates no longer reset layout.
2026-04-20 16:16:02 -04:00
Anso 6f132b7ffe feat(fleet): reorganize overview page for clarity and density (#712)
* feat(fleet): reorganize overview page for clarity and density

Scope Grid/Topology to the Overview tab (moved from above the tab bar so it
no longer implies it applies to Snapshots). Move Check Updates and Refresh
onto the tab bar row, right-aligned.

Compact the overview toolbar: constrain the sort combobox to a fixed width so
it no longer stretches full-page, and collapse the Status, Type, Severity,
and Tags pill groups into a single Filters popover with an active-count
badge and an inline Clear all filters action.

Render the local node card in the same responsive grid as remote nodes
instead of a dedicated full-width row. Visual distinction is preserved
through the existing brand gradient, cyan rail, ring, and "Local" badge.

* fix(fleet): stop local card from stretching when remote expands

Merging the local and remote node cards into a single responsive grid
meant CSS grid's default align-items: stretch made every cell in a row
match the tallest one. Expanding stack details on a remote card pulled
the local card up with it.

Add items-start on the merged grid so each cell sizes to its own
content.
2026-04-20 14:53:12 -04:00
Anso a41af47ff3 feat(fleet): aggregate labels across nodes and allow remote edits (#710)
Labels settings are now visible on remote nodes (scope: node, no longer
hidden on remote context). The LabelsSection already routes through the
active-node proxy, so edits land on whichever node the operator is viewing.

Fleet overview's label filter previously fetched only the control-plane
node's labels and assignments, so remote stacks could never match the
filter. Rewrote aggregation to fan out /labels and /labels/assignments
to every online node via fetchForNode + Promise.allSettled with a 5s
timeout per request. The palette dedupes by (name, color) so identical
labels on multiple nodes collapse into one entry while same-name +
different-color stay distinct. The assignment map is nested by nodeId
to avoid cross-node stack-name collisions.

Keyed the label refetch effect on a stable online-node id signature
rather than the nodes array reference, so the existing 30s overview
poll (and 5s fast-poll during updates) does not cascade into repeated
fleet-wide label fetches.
2026-04-20 12:56:00 -04:00
Anso c3b06f4b13 feat(fleet): add aggregate masthead and local-vs-remote topology (#677)
Introduce a status masthead above the node grid summarising fleet-wide
CPU, memory, container, and alert counts with a coloured rail that
reflects overall health. Pin the local node at the top of the grid with
a cyan accent rail and a Local badge so it is never confused with a
remote. Add a Topology view toggle that plots the local node on the
left with remotes radiating out, and colour-codes connector lines by
link health.
2026-04-18 02:06:56 -04:00
Anso a695251f38 fix(labels): harden stack labels with nodeId filtering, concurrency guard, and test coverage (#552)
* fix(labels): add nodeId filter, existence check, stale cleanup, concurrency guard, and body validation

- Fix getStacksForLabel to filter by node_id (prevents cross-node data leak)
- Add getLabel(id, nodeId) for single-label existence check
- Add getLabelCount(nodeId) for enforcing per-node label limit (50)
- Add cleanupStaleAssignments to remove orphaned assignments for deleted stacks
- Add label/assignment cleanup to deleteNode transaction
- Add label existence check on bulk action endpoint (returns 404 for missing labels)
- Add concurrency guard on bulk actions (returns 429 if already in-flight)
- Add requireBody guard on all mutation endpoints
- Extract isSqliteUniqueViolation helper to deduplicate constraint checks
- Add MAX_LABELS_PER_NODE constant (50) with limit enforcement on create
- Add diagnostic logging on all label endpoints (gated behind developer_mode)
- Add operational log line for bulk action results

* fix(labels): use ScrollArea, show failure details, add loading feedback, deduplicate constants

- Replace overflow-y-auto div with ScrollArea in LabelAssignPopover (design system)
- Show failed stack names in bulk action error toast
- Add loading toast for context menu label toggle
- Disable bulk action menu items while a bulk action is running
- Disable "New Label" button at 50-label limit with "Limit reached" text
- Export LABEL_COLORS and MAX_LABELS_PER_NODE from LabelPill (single source of truth)
- Import shared constants in LabelAssignPopover and LabelsSection (remove duplicates)
- Add BulkActionResult interface to replace inline type assertion

* test(labels): add comprehensive coverage for label CRUD, assignments, and bulk edge cases

42 tests covering:
- getLabels: empty, ordered, node isolation
- getLabel: found, wrong node, nonexistent
- createLabel: returns with ID, duplicate name constraint
- getLabelCount: correct count, zero for empty node
- updateLabel: name, color, both, not found, wrong node
- deleteLabel: removes label, cascades assignments, wrong node no-op
- setStackLabels: assign, replace, clear, invalid ID throws
- getLabelsForStacks: correct mapping, empty result
- getStacksForLabel: correct results, node filter, empty for nonexistent
- cleanupStaleAssignments: removes stale, preserves valid, handles empty
- deleteNode: cascades labels and assignments
- Edge cases: atomicity, cascade across stacks, multi-label assignment

* docs(labels): document 50-label limit and bulk action failure details

* fix(labels): add missing LabelColor type imports and explicit parameter types

* refactor(labels): extract label types and constants to label-types.ts

Moves LabelColor, Label, LABEL_COLORS, and MAX_LABELS_PER_NODE out of
LabelPill.tsx into a dedicated non-component file. This fixes the
react-refresh/only-export-components lint error caused by mixing
constant exports with component exports.
2026-04-13 12:49:03 -04:00
Anso d23c6779af fix(fleet): harden remote node updates with admin enforcement, expiry fix, and diagnostics (#542)
- Fix completed-entry auto-expiry using resolvedAt instead of startedAt
- Add admin role enforcement to update trigger and update-all endpoints
- Add missing error field in rejected-promise fallback for update-status
- Fix rejected promises in update-all losing node names
- Harden frontend recheck button with try/catch/finally error handling
- Align frontend isValidVersion with stricter regex validation
- Add diagnostic logging gated behind developer_mode
- Extract resolveTracker helper to centralize terminal state transitions
- Add 15 new fleet test cases covering auth, tier gating, input validation, and admin roles
- Document admin requirement and troubleshooting in fleet-view docs
2026-04-12 22:07:41 -04:00
Anso 1702dabb7a fix(fleet): add auth middleware, input validation, and design system compliance (#536)
Add authMiddleware to all 13 fleet endpoints that were previously
accessible without authentication. Add NaN validation for parseInt
params, stackName validation on snapshot restore, and description
length cap on snapshot creation. Clean up updateTracker entries on
node deletion to prevent memory leaks.

Replace hardcoded colors with design system tokens, swap Select for
Combobox, replace overflow-y-auto with ScrollArea, fix card styling
(shadow-card-bevel, border tokens). Fix stale container data by
always refetching on stack expand with a loading guard against
concurrent requests.

Add operational logging for state-changing fleet operations and
diagnostic logging gated behind Developer Mode. Add 20 fleet tests
covering auth enforcement, input validation, tier gating, and
snapshot CRUD lifecycle.
2026-04-12 20:28:18 -04:00
Anso 3d69746eee fix(fleet): make local self-update flow reliable end-to-end (#472)
The "Updating Sencho..." overlay used to dismiss prematurely while the
image pull was still running, after which the local node card would get
stuck in "updating" and eventually surface a generic "Timed Out" error
while the container remained on the old version.

Three root causes are addressed:

1. The image pull was synchronous (`execFileSync`), which blocked the
   Node event loop. The overlay's health probe saw the server come back
   the moment the pull finished and reloaded the page, even though the
   container had not restarted yet. The pull is now async via
   `promisify(execFile)`, so /api/health and /api/fleet/update-status
   keep serving throughout.

2. The overlay reloaded on the first 200 from /api/health regardless of
   whether the underlying process had actually restarted. /api/health
   now exposes the gateway boot timestamp, and the overlay captures it
   pre-update and only reloads when it observes a different value. A
   wasOffline-then-online fallback handles the case where the pre-update
   fetch failed.

3. Helper container spawn errors from `docker run` were silently
   discarded, so a failed compose recreate never surfaced anywhere.
   Errors are now captured into `lastUpdateError` via the execFile
   callback and surfaced through the existing /api/fleet/update-status
   error path.

A 3-minute early-fail heuristic on the local node block surfaces a clear
failure message when the helper fails silently, instead of waiting the
full 5-minute timeout for an unknown failure.
2026-04-10 12:00:44 -04:00
Anso 8de82ed81f fix(fleet): prevent modal flash when clicking Recheck button (#457)
Use a separate recheckingUpdates state for the Recheck button so the
modal content stays visible while refreshing. The full-screen loading
spinner (checkingUpdates) is now only used for the initial "Check Updates"
open from the header.
2026-04-09 13:10:50 -04:00
Anso 368bef20d3 fix(fleet): detect updates via GitHub Releases instead of gateway self-comparison (#454)
* fix(fleet): detect updates via GitHub Releases instead of gateway self-comparison

The fleet update check compared each node's version against the gateway's
own version, so the local node could never appear outdated. Now fetches
the actual latest release from GitHub Releases API with a 30-minute
in-memory cache and thundering-herd protection. The Recheck button
invalidates this cache via ?recheck=true to force a fresh lookup.

* docs(fleet): update docs to reflect GitHub Releases version detection

Replace "Gateway version" references with "Latest version" to match the
new label. Document that version comparison uses the latest GitHub release
rather than the gateway's own version, and that Recheck refreshes the
cached latest version.
2026-04-09 12:21:37 -04:00
Anso cc2da99d6f fix(fleet): resolve stuck update states and improve detection (#405)
* fix(fleet): resolve stuck update states and improve update UX

The fleet node update flow had several bugs: the in-memory update tracker
never cleared terminal states (timeout, failed, completed), leaving nodes
permanently stuck with no way to retry or dismiss. The Recheck button
only re-fetched stale state without clearing it, and the POST trigger
rejected retries with 409 even after timeout.

Backend fixes:
- Add DELETE endpoints (single node + batch) to clear tracker entries
- Fix 409 race: detect expired timeouts and clear terminal states before
  re-triggering
- Populate error messages in the tracker for timeouts and failures
- Include error field in the update-status API response
- Auto-expire completed entries after 60 seconds

Frontend fixes:
- Add retry (RotateCcw) and dismiss (X) buttons on failed/timed-out badges
- Show error details via animated cursor hover (CursorFollow pattern)
- Recheck button now batch-clears all terminal states before fetching
- Recheck shows loading spinner and disables while checking
- Extract NodeCardProps interface for readability

* fix(fleet): detect update completion via process start time

Remote nodes that cannot report their version (e.g. older builds)
caused updates to always time out because completion detection
relied solely on version comparison. The gateway now tracks the
remote node's process start time from /api/meta and detects
container restarts by comparing it across polls.

Also extracts a createTracker() factory to eliminate repeated
object construction across 5 call sites.

* docs: add troubleshooting for first-update timeout on old nodes

Adds a new troubleshooting entry explaining why the first remote
update on nodes running pre-v0.40.0 always times out (neither
version nor process start time can be detected). Documents the
fix: dismiss, recheck, and confirm the node updated.

Also adds a screenshot of the timed-out state with retry/dismiss
buttons to the remote updates feature page.

* fix(fleet): detect update completion via offline detection and error reporting

The update completion detection relied on version change and process
start time, both of which fail on nodes running older Sencho versions
that report "unknown" and lack the startedAt field. This caused every
update to time out after 5 minutes.

Add three-signal detection: version change, process restart (startedAt),
and offline/online detection (node went unreachable during update and
came back). Also add a 90-second early failure heuristic for when the
remote image pull fails silently, and surface pull errors from
SelfUpdateService via /api/meta so the gateway can report them
immediately.

* fix(deps): bump vite to 8.0.5 to resolve high severity vulnerabilities

Fixes GHSA-4w7w-66w2-5vf9, GHSA-v2wj-q39q-566r, GHSA-p9ff-h696-f583.

* fix(deps): bump vite in backend lockfile to resolve audit failures

Vitest pulls in vite as a transitive dependency. Bumps to 8.0.5.
2026-04-06 20:09:55 -04:00
Anso a55d1245f8 fix(fleet): resolve version detection pipeline for Docker builds (#402)
* fix(fleet): resolve version detection pipeline for Docker builds

The Dockerfile backend-builder stage was missing a COPY of the root
package.json, causing generate-version.js to fall back to "0.0.0-dev"
at build time. At runtime, the filesystem walk also failed (root
package.json not in the final image), producing the string "unknown"
which the frontend rendered as "vunknown".

Changes:
- Dockerfile: copy root package.json into backend-builder stage
- CapabilityRegistry: return null (not "unknown") for unresolvable
  versions; add isValidVersion() type guard; normalize remote meta
  responses to strip "unknown"/"0.0.0-dev" sentinel values
- Fleet endpoints: hoist gateway version validation outside per-node
  loops; treat unresolvable remote versions as "potentially outdated"
  instead of silently marking them up to date
- FleetView: guard all version display points (card badge, update
  button, gateway label, modal columns) via shared formatVersion()
- EditorLayout, CapabilityGate: use shared isValidVersion utility
- New frontend/src/lib/version.ts shared utility
- Docs: add troubleshooting section for version display edge cases
- Screenshots: updated Fleet Overview and Node Updates modal

* docs: update fleet node updates screenshot with live remote node
2026-04-06 03:03:56 -04:00
Anso 2089e75ef1 fix(fleet): filter invalid version strings from UI display (#399)
Treat 'unknown' and '0.0.0-dev' as absent versions in the node
switcher, fleet overview, and capability gate. Remote nodes running
older Docker images that return these fallback values now show a
clean label instead of "vunknown".
2026-04-06 01:43:52 -04:00