mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
00a323252d549cdd0859df1e74cef41fd5a981a3
91 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1b9a40f874 |
fix: align notification unread badge with panel visibility rules (#1520)
Stack success events were hidden from the panel but still counted unread on the bell and dashboard. Share one visibility helper across badge, panel, and Recent Alerts. Harden mark-all-read against partial API failures. Fixes #1513 |
||
|
|
997a6bb79a |
fix: gate cross-node HTTP and stop-by-label on remote RBAC capability (#1509)
An older remote node ignores the forwarded actor-role header (running proxied requests as admin) and ignores the stop-by-label stack allowlist (stopping every label-matched stack). The control could neither detect nor prevent this on a mixed-version fleet. Instances now advertise a cross-node-rbac capability, and the control refuses to act when a remote lacks it: - HTTP proxy: a non-admin user's request is not forwarded to a remote that does not advertise the capability (fails closed when it cannot be determined). Admins are unaffected. - Stop-by-label: a real stop bound to a confirmed stack set is not sent to a remote lacking the capability; the node is reported as needing an upgrade. As defense in depth, a node whose results name stacks outside the confirmed set is failed rather than rendered as a clean stop. Separately, the stop's lock-contention path now reports every confirmed stack as a contention failure (including one that lost its label), so a confirmed stack is never silently dropped and the result is never empty. |
||
|
|
b5810a9b55 |
feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501)
A batch of UI/UX polish: - New independent "Reduced motion" appearance setting (separate from Reduced effects). Drives framer-motion via MotionConfig and clamps CSS transitions via data-motion on <html>; toasts are unaffected. Defaults off (OS preference still honored). - Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted word-wrap toggle to the file viewer (default on), and add a fullscreen toggle that collapses the Command Center + Logs column so the editor fills the width. - Create Stack > From Git: remove the nested scroll clamp so the deploy toggle and footer are reachable. - Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state headings (calm drops the italic), and fix the Actions card body overlapping the action-row divider. - Snapshots: restyle Restore and Restore all to the ghost button design used by View / Preview / Download, and right-align the per-stack Restore. - Settings sidebar: App Store gradient active style and standard font size. - Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the findings change, via a shared fingerprint-keyed hook. - Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet Snapshots tab (FleetView tabs are now controlled to support the deep link). |
||
|
|
cf0db36e78 |
fix: fire a one-time schedule on the exact chosen date and time (#1497)
Simple "Once" schedules compiled to a 5-field cron, which has no year field, so the scheduler computed the next run as the next annual occurrence. A date chosen for a later year ran a year early, and a time already elapsed today ran a year late, contradicting the UI promise that the task fires on the chosen date. One-time schedules now send the chosen absolute timestamp (run_at) and the backend pins next_run_at to it instead of the cron-derived next run, so the run fires on the exact selected instant including the year. The Simple-mode validation now compares the full chosen instant against the current time, so a time earlier today is rejected as past rather than silently deferred a year. run_at is validated as a finite, future epoch-millisecond timestamp on create and update. The enable/disable toggle preserves a one-shot's pinned next_run_at (its yearless cron cannot reconstruct the chosen year), so re-enabling restores the exact instant. Recurring shapes and Advanced mode are unchanged (cron stays authoritative). |
||
|
|
3bece54e72 |
feat: add a Simple mode to the New Schedule flow (#1495)
The schedule editor exposed only a raw 5-field cron input, which is unfriendly for the common "daily at 3am" or "one-time next week" cases. Add a Simple mode (now the default) that builds the cron from a frequency and time: Once, Hourly, Daily, Weekly, and Monthly. Advanced mode keeps the raw cron input as an escape hatch. - Simple mode compiles to the existing cron_expression on save; no schema or scheduler changes. - One-time schedules reuse the existing delete-after-run flag: selecting Once turns it on and locks it so the task runs a single time (cron has no year field, so without it the task would repeat yearly). - Editing a task opens in Simple mode when its cron maps to one of the simple shapes, otherwise in Advanced; switching a custom cron to Simple warns that it will be replaced. - Day-of-week uses frosted toggle chips and the time uses hour/minute selects so the controls match the rest of the editor. |
||
|
|
5960c1e85e |
feat: show node and fleet targets on schedule timeline pills (#1480)
Timeline pills and the mobile schedule list now identify what each scheduled run acts on instead of repeating the task name. Pills stay compact (firing time plus a category-aware target) and carry the full detail on hover: - Stack actions show the stack name. - Fleet snapshots show "Entire fleet". - Fleet auto-updates and node-scoped prune/scan show the selected node. - The hover tooltip adds the action label, task name, and node. The mobile list resolves node names too, so prune and scan rows name the node rather than the literal "system". A shared scheduleTargetDescriptor helper removes the target-label logic that was duplicated across the desktop and mobile views. The lifecycle lane is renamed "Stack lifecycle" to match the action-picker category wording. |
||
|
|
1de49f8b1a |
fix: name matched risk inputs in policy scan banner and alerts (#1473)
The pre-deploy gate names the inputs that matched a scan policy (a known-exploited CVE, a fixable Critical/High, or a severity threshold), but the informational post-scan surfaces still framed every violation as a severity ceiling. The scan detail banner read "blocks severities at or above X, highest severity is Y" and the scheduled-scan alert read "<severity> exceeds <maxSeverity>", which is wrong for a KEV- or fixable-only policy that never gated on severity. Persist the matched reasons on the policy evaluation, carry them on the scheduled-scan violation, and render them on the banner so every policy surface names the input that actually matched. Evaluations persisted before this change carry no reasons: the parser defaults the field to an empty array and the banner falls back to a plain violation notice. |
||
|
|
315e8b6379 |
feat: add node update alerts with changelog tab and skip-version handling (#1463)
* feat: add node update alerts with changelog tab and skip-version handling - Add node_update_available notification category with blue/brand bell dot - Route node_update_available notifications to Fleet -> Node updates sheet - Add Changelog tab to NodeUpdatesSheet with GitHub release notes - Add per-node skip-version persistence (node_update_skips table) - Skip hides update CTA on node card and sheet; re-surfaces on newer version - Skipped nodes excluded from Update all backend filter - Add pulsating dot indicator on Changelog tab when updates available - Always-visible View changelog action in notification row bottom - Admin-only for all mutating controls (skip, unskip, update) - Backend tests for skip-version semantics (15 tests) - Update fleet-view.mdx, remote-updates.mdx, and OpenAPI spec * fix: address audit findings - nested button, stale changelog, semver normalization, mobile intent - Move View changelog button outside routable button (sibling element) - Fix aria-label for node_update_available notification rows - Support ?recheck=true on release-notes endpoint - Invalidate release notes cache on forced recheck - Store normalized semver (semver.valid strips v prefix) - Skip fleetUpdatesIntent on mobile (desktop only) - Add v-prefix normalization test * fix: restore View changelog on same line as timestamp, opposite sides The button is always visible at the bottom right of the notification card, on the same row as the timestamp (just now), using justify-between layout. * fix: update tests for node_update_available category and release-notes fetch - Backend: monitor-service tests now expect node_update_available instead of system - Frontend: NodeUpdatesSheet tests mock release-notes API call to prevent undefined then() * fix: resolve ci lint failures |
||
|
|
a698aaa926 |
feat: add per-stack project env file selection for Docker Compose (#1457)
* feat: add per-stack project env file selection for Docker Compose
Allow users to configure an ordered list of env files per stack that serve
as the project environment file(s) for Docker Compose ${VAR} interpolation.
The selected files are passed via repeated --env-file flags during all
compose commands.
Backend:
- Add stack_project_env_files table (node-scoped, ordered)
- Extend authoredComposeEnvFileArgs to emit --env-file for configured files
- Add GET/PUT /stacks/:name/project-env-files and /candidates endpoints
- Update resolveStackEnvSources to use configured files as interpolation source
- Update resolveAllEnvFilePaths to merge injection + interpolation sources
- Add discoverStackLocalEnvFiles for candidate discovery
- Extend backupStackFiles and snapshotStackFiles for project env files
- Add project-env-files capability to CapabilityRegistry
Frontend:
- Add project env file selector to EnvironmentPanel (capability-gated)
- Update EditorView banner to generic "project environment file" language
- Add project-env-files capability to capabilities.ts
Issue: #1454
* fix: add realpath validation, clear all stale backup files, reject nested paths
- authoredComposeEnvFileArgs: use fsPromises.realpath + isPathWithinBase
for symlink escape defense at use time
- backupStackFiles: clear ALL non-marker files from backup slot before
writing, not just PROTECTED_STACK_FILES (handles stale old.env)
- PUT project-env-files: reject paths containing / or \ (root-level
only, matching Compose auto-discovery behavior)
* fix: add getStackProjectEnvFiles to compose-service mock
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles
on the DatabaseService singleton. The compose-service mesh-override
tests mock that singleton without the new method, causing 6 failures.
Add getStackProjectEnvFiles: () => [] (empty = fall back to legacy
behavior, which is what these tests exercise).
* fix: add getStackProjectEnvFiles to remaining service mocks
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles,
which is missing from the mock in compose-images.test.ts (6 failures)
and image-update-service.test.ts (proactive fix).
* fix: apply inline path-injection barrier at fs sink for CodeQL
The PUT project-env-files route resolved paths via isPathWithinBase
before calling fsp.stat, but CodeQL does not credit a containment check
separated from the sink. Apply the canonical inline barrier pattern
(path.resolve + startsWith at the sink) used throughout the codebase.
* fix: resolve stackDir from the same canonical root as safePath
Prevents a containment bypass when the compose base directory is
a symlink: stackDir was previously joined from the unresolved
baseDir while the inline barrier used path.resolve(baseDir),
which could differ for symlinked paths. Now both stackDir and
safePath are resolved from a single canonical root, then each is
containment-checked against it.
* fix: remove unused isPathWithinBase import
The inline path-injection barrier refactor replaced isPathWithinBase
with an inline startsWith check at the fs sink, so the import is now
unused and fails ESLint no-unused-vars.
|
||
|
|
f1f64ec7f6 |
feat: show container name in structured log output (#1452)
* feat: show container name in structured log output Prepend a normalized container name prefix to each line in ComposeService.streamLogs() so both the structured log viewer and the raw terminal identify which container produced each entry. - Backend: prepend displayName (normalized via normalizeContainerName) before LogFormatter.process() in sendOutput and flushBuffer. - LogFormatter: refactor process() to handle both prefix-first and timestamp-first input orders via a while-loop; widen PREFIX_REGEX to accept dotted service names. - Frontend: add containerName to LogRow, extract prefix in parseLine, render as an inline mono chip in the message column, and include the name in downloaded logs (omitting the bracket prefix when null). - Tests: 14 new tests across log-formatter, compose-service streamLogs, and StructuredLogViewer chip rendering + download formatting. * fix: guard LogFormatter loop to at most one prefix and one timestamp The while-loop refactored for order-agnostic prefix/timestamp parsing could continue matching beyond the intended single prefix and timestamp. A log line like "redis | 2024-...Z api | started" would falsely colorize "api |" as a second container prefix in raw terminal output. Add prefixFound/timestampFound boolean guards so the loop stops after one prefix and one timestamp, regardless of input order. * feat: per-service color alternation for log container chips Add an Appearance setting that lets users switch between unified cyan and per-service label-token colors for the container name chips in the structured log viewer. - Extract HUE_VARS and hashLabel() from NodeLabelPill into a shared utility at frontend/src/lib/label-colors.ts. - Add useLogChipColorMode hook (browser-local localStorage, sencho.log-chip-color-mode key, unified by default). - Add SegmentedControl in Settings > Appearance > Display. - Apply inline label-token styles via style attribute in per-service mode; keep current text-brand/80 bg-brand/10 classes in unified mode. - 14 new tests across label-colors, hook, and viewer chip rendering. |
||
|
|
ba1be3cc4e |
fix: skip Docker Compose $$ escaped variables in Anatomy parser (#1450)
The Anatomy view's interpolation regex did not skip Compose's $$ escape
syntax, so $${VAR} (a literal, not a reference) was incorrectly flagged as
a missing variable. Add the (?<!\$) negative lookbehind that the backend
parser already uses, matching its behaviour.
|
||
|
|
7982251dc6 |
feat(scheduler): add helper text and risk badges to scheduled action picker (#1449)
* feat(scheduler): add helper text and risk badges to scheduled action picker Add a concise helper text and risk level badge to every scheduled action in the create/edit modal. The six risk levels (Safe, Read-only, Interruptive, Runtime change, Removes containers, Destructive) map to the four existing design-system tones and render as a small dot+label chip next to the helper text, following the same pattern as SeverityBadge. Fix an ambiguous mobile label: update + target_type: fleet now resolves through resolveTaskAction and renders 'update node stacks' instead of the misleading 'update fleet'. Add exact helper-text and risk-level assertions for all 10 actions, plus component tests for default modal state, action-switch scenarios, and mobile update+fleet rendering. * docs: update stale scheduled-operations alt text for changed helper text |
||
|
|
cc78873e65 |
feat(scheduler): group schedule action picker by operator intent (#1446)
* feat(scheduler): group schedule action picker by operator intent Reorganize the New Schedule action picker from a flat dropdown to a category-grouped list (Lifecycle, Updates, Security, Maintenance, Backups). - Extend Combobox component with optional group field on ComboboxOption, rendering grouped sections with non-interactive headers when groups are present. Flat rendering is unchanged for all other callers. - Reorder SCHEDULED_ACTIONS by category group and update seven action labels per the operator-intent spec. - Add DEFAULT_SCHEDULED_ACTION_ID constant so picker order and form defaults are independently controllable. - Wire grouped actionOptions into ScheduledOperationsView. - Update all label references in docs and tests. - Add Combobox grouping tests, registry order test, and default-constant test. * fix(scheduler): correct Combobox grouping for interleaved groups, docs labels - Replace last-group-append with Map-based group partitioning so interleaved or mixed-group options land in the correct group. - Add interleaved-groups test and restore non-interactivity test. - Update stale "Start Stack" references to "Start / Bring Up Stack" in doc action-label contexts. - Update action-picker alt text to describe the new grouped order. |
||
|
|
db8bb70b7d |
fix(scheduler): reject 6-field cron in Scheduled Operations (#1435)
* fix(scheduler): reject 6-field cron in Scheduled Operations Create and edit validation parsed cron with cron-parser, which accepts both 5- and 6-field expressions, while the form, presets, and docs all describe a 5-field cron. Because the scheduler ticks once per minute, a leading seconds field can never improve precision, so a 6-field expression was silently accepted but never honored on its stated schedule. Add a field-count guard on both sides: the API rejects 6-field input at create and edit with a clear message, and the form surfaces the same error inline and disables save. Cron nicknames such as @daily still pass. Document the five-field requirement in the cron reference. * chore: merge main into scheduled cron validation * fix: avoid logging policy bypass actor in debug output |
||
|
|
bc8c051962 |
feat(scheduler): consistent action targeting in Scheduled Operations (#1431)
Give every scheduled action an explicit, predictable target model (Action then Node then Stack then Options then Schedule): - System Prune now exposes a Node picker and requires a node, so it can no longer run silently on the default node. - Vulnerability Scan and System Prune list local nodes only; both run on the hub-local Docker daemon and reject remote nodes on the backend. - Restart Stack service discovery loads services from the selected node via fetchForNode instead of the active or local node. - Fleet Snapshot shows a read-only "Scope: Entire fleet" summary. Backend gains a shared local-node guard and prune node validation on create and update, plus an executor-level remote-node guard, so the frontend and backend validation now agree for every action. |
||
|
|
0af7ad1df2 |
refactor(scheduler): drive scheduled-action metadata from a shared registry (#1428)
Scheduled-operation action metadata was duplicated across the backend route validator, the DatabaseService action union, the desktop action picker, the Timeline lanes, and the mobile labels/tones. Adding or renaming one action meant editing all of them. Introduce one registry per package as the single source within that package: - backend/src/services/scheduledActionRegistry.ts owns the action list and target-type validation; routes/scheduledTasks.ts and DatabaseService import from it (BackendScheduledAction type, VALID_ACTIONS, validateActionTarget). - frontend/src/lib/scheduledActions.ts owns the UI metadata (labels, short labels, categories, tones, target/node/stack/service flags, helper text) and drives the create-flow picker, the All Tasks label, the Timeline lanes, and the mobile schedule view. Timeline lanes now group by semantic category (Lifecycle, Updates, Security, Maintenance, Backups) sourced from the registry. The update-fleet UI alias is made explicit via a backendAction field. Backend validation stays authoritative; parity tests on each side keep the action sets in lockstep. |
||
|
|
2eafee3594 |
fix(networking): treat host-network services as host-exposed in summaries (#1430)
The exposure summaries derived a stack's exposure solely from the declared published-port list, so a service running with network_mode: host (which publishes every container port on the host but declares no ports:) was under-reported as less exposed than it actually is. Capture network_mode in the lightweight dependency parser, add an isHostNetwork predicate, and treat a host-network service as exposed and publishing across the Fleet networking summary, the Stack Dossier export, and the Networking panel, matching how the Compose Doctor already flags host networking. |
||
|
|
401980ffa3 |
feat(resources): render anonymous volume names readably in the volume browser (#1429)
Anonymous Docker volumes carry a raw 64-character hex name, which is correct data but unreadable in the volume browser crumb, sheet title, and table row. Detect 64-hex names and show a short prefix (e.g. 079dfda49f2c…) in the table cell and sheet title, with the full name available on hover and an "anonymous" chip so the truncation is self-explanatory. The volume browser sheet adds a band that keeps the full hash visible and copyable. Named volumes still display in full. The name is passed verbatim to the API; only the rendered label changes. |
||
|
|
1c5b2715b8 |
fix(editor): suppress global hotkeys while the code editor is focused (#1413)
Typing a single-key shortcut (a/h/u/p/b) in the compose editor opened that shortcut's action instead of inserting the character, but only in Chrome. The shared isInputFocused() guard recognized inputs, textareas, and contentEditable elements; in Chromium the Monaco editor uses the EditContext API whose focused surface is a plain focusable div, so the guard missed it and the hotkey handlers fired. Safari falls back to a hidden textarea, which the guard already caught. Match any focus inside the .monaco-editor container so the guard covers the EditContext surface, bringing Chrome to parity with Safari for both global hotkey systems. Add a unit test for the guard. Closes #1410 |
||
|
|
37e6e48b40 |
feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409)
* perf(files): spool uploads to disk instead of buffering in memory
Switch the stack file-explorer upload from multer memoryStorage to
diskStorage and stream the spooled temp file through the file-root
gateway, so an upload is never held fully in RAM. Authorization and
root resolution now run before multer spools, so an unauthorized or
read-only-root request is rejected without writing a temp file, and the
spool is removed on every exit path. The named-volume helper write
verifies the written byte count, since cat cannot report a short write.
* feat(files): copy and duplicate files in the explorer
Add a copy capability to the stack file explorer: a same-folder
Duplicate (auto-suffixed name) and a "Copy to..." destination picker,
on both filesystem and named-volume roots. Copying is within-root,
symlink-leaf-safe, blocks a directory copy into its own subtree, and
refuses to create a protected name (compose/.env) at the stack root
while still allowing a protected file to be duplicated under a new name.
* feat(files): make the file tree keyboard accessible
Bring the stack file explorer tree to the WCAG tree pattern: rows are
treeitems carrying aria-level, aria-selected, and aria-expanded, with a
single roving tabindex and full keyboard navigation (arrow keys,
Home/End, Enter/Space) over a flattened visible-node list that stays in
lockstep with the rendered rows. A polite live region announces the
selected file. No visual change to the tree.
* feat(files): bulk select, delete, move, and download files
Add multi-select to the stack file explorer (checkboxes plus Shift and
Ctrl/Cmd click over the visible order) driving three bulk actions:
delete, move, and download as a streamed .tar.gz. All run within the
active root on both filesystem and named-volume backends, report
per-item results so partial failures surface (with the failed items
kept selected for retry), normalize ancestor/descendant selections
server-side, and cap the archive entry and byte counts before any
bytes are streamed. Protected compose/.env files are excluded from
bulk delete and move but may still be downloaded.
* docs(files): document copy, bulk actions, and keyboard navigation
Add the copy/duplicate and multi-select bulk delete/move/download
sections to the Files & Volumes page, a keyboard-navigation note for the
tree, an updated context-menu reference, and bulk troubleshooting entries.
* fix(files): inline path-injection barriers at the new file-op sinks
CodeQL js/path-injection does not credit the wrapped isPathWithinBase
containment check, so the new copy/bulk/disk-upload flows tripped the
gate. Inline the canonical path.resolve + startsWith barrier at the
realpath sink in resolveSafePathWithin (covers every user-relPath flow)
and confirm the multer spool path resolves within UPLOAD_TMP_DIR before
unlinking it or streaming it onward. Behavior is unchanged; the paths
were already validated.
* fix(files): guard the ancestor-walk realpath sink too
The first barrier covered realpath(target), but the ENOENT ancestor
walk re-derives the path via path.dirname, which static analysis treats
as a fresh tainted value. Add the same inline containment barrier before
that realpath and resolve the root case via the untainted base, so the
only tainted realpath input is one the startsWith check has cleared.
Behavior is unchanged.
* fix(files): resolve the root case off the taint path in the ancestor walk
The compound guard on existing (the same variable as the startsWith
subject) was not credited as a sanitizer. Handle the root case before
the barrier by resolving the untainted base directly, leaving a plain
canonical startsWith guard on the strictly-within ancestor. Behavior is
unchanged.
* fix(files): harden helper-backend bulk download and uploads
Address three issues found in the named-volume (helper) backend:
- Bulk download could send 200 headers before discovering a file the
helper download path refuses, tearing the archive mid-stream. The
prewalk now rejects symlinks, non-regular ("other") entries, and
files over the per-file download cap before any header (400/413).
FileEntry gains an 'other' type so non-regular entries stay distinct
from regular files as they pass through the gateway.
- The helper directory listing was fully buffered before the archive
entry cap could fire. listDir now accepts a limit; the list script
stops after limit+1 rows and the gateway reports truncation.
- A stdin pipeline error during a helper upload masked the container's
real nonzero exit code (and its 4xx mapping) as a generic 500. The
nonzero exit now wins; the masked stream error is logged.
* feat(files): add a New file toolbar button with server-enforced create-only
The stack file explorer could create a folder from a toolbar button but a
new file only from a folder's right-click menu, so a file could not be
created at the stack root at all. Add a New file toolbar button beside New
folder, targeting the current directory.
Creating a file now routes through a new createEmptyStackFile helper that
posts a zero-byte file through the existing upload endpoint with overwrite
off, so the server's exclusive-create path rejects an existing name instead
of clobbering it. A file collision surfaces inline in the dialog; a folder
collision and other failures surface as a toast.
* fix(files): widen the tree row hit area and add horizontal scroll for long names
Right-clicking a file tree row only opened the Sencho context menu when the
click landed on the filename; the rest of the row fell through to the native
browser menu, and long names were truncated with no way to read them.
Make each row span the full pane width (and grow with its content) so the
whole row is the context-menu trigger, and let the tree scroll horizontally
so a long name is reachable instead of clipped. A new opt-in horizontal prop
on ScrollArea adds the styled horizontal scrollbar without clamping content
width.
* docs(files): document the New file button, full-row right-click, and long-name scrolling
* test(files): cover createEmptyStackFile targeting the stack root
Add an API-layer case for the empty-directory (stack root) create path, the
primary reason the New file toolbar button exists, so a regression in the
root-level URL would be caught at unit speed rather than only in e2e.
|
||
|
|
b9d8e9f490 |
feat(stacks): browse and edit mounted volume files in the explorer (#1403)
* feat(stacks): browse and edit mounted volume files in the explorer Reposition the stack file explorer around runtime configuration access: discover a stack's declared mounts and expose each as a safe, stack-scoped file root. The explorer opens on a Volumes group (bind mounts and named Docker volumes) by default, with the stack source directory as a secondary group, on a "Files & Volumes" tab. - Discover roots from the rendered effective compose model; resolve named volumes to their Docker name and browse/edit them through the hardened helper container, with bind mounts handled directly when reachable. - Re-derive the allowed roots server-side on every file operation and match the client root id against them, so a request can never address a path the stack did not declare. Block dangerous host mounts and binds that overlap Sencho's managed directories; reject writes to read-only mounts. - Thread an optional root id through the existing file endpoints and an opaque, parseable optimistic-concurrency token through read, conflict, and write, for both filesystem and helper backends. - Keep compose and env file protection on the stack source root only. * fix(stacks): theme the Files & Volumes root switcher Replace the raw native select in the file-root switcher with the design system Select component. The native control did not honour the dark theme, so the panel rendered white with unreadable text. The themed Select gives a dark popover with grouped Volumes / Stack source labels and disabled items. * fix(stacks): contain the bind-root probe and de-taint the file-op error log Gate the volume-root bind probe's realpath/stat behind a compose-base containment check (mirroring the storage host-path probe) so they never run on an unvalidated host path; a source outside the compose dir is unreachable in the containerized deployment anyway and is reported non-accessible without touching the filesystem. Log the helper-backed file-op failure through a constant format string with sanitized arguments instead of an interpolated template literal. * fix(stacks): inline the bind-probe containment guard at the fs sinks The wrapped containment predicate was not recognized as a path barrier, so the bind probe's realpath/stat still flagged as uncontrolled-data-in-path. Inline the path.resolve + startsWith check directly at each filesystem sink (and re-check the resolved canonical before stat, so a within-base symlink that resolves outside the compose dir is also rejected). * fix(stacks): harden file-root lifecycle, upload race, and helper errors Address review findings on the Files & Volumes feature: - Invalidate the file-root allowlist on stack create/delete/import/from-git (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a stack deleted and recreated under the same name cannot serve the old stack's roots from the 15s cache. - Use the atomic exclusive write for a non-overwrite upload so a file created by another writer after the existence check is not silently clobbered. - Let the helper's real cd errno through and map permission failures to 403 consistently across list/stat/read/write/mkdir/delete/pathKind, instead of reporting EACCES as 404/500; pathKind no longer reports a permission-denied parent as absent. - Document the realpath-then-open TOCTOU as a known, pre-existing limitation of every file op (O_NOFOLLOW is not viable because config volumes legitimately contain symlinks); the bind root is contained to the compose dir and the op requires stack:edit. - Docs: drop a missing screenshot reference and correct the protected-file delete behavior (stack-root compose/.env cannot be deleted via the explorer). |
||
|
|
9ea2864d60 |
feat: per-stack storage inventory and portability guardrails (#1399)
* feat: per-stack storage inventory and portability guardrails Add a Storage tab to the stack Anatomy panel that derives a per-stack mount inventory (bind mounts, named/anonymous volumes, tmpfs, docker socket; read-only vs read-write; host-path existence, type, and owner) from the effective Compose model, and classifies the stack as Portable, Partially portable, Node-bound, or Unknown with the reasons behind it. - New GET /api/stacks/:stackName/storage route (stack:read, Community), served by an on-demand, non-persisted service that renders the effective model, probes within-stack bind sources (symlink-escape aware), and runs the deterministic portability classifier. - Extend the effective-model parser additively with a full per-mount inventory and service-level tmpfs, leaving the rule-facing binds/namedVolumes byte-identical for the existing preflight rules. - New anonymous-volume preflight finding. - Admin-visible "no recent snapshot" warning that reuses the existing hub-local snapshot-coverage endpoint, plus a static note distinguishing config snapshots from application-data backups. - Surface storage assumptions in the Stack Dossier markdown export. - Gate the tab behind a new compose-storage capability on both sides. * docs: phrase the Storage tab availability as current behavior Replace the "older Sencho version / until it is updated" wording in the Storage feature page with present-tense, capability-based phrasing. |
||
|
|
57a0856ffc |
feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)
* feat(stacks): per-stack environment inventory and secret-safe guardrails
Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.
Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.
The Environment tab is capability-gated so it hides on older remote nodes.
* fix(stacks): harden env-file reader against a stat-then-open race
Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.
* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service
Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.
Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
|
||
|
|
888f658a7a |
feat(files): move files and folders across directories in the stack explorer (#1373)
* feat(files): move files and folders across directories in the stack explorer Add a cross-directory move to the stack file explorer. Files and folders can be relocated either through a "Move to..." context-menu item that opens a folder-picker dialog, or by dragging an entry onto a folder node (or onto the root area to move it to the stack root). The backend reuses the existing rename endpoint: renameStackPath now resolves both ends through the leaf helper, so a symlink moves as the link entry rather than its target, and it guards against moving a directory into its own subtree. A cross-filesystem rename surfaces as a clean 409 instead of a 500. Protected root files (compose / docker-compose / .env) stay put. Moving the open file, or a folder containing it, deselects the viewer; a move that would discard unsaved edits is blocked with a clear message. * fix(files): fold case in move guards and keep the move dialog open on failure Harden the cross-directory move against case-insensitive filesystems and fix a dialog dismissal edge: - Protected root files (compose / docker-compose / .env) were gated by an exact, lowercase name match. On a case-insensitive filesystem a request like COMPOSE.YAML resolves to the real compose.yaml and slipped past the gate, so a protected file could be moved out of the stack root via the API. The gate now folds case on case-insensitive platforms; Linux stays case-sensitive, where a differently-cased name is a distinct, unprotected file. - The directory-into-descendant guard compared resolved paths case-sensitively, so a source supplied with non-disk casing skipped the guard and fell through to an opaque OS error (500) instead of a clean 400. The comparison now folds case the same way. - The move dialog closed after awaiting the move regardless of outcome, so a blocked move (unsaved edits) or a failed move dismissed the picker as if it had succeeded. The shared handler now reports success and the dialog only closes on an actual move. |
||
|
|
0066887cee |
feat(security): reflow the node Security page for mobile (#1372)
* feat(security): reflow the node Security page for mobile Below the md breakpoint the Security page now reads as a phone surface instead of a squeezed desktop, with no change to the desktop layout. - Masthead stat cluster moves into a full-width 3-cell strip (critical / high / last scan) below the tab strip, since the masthead hides its inline cluster on a phone. - The eight-section tab strip becomes a horizontally scrollable mono row with an edge mask-fade and a cyan underline on the active tab; every section stays reachable by scroll. - The six totals render as a 3x2 hairline-divided grid instead of the 640px-wide rail that forced a horizontal scroll. - The Images tab becomes a filterable, scrollable list (severity dot, truncated ref, freshness, critical/high count tags) with a chip row, in place of the desktop table. - A freshness footer band states scan recency and scanner version. All mobile treatment is gated by useIsMobile() or max-md: utilities, so the desktop view is byte-identical. Charts are reused full-width. * feat(security): make the mobile Security page a bespoke masthead-led screen On a phone the Security page now drops the global top bar and leads with its masthead (the notifications + more-menu cluster moves into the masthead's right slot), matching Home and Fleet so the mobile shell is continuous across pages. The view is reclassified bespoke and rendered through the masthead-led path; the desktop layout is unchanged. The mobile "more" menu now lists every destination instead of omitting the bottom-tab views, so the same menu opens the same set on every screen rather than changing contents from page to page. |
||
|
|
3d39d856a3 |
feat: chart-led Security overview with sortable Images and History tables (#1364)
* feat: chart-led Security overview with sortable Images and History tables Refine the Security page around the existing design system and add the data the dashboard needs. - Overview leads with four charts (30-day risk trend, severity donut, top exposed images, findings by type); the signal-rail counts become a secondary summary, and the scanner and deploy-enforcement posture follow. - Images becomes a recessed table with search, a severity filter, sortable columns, a last-scan column, and inline scan actions; the findings cell is clickable into the scan sheet, and the per-row cursor tooltip is dropped where the columns already carry that information. - Policies puts deploy-enforcement first, collapses the policy packs into an accordion, and uses the standard primary button for Add policy. - Suppressions and acknowledgements move their titles and Add buttons outside the cards, matching the Fleet tab layout. - History switches from the detail sheet to an inline table (search, sortable columns, two-scan compare, pagination); the now-unreachable scan-history overlay is removed. - Add GET /api/security/overview/trend, a node-scoped daily critical/high rollup backing the risk-trend chart. - Extract the shared image-scan hook and the severity classifier, and harden the overview data fetch so a malformed non-critical response can never read as a clean security state. * fix: treat malformed Security responses as errors, not empty or clean states Address an independent review of the data-fetch paths so a 200 with an unexpected shape can never read as a benign "no findings" view. - SecurityView: validate that the image-summaries body is a scan-summary map; an unexpected shape now sets the error state instead of an empty map. Isolate the trend fetch in its own self-catching promise so a transport failure on the non-critical chart can no longer poison the overview or summaries error state. - useImageScan: only a "completed" poll counts as success (a malformed or unknown status now throws), and a failed post-scan summaries refresh is logged instead of silently dropped. - HistoryTab: a 200 whose body lacks an items array is treated as an error, not an empty "no completed scans" list. |
||
|
|
2a4955f56d |
feat: add dedicated Security page and policy-pack foundation (#1362)
* feat: add dedicated Security page and policy-pack foundation Bring vulnerability scanning, scan history, suppressions, Compose risks, secrets, policy packs, and scanner setup into one node-scoped Security command center instead of scattering them across Resources and Settings. - New top-level Security view with Overview, Images, Compose risks, Secrets, Policies, Suppressions, History, and Scanner setup tabs (status masthead + signal rail; controlled tabs with deep-link support). - Backend: GET /security/overview rollup and GET /security/policy-packs static catalog (auth-only, Community). DatabaseService gains an uncapped scan-status count and a node-eligible block-policy count, and getImageScanSummaries now projects secret and misconfig counts. - Reuse existing surfaces: the scan-history sheet, the control-governed suppression and acknowledgement panels, and the scan-detail sheet (now with an initial-tab prop so it opens on the matching finding type). - Extract a shared SeverityBadge (from Resources) and a TrivyManager (from Settings) so both surfaces render identical controls. - Resources "Scan history" now links into the Security page History tab. - Docs for the new Security surface and tests for the new endpoints, helpers, nav wiring, and tabs. * refactor: consolidate scanner and policy management onto the Security page Remove the Settings "Vulnerability Scanning" section now that the Security page covers the same ground, with every option preserved: - Scanner install / update / uninstall / auto-update live on the Scanner setup tab (TrivyManager). - Scan policies, the honor-suppressions toggle, and the replica managed-by-control / demote controls move into a new ScanPolicyManager on the Policies tab (paid; Community sees only the policy-pack catalog). - CVE suppressions and acknowledgements remain on the Suppressions tab. Wiring removed: the registry section and the now-empty Security settings group, the SectionId, the SettingsSectionContent case and the isPaid prop it was the sole consumer of, and SecuritySection itself. The dashboard configuration-status "Vulnerability scanning" row now navigates to the Security page Policies tab. Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept to the relevant Security page tabs. * fix: harden Security page scanner refresh, policy-load errors, and secret-only badges Address independent-review findings on the Security page: - Scanner setup now refreshes Trivy state when the active node changes, so the displayed scanner status matches the node TrivyManager's actions target (both follow x-node-id). Previously, switching nodes on the tab left stale state. - ScanPolicyManager surfaces an explicit error state on a failed policy fetch instead of falling through to a false "No scan policies configured". - The shared SeverityBadge and the Images findings column no longer label a scan "clean" when it has secrets or misconfigurations but no CVE severity (highest_severity is derived from vulnerabilities only); they show a "Findings" state and the secret/misconfig counts instead. - The Overview enforcement note points to the Policies tab, not the removed Settings section. - The History tab auto-opens the scan-history sheet only on a deep-link (mount with the History tab active), not on every manual tab selection. Adds tests for the badge secret/misconfig state and the policy-load error state. |
||
|
|
77f1611971 |
feat: Compose Network Inspector and exposure intent guard (#1360)
* feat: add Compose Network Inspector facts engine Render a stack's authored effective model and pair it with the live Docker snapshot to derive per-stack networking facts: project networks with external and internal flags, service-to-network membership and aliases, published ports with host-binding scope, network_mode, and extra_hosts, plus runtime drift (runtime-only attachments, foreign networks, and declared-but-unused or missing networks). Extend the effective-model parser with service network membership, extra_hosts, and label keys (key names only, never values), and add a key-space normalized network model with adapters from both the rendered model and the raw declared compose so the Inspector and drift share one comparison. Expose GET /api/stacks/:stackName/networking: advisory and read-only, it renders the authored model only and never returns or logs raw stderr, env values, or label values. * feat: store and edit per-stack and per-service exposure intent Add a stack_exposure_intent table (intent values constrained by a CHECK, unique per node, stack, and service) with DAO methods to read, upsert, clear one row, and clear all rows for a stack. The classification is stored independently of the generated networking facts so a later mismatch stays detectable; service rows are kept separately from the stack-level row (service ''). Expose GET and PUT /api/stacks/:stackName/exposure: GET requires read access, PUT requires edit access and validates the intent against the allowed set. Sending intent null clears that row, returning the scope to unset so a service inherits the stack intent again. Intent rows are cleared when the stack is deleted and when the owning node is removed, so a later same-named stack never picks up stale classification. * feat: add exposure-aware Compose Doctor findings Feed the Compose Doctor's effective-model context with the stored exposure intent (resolved into a stack-level value plus per-service overrides) and the dossier's documented access-URL ports, read fail-soft so a metadata read error skips these checks rather than failing the preflight. Add five deterministic findings on top of that context: - a service classified internal or same-node that publishes a host port (same-node tolerates a loopback bind), - a sensitive database or admin image published on all interfaces, - a port-publishing stack with no exposure intent set, - a published port not reflected in the documented access URLs, - reverse-proxy labels with no documented URL or reverse-proxy intent. The rules stay pure functions over the preflight context; the registry completeness test pins the new rule set. * feat: detect compose network drift in the drift ledger Extend the spatial drift engine with two network-level findings: a running container attached to a stack-owned or foreign network that compose does not declare (one finding per service), and a declared network that no running service uses or that is absent from the runtime (one stack-level finding, every network named by its resolved runtime name). The comparison reuses the same helper the Network Inspector uses, so the two surfaces never disagree. Network drift runs only when the stack has running containers and the runtime is reachable, preserving the existing missing-runtime, parse-error, and unreachable behavior. The findings persist through the existing drift ledger and surface on the Drift tab, which now labels the two new kinds. * feat: link a Docker network back to its owning stack Add a cross-component open-stack event and make the owning-stack badge on a managed network in Resources a link: clicking it loads that stack on its node and opens the editor, reusing the existing fleet navigation. A latest-ref keeps the window listener current without re-subscribing each render. Image and volume badges are unchanged; only a managed network opts in via the new optional handler. * feat: add the Networking tab to the stack detail panel Add a capability-gated Networking tab that reads the per-stack networking facts and exposure intent. It shows the project networks (with external, internal, and created-by-stack flags), per-service network membership and aliases, published ports with their host-binding scope, network_mode and extra_hosts, and runtime drift, degrading to the declared model when the runtime is unavailable. Users can classify the stack and each service (internal, LAN, reverse proxy, public, and so on) or clear a row to inherit; the controls are read-only when the user cannot edit, and a broken exposure response never tears down the facts view. A new compose-networking capability is added to both registries so older nodes hide the tab, and the tab cross-links to the Doctor for the deploy and security findings. * docs: document the Compose Networking tab Add a feature page covering the Networking tab: the network facts, published ports and host bindings, the exposure-intent classification and inheritance, the exposure-aware Doctor findings, runtime drift, and a troubleshooting section. Register it in the docs navigation next to Compose Doctor. * feat: add a redacted network summary to the Stack Dossier export Append a network exposure section to the dossier Markdown: the stack and per-service exposure intents, the networks with their external and internal flags, and each service's published ports with their binding scope. It carries only names, intents, port numbers, and scope, never an env value or a label value. The summary is fetched only when the user exports (copy or download), so opening the panel costs nothing, and it degrades to omitting the section when the data is unavailable. The whole-fleet dossier export collects the same summary per stack, rethrowing the unauthorized sentinel like the sibling loaders. * feat: add a Fleet networking filter for exposure and drift Add a per-node networking summary that classifies a node's stacks as exposed (a host port published beyond loopback), unknown-exposure (publishes ports with no exposure intent set), or network-drift. It reads each stack's compose with the light dependency parser and one Docker snapshot, so it stays cheap across a node's full stack set, and it skips drift when the runtime is unreachable rather than inventing it. Serve it node-locally at GET /api/networking/summary, and aggregate it fleet-wide at GET /api/fleet/networking-summary: the hub computes its own summary in-process and reaches each remote through its node-local route, degrading an unreachable or older node to a skip. Because the aggregate lives under the proxy-exempt /api/fleet prefix it is never wrongly proxied. The Fleet overview gains a networking filter chip backed by that aggregate, fetched fail-soft and detached so it never gates the grid. * fix: spin the Networking refresh button while it reloads The refresh button silently refetched the same data, so a click gave no feedback. Track a refreshing state and spin the icon while the load is in flight, disabling the button, matching the Compose Doctor preflight button. * fix: apply effective per-service exposure intent to unclassified checks The "unclassified exposure" decisions only consulted the stack-level intent row, so a service classified directly (with no stack row) was still reported as unclassified, and a service explicitly marked unknown over a classified stack was missed. Both the exposure-unclassified preflight rule and the networking summary's unknown-exposure bucket now resolve the effective intent per publishing service (service row overrides stack row), matching the precedence already used by the exposure-internal-published rule. * fix: resolve drift network names via the compose top-level name When a compose file sets a top-level name:, Docker prefixes resource names with that project name instead of the stack directory. The light dependency parser dropped name:, so network-drift normalization compared runtime networks against directory-prefixed names and reported false network-undeclared / network-missing findings. Carry the parsed project name through DeclaredCompose and use it when normalizing declared networks for drift, while still filtering containers by the stack directory. |
||
|
|
f253276303 |
fix: make published port links open reliably (#1359)
* fix: make published port links open reliably Container published-port links now render as real anchors that open on desktop and mobile, replacing ad hoc window.open calls. A shared service URL builder centralizes host resolution (configured host, remote node API host, or the browser host, with no browser fallback for unreachable remote nodes), protocol selection (HTTPS for port 443), and known app sub-paths (Plex opens its web path). The container port mapping itself is the link, with a Copy URL action beside it. The stack Open App menu and the anatomy panel footer use the same builder, and the menu only offers Open App when a reachable URL can be built. * fix: skip UDP ports and scope known-app paths to the container port Two follow-ups to the published-port links: - The known-app path (Plex web sub-path) was borrowed from the published host port even when the container port was known and unregistered, so a non-Plex service published on host port 32400 wrongly inherited it. The container-port lookup now wins when known; the published-port lookup stays a fallback for the menu and anatomy footer, which only have the host port. - UDP ports could surface as HTTP links. The backend now carries the port protocol through both container-mapping paths and skips UDP when choosing the main web port (extracted as selectMainWebPort), and the container card filters UDP before selecting a port to link. |
||
|
|
e3944295f2 |
feat: link container images to registry and source metadata (#1358)
* feat: link container images to registry and source metadata Turn image references on the stack surfaces into actionable links so an operator can review what an image ships before approving an update. A new pure helper maps a reference to its registry page (Docker Hub for official and namespace images, the owner profile for GitHub Container Registry) and never guesses a link for unknown or private registries. A shared dropdown adds copy-image-reference plus source, homepage, documentation, and revision links read from the image OCI labels via a local inspect, so they need no internet access and only appear when the image ships them. Version and non-commit revisions render as plain text. The menu is wired into the stack header image row, each per-container health card, and the update-readiness cards. * fix: invalidate image-source inspect token synchronously on image change The request token that drops a stale image inspect response was bumped only in a passive effect cleanup, which runs after paint. A response for a superseded image could resolve in the gap after the image-id change committed and still pass the token check, writing stale source labels into the new menu. Move the invalidation to a layout effect so the token is bumped during commit, before any network response can interleave. Add a regression test that holds the first inspect open, changes the image id, then resolves the stale response and asserts it is discarded. |
||
|
|
48cebf9501 |
fix: bind deploy progress, request, and health gate to the captured node (#1357)
* fix: bind deploy progress, request, and health gate to the captured node A deploy/update/install/git-apply re-read the active node from localStorage independently at three points: the progress WebSocket at mount, the POST at call time, and the health-gate poll. If the active node changed between the click and any of those, the operation, its live output, and its health verdict could target different nodes, and the socket and POST splitting across nodes broke output streaming. Capture the operation's node once when it starts and thread it through every leg. A new nodeId option on apiFetch overrides the active-node read, the progress terminal takes a nodeId prop for its socket URL, the health gate polls on the captured node, and a failed gate records its recovery entry only on the node it ran on. The surface, the request, and the gate now always agree. * fix: scope failed-gate recovery to the file list's node and harden node targeting Addresses review findings on the captured-node binding: - Track the node the stack file list was fetched for (filesNodeId) and record a failed gate's recovery entry only when it matches the gate's node. This closes a race where switching back to the gate's node could match a same-named stack from the previous node's still-loaded list before the new list lands, keying the record to the wrong file and blocking the correct one. refreshStacks now carries a sequence token so an out-of-order resolution cannot leave files and filesNodeId inconsistent. - Make an explicit apiFetch nodeId authoritative over a caller-supplied x-node-id header. - Add the missing stack-logs nodeId cases (null, and active-node fallback) to the terminal tests. |
||
|
|
38aabe7064 |
feat: health-gated updates and rollback readiness (#1354)
* feat: classify stack deploy and update failures with suggested next actions Failed deploy and update responses now carry a failure classification (cause category, headline, and suggested next step) derived from the compose error output. The recovery panel and chip render the classification and include it in copied diagnostics, and gateway-style failures surface as a node-unreachable cause. * feat: add update and rollback readiness reports for stacks Before a manual update, Sencho now shows an advisory readiness verdict computed from the stored preflight result, open drift findings, live container health, the pending image change, the rollback backup slot, and node disk headroom. The Stack Dossier gains a rollback readiness section that states what a rollback can restore and explicitly discloses that volume and bind-mounted data are not covered. Toolbar and sidebar updates now share one update path, and admins can create a fleet snapshot from the readiness dialog before updating. Nodes that do not advertise the capability keep the direct update flow. * feat: observe stack health after updates with a post-deploy health gate After a deploy or update succeeds, Sencho now watches the stack for a configurable observation window and records a passed, failed, or unknown verdict: containers must stay running, healthchecks must report healthy, and restart loops or disappearing containers fail the gate. The deploy panel shows the observation live and holds off auto-closing until the verdict lands, a failed gate surfaces the existing recovery actions including rollback, and the stack timeline records update started and gate verdict events. Scheduled, webhook, bulk, and git-source updates are gated the same way; rollbacks and installs are deliberately not. The gate is observational only and can be tuned or disabled per node under host alert settings. * docs: document health-gated updates and rollback readiness New operator page covering the update readiness dialog, the post-update health gate and its settings, the rollback readiness disclosure, and classified failures, with cross-links from the atomic deployments and deploy progress pages. The API reference gains the readiness and health-gate endpoints, the healthGateId success field, and the failure classification schema on deploy and update error responses. * feat: withhold the success verdict while the health gate observes An update used to show a green Succeeded that a failed health gate then contradicted moments later. The deploy modal now reports Verifying health while the gate observes, shows success only when the gate passes, and makes a failed or unknown gate the headline result; success toasts soften to a verifying message while a gate runs. The mobile recovery card groups its actions behind one bottom-right Take action menu so it stays compact on a phone, with the classified cause still visible on the card. A successful image update now also counts as the last known-good marker in rollback readiness, and the docs gain screenshots of the readiness dialog, gate states, dossier section, and settings. * fix: harden log format strings and the env existence path check Log calls that interpolated the stack name into the console format string now use constant format strings with placeholder arguments, and envExists validates path containment inline at its filesystem access, matching the established patterns used elsewhere in the same files. * test: adapt deploy modal success specs to the post-deploy health gate The deploy feedback modal now withholds its success verdict while the health gate observes the new containers, showing "Verifying health" until the gate passes. The two success-path E2E tests waited for "Succeeded" within the gate's 90s default window and timed out. Shorten the observation window to the 15s minimum for these tests via the settings API, assert the verify-then-succeed sequence the modal actually renders, and restore the default window afterward so the test value does not leak into later runs. * fix: serialize health gate polling and harden gate observation Address race conditions in the post-update health gate found in review. Backend: the gate poller used setInterval, so a Docker observe slower than the 5s tick could overlap the next poll and corrupt the restart and missing-container accounting, and a wedged socket could leave a poll pending forever. Polling is now single-flight: each cycle self-schedules the next only after it settles, and the observe is bounded by an 8s timeout so a hung probe counts as a poll error and resolves the gate unknown after three in a row. Frontend: the gate poller could overlap requests, letting a slow earlier "observing" response overwrite an already-applied terminal verdict. It is now single-flight with a terminal latch, so a late response can never roll the UI back from passed or failed. Also reject a non-digit nodeId on the snapshot coverage route instead of letting parseInt coerce it, document that turning off the deploy progress panel opts out of the live gate UI while the gate still runs server-side, and add gate-coverage tests for the webhook, git source, and auto-update apply paths plus the new single-flight, observe-timeout, and recovery cases. |
||
|
|
739bbf990e |
feat: flag documentation drift in the stack dossier (#1349)
* feat: flag documentation drift in the stack dossier Warn in the Dossier tab when a port written into a stack's access_urls is not published by the stack's compose, so operator documentation stays aligned with what Sencho can observe. The check is deterministic, read-only, and frontend-only: it compares ports parsed from access_urls against the published ports the Anatomy panel already shows, never interprets prose, and stays quiet for port-less URLs, scheme-default ports (:80/:443), and ports published through a variable, to avoid false positives. Community tier, no gating. * test: pin doc-drift handling of bare hosts and mixed variable ports Make two deterministic-drift behaviors intentional and regression-proof after review: a scheme-less single-label host (plex:32400) is not checked, since it cannot be told apart from a plain note (add a scheme to opt in), and a stack mixing a variable-published port with fixed ports suppresses the whole check. Adds tests for both, a clarifying code comment, and a docs note with the http:// workaround. No behavior change. |
||
|
|
52ff0725f4 |
feat: add Compose Doctor preflight checks for stacks (#1348)
* feat: add Compose Doctor preflight checks for stacks Add an on-demand, advisory preflight that renders a stack's effective Compose model with `docker compose config` and runs a registry of deterministic checks before deploy, surfacing findings grouped by severity (blocker, high, warning, info) with a remediation for each. Findings cover unset env vars, host-port conflicts on the node, broad 0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket, privileged and host networking, moving image tags, missing restart policy and healthcheck, Swarm-only deploy fields, missing external networks or volumes, and container_name collisions. The report is node-scoped and stored as the last run per stack, and the route auto-proxies to the active node so a remote stack is checked on the node that owns it. A new Doctor tab on the stack detail panel runs preflight and shows the grouped findings, with a severity dot on the tab when the last run has blocker or high findings. The tab is gated on a compose-doctor capability so older nodes hide it. No environment value is ever stored, returned, or logged: only env key names and structural facts are read, and render failures surface a generic message or the missing required-variable names, never raw stderr. * fix: scroll the stack tab strip when its tabs overflow Adding the Doctor tab can push the per-stack Anatomy tab strip past the panel width on narrower layouts. Make the tab row scroll horizontally with subtle edge fades that appear only while there is more to scroll in that direction, so a panel wide enough to show every tab is unchanged. * fix: add clickable arrows and wheel scroll to the stack tab strip Hiding the scrollbar left mouse users with no way to scroll the overflowing tab row: a vertical wheel does not move a horizontal overflow and native rows do not drag-scroll. Replace the passive edge fades with clickable chevron arrows shown only when the row overflows that edge, and translate a vertical wheel over the row into horizontal scroll. * fix: inline the path-injection barrier in renderConfig CodeQL's path-injection check does not credit the wrapped isPathWithinBase helper as a sanitizer, so move the containment check inline at the spawn cwd sink, matching the canonical barrier used elsewhere in the codebase. Behavior is unchanged: the resolved stack directory must be contained in the compose base and may not be the base itself. * fix: hoist the compose-config spawn into the path-barrier scope The earlier inline barrier sat in a different scope than the spawn cwd sink (separated by the Promise-executor closure) and used a compound guard, so CodeQL did not credit it. Use the exact canonical startsWith barrier and hoist the spawn into the same scope as the check. Behavior is unchanged: the executor runs synchronously in the same tick as the spawn, so handlers still attach before any event can fire. |
||
|
|
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. |
||
|
|
57fe430db8 |
feat(stacks): add Stack Dossier tab with operator notes and Markdown export (#1326)
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It shows a read-only summary auto-derived from the stack's Compose anatomy (services, ports, volumes, network, restart policy, env file, source) plus an editable form for the context Sencho cannot infer: purpose, owner, access URLs, static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and custom notes. Notes persist per stack and per node in a new stack_dossiers table, reached transparently through the remote-node proxy so a remote stack's dossier round-trips to the node that owns it. Reading a dossier needs stack read permission; saving needs stack edit. The tab exports a single Markdown document combining the generated facts and the operator notes, with copy-to-clipboard and download actions; env values are never exported, only variable names and counts. Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is removed since the Dossier export supersedes it. * fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete A dossier endpoint validated the stack name but not that the stack exists, so an editor could PUT a dossier for a name with no stack, leaving an orphan row that a later stack of the same name would inherit. Require the stack to exist (existing requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise. Also clear a node's stack_dossiers rows when the node is deleted, alongside the other node-scoped cleanup, so removing a node leaves no orphan dossiers. Docs: scope the "no secret exported" statement to the generated facts (which only ever carry variable names and counts) and clarify that operator notes are exported exactly as written. |
||
|
|
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. |
||
|
|
13b6acab16 |
feat(stacks): copy stack anatomy as Markdown (#1323)
* feat(stacks): copy stack anatomy as Markdown Add a "copy md" action to the Stack Anatomy panel that exports the parsed compose summary as a deterministic Markdown document: services, ports and volumes tables, restart policy, env file with its variable count, missing variables, network, and source. Useful for pasting a stack's wiring into a README, a Git repository, Obsidian, BookStack, or a support thread. The export carries only env variable names and counts, never the values stored in .env files. * fix(stacks): escape backslashes in anatomy Markdown table cells escapeCell escaped pipes but not the backslash itself, so a backslash in a cell value (for example a Windows path) could defeat the pipe escaping and break the rendered table row. Escape the backslash first, then pipes, then collapse newlines. Adds a regression test. * fix(stacks): correct git source attribution and CR handling in anatomy export Gate the Anatomy panel's git source on its owning stack so a slow git-source response for a previously selected stack can no longer render or be exported under a different stack. Also collapse lone carriage returns (not just CRLF and LF) in Markdown table cells so a stray CR cannot break a table row. |
||
|
|
d03d97d964 |
fix(nodes): close capability-gating gaps in node compatibility (#1261)
* fix(nodes): close capability-gating gaps in node compatibility Vulnerability scanning is now gated correctly on whether the active node advertises support for it: - A node without the Trivy binary stops advertising the scanning capability. Previously the capability was toggled only on a state change, so a node that booted without Trivy kept advertising scanning it could not perform. - The control node's own capability list now reflects features disabled at runtime, matching what it advertises to peers. - The scan history surface shows a clear "not available on this node" card, with its header actions hidden, instead of attempting a request that fails. A node's version and capability metadata now refreshes immediately after a connection test or a completed update, rather than waiting out the cache. Capability gates fail closed to the unavailable card when a node's metadata request errors, instead of staying open until the next fetch. Adds a test that fails if the frontend and backend capability lists drift, plus coverage for the metadata error path, the runtime-disabled local meta, the scanning capability sync, and the metadata cache invalidation paths. * fix(nodes): refresh node metadata client-side after a connection test A connection test dropped the server-side metadata cache, but the dashboard kept its own cached copy until the client TTL expired, so version and capability gates could stay stale in the browser. The test now forces a client-side metadata refresh for that node, so the version pill and gates reflect the node's current state immediately. Also strips any URL userinfo before logging the metadata fetch target, and makes the scanning-capability detection test deterministically exercise the no-binary disable path rather than depending on whether the runner has Trivy. |
||
|
|
5dea040ec8 |
fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream The deploy progress modal streamed compose output over a WebSocket, but the deploy itself was coupled to that socket in two ways that could break or silently abort a deploy: - The deploy request was gated on the progress socket connecting, so any upgrade failure (a reverse proxy blocking WebSocket upgrades, or the admin-only stream rejecting a scoped deployer) left the modal stuck on "Connecting..." and the deploy never fired. - The backend terminated the running compose process when that socket closed, so minimizing the modal, navigating away, or a network blip aborted an in-flight deploy. Make the progress socket output-only: the deploy is owned by its request and runs to completion (or the existing command timeout) regardless of the stream. The modal now degrades to a "Live progress unavailable" state and still reports success or failure from the request result. Connect failures, drops, and a connect timeout all release the deploy instead of blocking it. Also route progress output per deploy: the frontend sends a correlation id on both the connectTerminal message and the deploy request header, and the backend keys progress sockets by that id so concurrent deploys from different tabs or users no longer cross-stream each other's output. Cap the in-memory parsed log rows so a very long deploy cannot grow the modal's state unbounded. * fix(deploy-progress): generate the deploy session id with a CSPRNG The per-deploy correlation id keys which WebSocket receives a deploy's live output, so a guessable id lets one authenticated client register a victim's id and read its compose output. It was built from Math.random() plus a timestamp, which is not cryptographically secure. Generate it with crypto.getRandomValues (128 bits, hex). That is the one Crypto member available in insecure contexts, so it still works over LAN HTTP where crypto.randomUUID is unavailable. * fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal Address review findings on the progress-stream routing: - Only an id-less connectTerminal registration may become the id-less fallback socket. Previously every connectTerminal (including keyed deploy modals) set the fallback, so a headerless operation (bulk update, rollback, or a legacy client) resolved via getTerminalWs() into another user's keyed deploy modal. Keyed sockets are now excluded from the fallback, and a socket that adopts a session id is removed from it. - The connect-timeout fallback now also flags the modal as "Live progress unavailable" instead of leaving it on "Connecting..." while the deploy runs. - Log only a short prefix of the deploy session id in developer diagnostics, not the full capability value. |
||
|
|
d8b6f8cf3b |
feat(stack-files): force-text override for misidentified binary files (#1215)
* feat(stack-files): force-text override for misidentified binary files
The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.
readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.
SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.
Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.
* fix(stack-files): guard the binary-override path against oversized files
The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.
Two reinforcing changes:
- Initial load now checks result.oversized before result.binary, so a
file that is both oversized and has binary bytes in the 8 KB probe
shows the Download panel rather than the binary panel. The size
signal stays in front of the operator and the override button never
surfaces for a file that cannot be safely opened inline.
- The handleForceText handler now respects result.oversized on the
refetch and transitions to the Download panel instead of clearing
isBinary and copying result.content ?? '' into Monaco.
Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.
Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
|
||
|
|
4964320f50 |
fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag PUT /api/stacks/:name/files/content previously did a blind write; two operators editing the same script lost one of the saves with no warning. The compose-file editor already had mtime optimistic concurrency (PR #1183); this brings the file-explorer write path to the same shape. GET /files/content now also returns mtimeMs and sets a weak ETag header derived from the stat. The matching PUT reads If-Match, asks FileSystemService.writeStackFileIfUnchanged to compare against the live mtime, and returns 412 PRECONDITION_FAILED with the current content and mtime when the stale-write check fails. Successful writes echo a fresh ETag so the client can pin the next save without re-GET. readStackFile and writeStackFileIfUnchanged each open the file once and stat+read through the same handle so the mtime returned to the client matches the bytes that were sent, even if the file is replaced between the two operations. PUT without If-Match still succeeds (backward compatibility with scripted clients that do not roundtrip the ETag). FileViewer now sends the loaded mtime on save, updates its local mtime from the success response, and on FileConflictError adopts the server snapshot as the new baseline so the user's follow-up edit-and-save does not loop on the same precondition. * fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict Two follow-ups from code review on the prior commit: - writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is set and the target has been deleted. The caller was editing a file that no longer exists; silently writing the buffer to the void is wrong. The client adopts the empty snapshot as 'file is gone, start over' and the user keeps control of what to save next. - The FileViewer conflict handler no longer overwrites the user's typed buffer with the server snapshot. It updates the baseline so the next save sends the fresh mtime, then leaves the editor content alone. The user sees their edits, the Save button stays enabled, and a follow-up click applies their changes on top of the new server version without silently destroying what they typed. * fix(api): preserve default headers when caller supplies a headers field apiFetch built defaultOptions.headers by merging Content-Type, x-node-id, and the caller's headers, but then spread the unmodified fetchOptions over defaultOptions at the outer level. The spread overwrote the merged headers with the caller's bare headers, silently dropping Content-Type on every request that supplied any custom header. This was latent until the file-explorer save path started sending an If-Match header. The Express body parser refused the PUT without Content-Type, the route returned 400, the editor showed an error toast instead of the success toast, and the Playwright save assertion timed out. Destructure headers out of fetchOptions before the outer spread so the already-merged defaultOptions.headers survives. Add api.test.ts with four regression cases pinning Content-Type, the If-Match merge, x-node-id presence when active, and localOnly skip. |
||
|
|
c8b095b887 |
fix(stack-files): confirm before overwriting an existing upload target (#1204)
* fix(stack-files): confirm before overwriting an existing upload target Same-name uploads previously truncated the existing file silently. A user dragging a file with a name that matched an in-place file destroyed the original with no warning and no undo. The upload route now reads ?overwrite=0|1. When the flag is not set and the target already exists, the server returns 409 FILE_EXISTS and the original file is untouched. The frontend opens a confirm dialog and retries with overwrite=1 on the user's approval; cancel keeps the original. A new pathExists helper on FileSystemService performs the existence check through the same path-resolution barrier as the write so a malicious relPath cannot bypass the conflict check. UploadConflictError is exported so callers can distinguish the conflict case from generic upload failures without parsing error strings. * fix(stack-files): distinct DIR_EXISTS code, drop INVALID_PATH swallow in existence check |
||
|
|
82a4e94589 |
chore(stack-files): client-side path-traversal guard in stackFilesApi (#1190)
The backend already rejects path-traversal attempts through isValidRelativeStackPath, so the server side is safe today. Adding a client-side mirror is defense-in-depth: it shortens the failure loop (no wasted round trip) and protects against a future server-side regression that loosens validation. Adds isClientSafeRelPath in frontend/src/lib/stackFilesApi.ts mirroring the backend predicate (rejects absolute paths, drive letters, backslashes, NUL bytes, double slashes, and any segment that is `.` or `..`). Wraps every export that accepts a relPath / targetDir / fromRel / toRel argument with assertSafeRelPath, throwing a clear Error before the fetch is issued. 12 unit tests cover the predicate (POSIX accepts, traversal rejects, Windows drive letters, backslashes, NUL bytes, non-string inputs). Frontend suite stays at 288/288. |
||
|
|
cdb9cd414e |
feat(routing): redesign node cards to the System Sheet recipe (#1133)
Lift <RoutingNodeCard> primitive from audit §21 / DESIGN §9.13. Migrate the fleet adapter to consume it: collapse five inline state pills into a single nodeState prop, replace the k/v stat list with a mono meta line, drop the per-row HEALTHY chip in favor of a state dot, and add a real empty-state block with a primary CTA for every state. Compact density body swap stays inside the component, no -compact fork. |
||
|
|
e05099f2a1 |
fix(fleet-sync): make control-identity-mismatch sticky and surface in UI (#1117)
Treat 409 CONTROL_IDENTITY_MISMATCH from a replica as a non-retriable
failure instead of looping the same 409 through the 5-minute retry
service forever and silently writing identical failure rows.
Backend
- DatabaseService: add `sticky_error_code`, `sticky_error_expected`,
`sticky_error_got` columns to `fleet_sync_status` via an idempotent
migration. New methods setFleetSyncSticky, getFleetSyncStickyCode,
clearFleetSyncStickyForNode. recordFleetSyncSuccess clears the sticky
flag on a clean push. getFailedSyncTargets SQL adds
`AND sticky_error_code IS NULL` so the retry loop skips sticky rows.
- FleetSyncService.executePushToNode: short-circuits at the top when
sticky is set (covers event-driven pushResourceAsync calls). On a 409
with code CONTROL_IDENTITY_MISMATCH, records the failure once and
pins sticky with the expected/got fingerprints carried in the 409 body.
- routes/nodes.ts: new POST /api/nodes/:id/fleet-sync/reset-anchor.
Admin + paid + node:manage. Proxies POST /api/fleet/role/reanchor to
the peer with `{override:true}` using the stored Bearer node_proxy
token. On peer 200, clears every sticky row for the node so the next
push re-anchors and resumes replication. Distinct 502 / 504 responses
for peer-rejected / peer-unreachable so the UI can show a useful toast.
Frontend
- New lib/fleetSyncApi.ts + hooks/useFleetSyncStatus.ts. Polling hook
(30s visibilityInterval) skips fetch when !isPaid.
- NodeManager.tsx: destructive banner per affected node listing both
fingerprints, with `Reset anchor on peer` and `Remove node` buttons.
Hidden for community-tier users via empty hook data.
- FleetConfiguration.tsx (Fleet -> Status): read-only `Policy sync`
SummaryRow per remote node card. In sync / degraded / paused with
a tooltip; no action buttons (the action lives in NodeManager).
Tests
- fleet-sync-service.test.ts: 4 new cases for sticky-set on first
mismatch, short-circuit on subsequent pushes, null fingerprints,
and non-mismatch failures not setting sticky.
- database-fleet-sync-sticky.test.ts (new): 6 cases pinning the DB
contract incl. retry-loop SQL filter and migration idempotency.
- nodes-fleet-sync-reset-anchor.test.ts (new): 6 cases covering
happy path, peer 401 -> 502, peer unreachable -> 504, local-node
rejection, unknown node id, and community-tier 403.
Gate parity (Directive 30): the new POST .../reset-anchor enforces
requireAdmin + requirePaid + node:manage (matches the existing read at
GET /api/fleet/sync-status). UI banner + SummaryRow only render when the
hook returns data, which it only does for paid-tier authed users. No
existing tier-gate file moved; this is greenfield parity.
Auth audit: the peer's POST /api/fleet/role/reanchor route already uses
requireAdmin, which accepts the central's stored node_proxy Bearer
token because authMiddleware maps `scope === 'node_proxy'` to
`req.user = { username: 'node-proxy', role: 'admin', userId: 0 }`.
No widening required.
Backend tsc clean. Frontend tsc -b clean. 59 fleet-sync tests pass; full
backend suite green minus the pre-existing Windows-only file-lock flake
on filesystem-backup.test.ts that reproduces unchanged on main.
|
||
|
|
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. |
||
|
|
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). |