mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
c460bb87a87cb4a39db11c6cec2ca9f1ffbb956f
23 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c460bb87a8 |
fix(mesh): probe upstream synchronously in route diagnostic (F-11) (#1100)
GET /api/mesh/aliases/:alias/diagnostic returned a cached state derived from the last latency/error maps. Those maps only mutated when someone called POST .../test or when a cross-node connect logged an event, so once an upstream stopped the diagnostic kept reporting "healthy" until the 60 s alias-cache refresh pruned the alias entirely. The GET now calls testUpstream synchronously after the alias-resolved, opted-in, tunnel-up short-circuits. Probe failures land in routeErrorMap via logActivity (cross-node already did this; same-node timeout/error paths now log probe.fail in the same shape), so the state computation flips to "unreachable" on the same request that exposed the stopped upstream. A new routeProbeAtMap stamps freshness and surfaces in the response as lastProbeAt; the route detail sheet renders "Last probe <age> · <ms>" using the existing formatTimeAgo helper. |
||
|
|
554f662563 |
fix(mesh): surface stopped-stack opt-ins on routing node cards (#1098)
Add `currentlyResolvable: boolean` per entry in `MeshNodeStatus.optedInStacks`, derived from the existing alias cache so the new field stays consistent with `/api/mesh/aliases` without any extra Dockerode or cross-node inspect on the status path. The Routing tab renders an amber `suspended` pill for entries whose stack is opted in but currently has no running services, plus a single explanatory caption below the suspended list. Resolves the contradictory `Mesh stacks: 1 / Aliases: 0 / No mesh services on this node yet` copy on the node card when a meshed stack's container has been stopped; the misleading line is now only shown when the node truly has no opt-ins. The opt-in itself remains sticky: when the stack starts again, its aliases reappear automatically on the next refresh. Defensive de-dup in the UI filters suspended entries against the live alias snapshot to handle the transient gap where `/mesh/status` and `/mesh/aliases` return slightly inconsistent views from their separate fetches. Tests: - new `mesh-status-resolvability.test.ts` (6 cases) locks the resolvable / suspended / mixed / empty / per-node-scoping / stale-alias-no-phantom invariants for `getStatus`. - `mesh-topology-layout.test.ts` gains a `stacksKey` resolvability-flip case and a `meshNodeStateEqual` case asserting a resolvability flip on an otherwise identical stack registers as a state change so the topology layout re-runs. Operator docs gain one new troubleshooting accordion in `/docs/features/sencho-mesh.mdx` explaining the suspended state. |
||
|
|
f6e42535c8 |
fix(mesh): route peer→central traffic over the existing forward WS (#1094)
* fix(mesh): route peer→central traffic over the existing forward WS The reverse mesh callback path (`/api/mesh/proxy-tunnel-from-peer`) needed SENCHO_PRIMARY_URL on central plus a publicly reachable origin from the peer's perspective. In a typical homelab where central sits behind NAT, peer→central dispatch silently failed at the dialer's short-circuit and the headline "call any service on any node by hostname" worked one way only. The forward WS at `/api/mesh/proxy-tunnel` is already bidirectional end to end. Make the bridge a persistent control-plane primitive: dial every mesh-enabled proxy peer at startup, reconcile every 60 s, never idle-close. Peer→central traffic multiplexes over the same WS via `tcp_open_reverse`. Removed: - `meshProxyTunnelFromPeer.ts` WS handler and dispatch - `MeshCentralRegistry`, `PeerToCentralMeshSessionDialer` - `mesh_handshake` first-frame state machine in `meshProxyTunnel.ts` - `maybeSendBootstrap`, `buildHandshakeFrame` in the dialer - `mesh_proxy_callback_bootstrap` capability and `maybeWarnUnsetPrimaryUrl` - `mesh_centrals` table (drop migration; greenfield, no users) - `PilotTunnelManager.replaceOrRegisterProxyBridge` (dead after handler removal) - twelve associated unit/integration tests plus the peer-recovery branch in `MeshService.openCrossNode` Added: - `MeshService.proactiveBridgeFanout` selects every mesh-enabled proxy peer (no longer gated on `mesh_stacks` rows) - `startBridgeReconcileLoop` runs the fanout every 60 s (override via `SENCHO_MESH_RECONCILE_INTERVAL_MS`) - `MeshProxyTunnelDialer` default idle TTL is now `0` and exposes `isDialing(nodeId)` for the status surface - `MeshNodeStatus.reverseCallbackStatus` discriminator (`connected | connecting | unavailable | not_applicable`) surfaced via `/api/mesh/status` and rendered as a pill in the Routing tab - `openCrossNode` error message distinguishes "no proxy target" from "waiting for central to dial the reverse bridge" - New tests: `mesh-service-proxy-tunnel-reconcile`, `mesh-status-reverse-callback`, `mesh-proxy-tunnel-dialer-no-idle-close` SENCHO_PRIMARY_URL is no longer required for any mesh function. * fix(mesh): rewrite proxy-tunnel reconcile test contents The previous commit renamed the file but the rewritten test bodies stayed unstaged on top of the rename. This commit lands the actual rewrite: the fanout assertion now requires every mesh-enabled proxy peer to be dialed, not just those with `mesh_stacks` rows, and adds a reconcile-tick repeated-call test. |
||
|
|
578cac89da |
fix(mesh): surface data-plane failures in health, meta, and Routing tab (#1088)
The three previously-silent console.warn paths in MeshService.setupMeshNetwork now route through a typed recordSetupFailure helper that classifies the failure (subnet_invalid, subnet_overlap, subnet_mismatch, ip_in_use, attach_failed, not_in_docker), emits a mesh.disable activity entry at the matching level (error for real failures, warn for the expected dev-mode not_in_docker case), and strips mesh_proxy_callback_bootstrap from advertised capabilities via CapabilityRegistry. /api/health gains a mesh.dataPlane block carrying the typed status. /api/mesh/status carries localDataPlane at the top level so the Routing tab renders a red banner with an operator-actionable recovery hint (set SENCHO_MESH_SUBNET to a free /24 and recreate the container) when the data plane is down. The success path re-enables the capability and flips the status to ok. Generalizes the previously-documented F-0 failure mode (IP-in-subnet collision) to also cover the subnet-pool-overlap case where another Docker bridge on the host already owns the requested CIDR. |
||
|
|
3323a59003 |
fix(mesh): poll topology data and tighten stack-membership equality (#1059)
Adds a visibility-aware 30s poll on the Routing tab so the graph reflects tunnel state, alias publishes, and remote-node disconnects without a manual reload. Polling pauses while the tab is hidden and wakes immediately on focus via the shared visibilityInterval helper. Tightens nodeStateEqual so a stack swap on the same node (opt out A, opt in B) is detected even when the count is unchanged. Extracts the helper, the minimap colour mapping, and the minimap colour literals to mesh-topology-layout so they can be unit-tested and to drop a now-unused isOwnerView field from MeshNodeData. Persists the Table/Graph and Tunnels/Aliases toggles to localStorage so a Routing tab session keeps the operator's last-used view. Adds a legend line to the per-stack topology sheet clarifying that consumer edges mean meshed peers that could reach the aliases via DNS; whether containers dial them depends on each consumer's own opt-in stacks. Adds unit tests for stacksKey, meshNodeStateEqual, and miniMapColorFor, plus troubleshooting entries and a refresh-cadence note to the mesh docs. |
||
|
|
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). |
||
|
|
9e4b969dfb |
feat(mesh): add topology graph view and per-stack drill sheet (#1052)
Adds a Table/Graph toggle to Fleet → Traffic. Graph mode draws the fleet as a ReactFlow diagram with a second toggle that switches edge encoding between Tunnels (one edge per node pair, coloured by tunnel state) and Aliases (same edges labelled with alias counts). Clicking a node card opens the existing opt-in sheet, where each opted-in stack now has a Topology button that opens a focused side sheet showing the stack at the centre, the aliases it publishes, and the meshed consumer nodes with per-tunnel state. Reuses the existing /mesh/status and /mesh/aliases endpoints; no backend changes. Inherits the Admiral gate from the parent Routing tab. Layout helpers and the per-stack sheet are unit-tested. |
||
|
|
a38a3e0226 |
feat(mesh): route mesh traffic over Distributed API remotes (#1048)
* refactor(mesh): extract shared TCP stream switchboard
Pull the `tcp_open` / `tcp_open_ack` / `tcp_open_reverse` / `tcp_close`
+ `TcpData` handling out of the pilot agent into a reusable module so a
second caller (the upcoming proxy-mode WS handler) can run the same
frame parser, stream allocator, idle timers, and Compose-label
resolver. One parser, two callers, zero drift on a
security-sensitive protocol surface.
The pilot agent keeps its public `openMeshTcpStream` API and delegates
to a per-connection switchboard constructed in `connect()` and torn
down in `cleanupAfterDisconnect`. Existing reverse-stream and resolver
tests are rewritten to exercise the shared module directly.
* feat(mesh): route mesh traffic over Distributed API remotes
Sencho Mesh now works against remotes in Distributed API mode in
addition to Pilot Agent mode. Central opens a short-lived WebSocket
tunnel to the remote's new `/api/mesh/proxy-tunnel` endpoint on demand
and tears it down after 5 minutes of idle (configurable via
`SENCHO_MESH_PROXY_TUNNEL_IDLE_MS`). Mesh dispatch is mode-agnostic at
the routing layer; `PilotTunnelManager.ensureBridge` resolves an
existing tunnel or asks the new `MeshProxyTunnelDialer` to dial.
The Routing tab badges now use a `reachableMode` classifier: `★ Local`
for local, `pilot offline` red badge for a pilot with a down tunnel,
and a new `unreachable` red badge surfacing the specific reason
(missing token, scope not full-admin, TLS failure, remote does not
support proxy mesh). Distributed API remotes with valid credentials
show no negative badge; the tunnel opens on first dial.
Auth: the proxy-tunnel WS upgrade requires an `Authorization: Bearer`
API token with the `full-admin` scope. Lower-scoped tokens are
rejected at upgrade time so a leaked read-only token cannot reach the
mesh data plane.
Bidirectional: the proxy-mode WS handler registers itself as the
local `MeshService` reverse dialer (compare-and-swap), so meshed
containers on a Distributed API remote can dial cross-node aliases
via `tcp_open_reverse` over the same tunnel. Cross-node relays
(`PilotTunnelBridge.acceptReverseRelay`) await `ensureBridge` so
proxy-to-proxy mesh works without standing tunnels.
* docs(mesh): describe Distributed API mesh and unreachable troubleshooting
Refresh the user-facing mesh documentation to reflect that mesh works
over both Pilot Agent and Distributed API remotes. Adds a
Troubleshooting accordion entry for the new `unreachable` Routing tab
badge so operators can match a tooltip reason ("api token rejected
(scope must be full-admin)", "remote does not support proxy mesh",
"TLS handshake failed", "api_url not set", "api token missing") to a
concrete fix.
* refactor(mesh): apply /simplify review findings
Five cleanups surfaced by the post-implementation code review pass.
No behaviour change for fleets running on `main`; only internal
structure improves.
- **Deterministic container IP shared.** Extract the compose-default
network preference (`pickContainerIp`) and the conventional-name
fast path (`lookupContainerIp`) into `backend/src/mesh/containerLookup.ts`.
Both `MeshService.resolveContainerIp` (existing same-node fast
path) and the switchboard's `resolveByComposeLabels` (proxy/pilot
inbound dial) now use the same logic. Earlier the switchboard
helper grabbed the first `Object.values(Networks)` IP, which
could flip across daemon versions on multi-network containers;
fixed.
- **WS URL upgrade extracted.** New `backend/src/utils/wsUrl.ts`
exposes `httpUrlToWs(baseUrl)` that maps `http://` to `ws://` and
`https://` to `wss://`. Used in both `pilot/agent.ts` and the
proxy-tunnel dialer. The previous inline `replace(/^http/, 'ws')`
silently downgraded `https://` to `ws://` (cleartext).
- **`computeReachable` takes the pre-fetched node.** `getStatus`
already iterated `db.getNodes()`; the helper previously re-queried
by id per node (N+1 reads). Pass the row in.
- **Reachable-reason ternary -> const map.** Replace the nested
ternary in `MeshService.computeReachable` with a
`Record<DialFailureCode, string>` lookup keyed on the dialer's
failure code.
- **Activity-type ternary -> const map.** Same flattening inside
`MeshProxyTunnelDialer.logActivity`.
All mesh tests pass (85/85). Full backend suite green (2126/2126).
* fix(mesh): cache proxy dial failures and contain WS handshake errors
`ensureBridge` now consults the recent-failure cache before dialing so a
continuous mesh workload against a misconfigured proxy-mode remote does
not produce one upgrade attempt per cross-node TCP open. `recordFailure`
emits at most one activity-log entry per cache window per (nodeId, code)
so a connect-loop on a single bad remote cannot flush the ring buffer.
Failure messages run through `redactSensitiveText` before reaching the
log so any embedded Bearer / JWT / inline-URL credentials are scrubbed.
`stop()` clears the inflight map and `dial()` checks `this.stopped`
between awaits so a shutdown does not leak a half-opened bridge.
When `awaitOpen` rejects (401, 4403, TLS), the dialer attaches a noop
'error' listener before calling `ws.close()`. Without it the ws library
emits a tail 'error' on a still-CONNECTING socket that propagates as an
unhandled exception. Surfaced by a live-network test against a real
proxy-mode peer.
Adds `mesh-proxy-tunnel-live.test.ts` (skipped unless MESH_AUDIT_URL
and MESH_AUDIT_TOKEN_FILE env vars are set) covering both the happy
path and the auth-rejected path against an actual remote Sencho.
* feat(mesh): instrument proxy tunnel observability
Adds three counters to `PilotMetrics`:
- `proxy_bridges_total` (incremented on `registerProxyBridge` success)
- `proxy_dials_failed` (every failed dial attempt, not deduped)
- `proxy_idle_closes` (idle-sweep teardowns)
All three surface automatically via `GET /api/system/pilot-tunnels`
since the route returns the full `Counters` snapshot.
Gates two pre-existing always-on `console.warn` calls in
`tcpStreamSwitchboard.ts` (mid-stream socket errors and Docker resolve
failures) behind `isDebugEnabled()`. Both fire on per-stream events and
would otherwise violate the diagnostic-log safety rule under load.
Adds `mesh-tcp-stream-switchboard.test.ts` covering the forward
`tcp_open` path: real localhost dial, resolver errors (no_target,
denied), per-tunnel cap saturation, frame-routing fall-through
invariants, `tcp_close` socket teardown.
Drops `MESH_CONNECT_TIMEOUT_MS` from the public surface; only the
switchboard itself uses it.
* fix(mesh): tighten Routing tab unreachable handling
`RoutingNodeCard.tsx`: the **Add stack to mesh** button now disables
when `reachableMode === 'unreachable'`, matching the existing
TogglePill behavior. Without this, an operator on an unreachable node
could open the opt-in sheet, confirm, and watch the redeploy proceed
against a target whose mesh data plane will silently fail to route.
Also drops the leftover `status.nodeId !== -1` guard on the pilot-
offline badge.
`MeshService.ts`: renames `isMeshReachable` to `isMeshConfigured` with
the new predicate that returns true for proxy-mode remotes whose creds
are valid (the tunnel is opened on demand). `getRouteDiagnostic` now
distinguishes "routable" (configured) from "pilotLive" (live tunnel
state, only meaningful for pilot mode); without the split, every idle
proxy-mode route would report `tunnel down`.
`MeshService.setReverseDialer` warns when the unconditional install
path silently overwrites a non-null current dialer. By topology a
Sencho is either pilot or central, so the branch flags a misconfigured
deployment rather than an expected race.
Drops the dead `export { ReverseTcpStreamHandle }` re-export from
`pilot/agent.ts`; its only consumer imports straight from
`mesh/tcpStreamSwitchboard.ts`. Fixes a stale doc comment in
`MeshService.ts` that referenced the wrong source file.
Adds `mesh-proxy-tunnel-handler.test.ts` covering the WS handler
lifecycle: pilot-mode 404 rejection, post-upgrade reverse-dialer
install, concurrent-upgrade 1013 rejection (single-tenant slot), and
error-path teardown.
Updates the troubleshooting accordion in the user docs to mention the
disabled-state behavior.
* fix(mesh): close ESLint and CodeQL findings on the proxy-tunnel diag logs
Remove the unused `reverseDialer` local in `meshProxyTunnel.ts`; the
closure target is `localDialer` above and this assignment was always
dead. Strip line breaks inline on the three `MeshProxyTunnelDialer`
diag log lines so CodeQL `js/log-injection` data flow recognises the
sanitisation that `sanitizeForLog` already performs.
No behaviour change; the diag logs render the same characters they
do today.
|
||
|
|
23bbee4f45 |
feat(mesh): replace host-mode with shared sencho_mesh Docker network (#1009)
* feat(mesh): replace host-mode with shared sencho_mesh Docker network Phase D of the mesh redesign: drop the operator's `network_mode: host` requirement and the `host-gateway` extra_hosts pattern that did not work on cloud iptables-restrictive distros (OCI, etc.) or Docker Desktop. Each Sencho creates a shared `sencho_mesh` Docker bridge network on boot (default subnet 172.30.0.0/24, override via SENCHO_MESH_SUBNET), pins itself at `<network>+2`, and attaches every meshed user service to the same bridge. Compose overrides now emit IP-based `extra_hosts` plus a top-level `networks` block declaring `sencho_mesh` external. Override delivery: central renders for local stacks; for remote stacks it sends the fleet alias list to the remote's new `PUT /api/mesh/local- override/:stackName` endpoint, which renders against the remote's OWN local senchoIp and writes under its OWN DATA_DIR. Each node may use a different subnet without coordination beyond the env var. Opt-in / opt-out now trigger an automatic redeploy of the affected stack via the existing deploy code path (local: ComposeService; remote: HTTP POST through proxyFetch). The frontend opt-in sheet shows a confirmation modal (ConfirmModal) before the mutation. Failed redeploys emit both a mesh activity event and a durable audit-log row. Hardening: - Reserve port 1852 at opt-in (prevents user containers from racing the Sencho API listener). - ensureMeshNetwork refuses to continue if `sencho_mesh` exists with a mismatched subnet rather than silently routing to the wrong IP. - Idempotent network connect/disconnect helpers in DockerController. - optInStack rolls back the DB row if the just-inserted stack's override push fails (no half-states surviving across calls). - regenerateOverridesForNode runs in parallel and skips the just- pushed stack on opt-in. Operator template: drop `network_mode: host`, restore `ports: ["1852:1852"]`. Mesh now works identically on Linux LAN, OCI, and Docker Desktop without firewall changes. Docs: rewrite docs/features/sencho-mesh.mdx around the shared bridge network, document SENCHO_MESH_SUBNET, surface the host-network-service opt-in restriction, and cross-link with the Pilot Agent docs. BREAKING CHANGE: the operator's `docker-compose.yml` no longer uses `network_mode: host`. After upgrading, redeploy any meshed stacks once so they pick up the new IP-based override and join `sencho_mesh`. * fix(mesh): wrap stackName with path.basename in local-override fs ops CodeQL flagged js/path-injection on the new applyLocalOverride and removeLocalOverride methods because they are publicly reachable and its data-flow model does not recognize isValidStackName / isPathWithinBase as sanitizers. The validation IS sufficient (the allowlist regex blocks path separators, the path-prefix check blocks escape), but path.basename is a model CodeQL recognizes and is purely defensive: for any input that already passes isValidStackName, basename is the identity. |
||
|
|
f599110386 |
feat(mesh): collapse sidecar into Sencho process via in-process forwarder (#1000)
The separate saelix/sencho-mesh sidecar container is gone. The
forwarder logic that previously lived in mesh-sidecar/src/forwarder.ts
moves into the Sencho process as backend/src/services/MeshForwarder.ts,
a thin per-port net.Server lifecycle wrapper. MeshService implements
the host interface and owns resolve plus splice; MeshForwarder owns
listener boilerplate. One container per node, no separate image to
publish, no control WebSocket.
Operator-facing change: the Sencho container now runs in
network_mode: host so the forwarder can bind alias ports on the host
network where meshed containers' extra_hosts host-gateway entries
point. Without host network mode the listeners would land in the
container's namespace and inbound traffic from peers would never
reach them. The 1852:1852 port publish becomes a no-op under host
mode and is commented out in the operator template.
Same-node forward path now dials the target container's bridge IP
via Dockerode (preferring the compose default network for
deterministic selection across daemon versions) instead of 127.0.0.1.
The legacy 127.0.0.1 path only worked when the target service
published its port to the host; the IP path works regardless.
Cross-node mesh routing in this phase is central -> pilot direction
only via PilotTunnelManager.openTcpStream. Pilot -> central and
pilot <-> pilot via central relay land in Phase B with the
tcp_open_reverse frame.
Deletions:
- mesh-sidecar/ package entirely (Dockerfile, package, sources, tests)
- backend/src/websocket/meshControl.ts
- MeshService sidecar lifecycle: spawnSidecar, stopSidecar,
isSidecarRunning, mintSidecarToken, verifySidecarToken,
attachSidecarSocket, handleSidecarResolve, sendSidecar
- POST /api/mesh/nodes/:id/sidecar/restart route
- /api/mesh/control WS dispatch in upgradeHandler
Type cleanup: 'sidecar' literal removed from MeshActivitySource and
MeshProbeResult.where (also the frontend mirror). MeshNodeStatus
sidecarRunning becomes localForwarderListening (boolean | null) so
non-local nodes get a null instead of an unconditional false; the
honest semantic is "this view only knows the local forwarder state;
remote forwarder status lands in Phase B." MeshNodeDiagnostic
sidecar object becomes forwarder { listening, listenerCount }.
Frontend MeshDiagnosticsSheet drops the restart-sidecar action and
sidecar liveness card; surfaces forwarder state plus a "runs
in-process; no separate container" caption.
Resolves audit findings C-1 (data plane non-functional), C-2 (sidecar
control WS not loopback-enforced), C-4 (sidecar lifecycle Dockerode-
on-remote, PR #999 closed), and C-5 (saelix/sencho-mesh:latest
unreachable). C-3 (PR #992) is unchanged. M-12 (PR #994) is
unchanged.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
4d9617a5c6 |
feat(frontend): add SystemSheet primitive and migrate mesh sheets to §9.11 chrome (#960)
DESIGN.md §9.11 codifies one canonical right-side detail-sheet shell (cyan
rail, mono crumb, italic serif name, mono meta, ESC chip + close glyph,
fixed three-slot toolbar, cyan-underline tabs, ScrollArea body, footer
freshness band). Today the 16 sheet consumers each render their own
header chrome with stock shadcn SheetHeader/SheetTitle.
Introduce <SystemSheet> + <SheetSection> in
frontend/src/components/ui/system-sheet.tsx, composing the existing
<Sheet>/<SheetContent> primitive. Add a backward-compatible showClose
prop to SheetContent so SystemSheet can render its own ESC chip + close
glyph instead of the stock cyan square close.
Migrate the four mesh sheets as the first batch:
* MeshActivitySheet: crumb Fleet › Mesh › Activity, footer freshness from
most-recent event timestamp.
* MeshOptInSheet: crumb Fleet › Mesh › {nodeName}, meta of opted-in
count, drops the redundant bottom Close button (ESC chip dismisses).
* MeshDiagnosticsSheet: removes the icon-prefixed title (forbidden by
§9.11), lifts Refresh/Restart buttons from the body into the toolbar
band, three SheetSection blocks for sidecar status, streams, cache.
* MeshRouteDetailSheet: adds Overview/Events/Raw tabs, lifts Test probe
into the toolbar primary slot, footer surfaces last probe latency.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
ae8211c0b4 |
fix(fleet): forward main node tier to remote config fetch and hide local-only fields (#811)
The /fleet/configuration endpoint fetched remote node config via a direct backend-to-backend fetch that omitted the distributed license headers (x-sencho-tier, x-sencho-variant). Remote nodes evaluated their own Community tier and returned locked: true for Webhooks, Scanning, and Backup even when the main node held a Skipper/Admiral license. Forward the same tier/variant headers that remoteNodeProxy already injects so tier gates on remote nodes honour the main node's license. MFA and Backup are also hidden for remote node cards in the Status tab: - MFA is a user session feature managed on the main node; remote nodes are accessed via node_proxy Bearer tokens with no userId, so the value was always "Not set" and provided no useful information. - Backup (Sencho Cloud Backup) runs fleet-wide from the main node and captures all nodes' compose files; remote nodes never configure it independently, so showing it there was misleading. Removing both fields from remote cards keeps the grid at 6 items (3 even pairs) and eliminates stale or irrelevant data. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |