mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
1b96f3b9803f37ae6f120e1bb0181ae67f88751f
170 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1b96f3b980 |
feat: add a compact icon-only top navigation toggle (#1363)
* feat: add a compact icon-only top navigation toggle Add a browser-local "Top navigation labels" preference under Settings > Appearance. With it off, the desktop top navigation renders icon-only; each destination keeps an aria-label, gains a hover/focus tooltip, and stays reachable from the command palette. The setting defaults on, so current behavior is preserved, and the mobile navigation always keeps its labels. Also left-align the desktop nav (previously centered) and shorten the longest nav label from "Auto-Update" to "Update" so the bar scans faster. * feat: let the icon-only top nav be left or centered Add a "Top navigation alignment" preference under Settings > Appearance that appears only when top navigation labels are off. It places the icon-only bar against the left edge (the default) or centered. With labels on, the nav always stays left so the longer labels read from the edge. The choice is browser-local and persists per device. |
||
|
|
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. |
||
|
|
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. |
||
|
|
e20f1fe415 |
feat: add an inline deploy-progress style for the stack detail (#1355)
* feat: add an inline deploy-progress style for the stack detail Deploy progress gains a presentation choice under Settings > Appearance > Display: Modal (the default centered overlay) or Inline. In Inline style a compact status band on the stack detail shows the running operation, its elapsed time, the live phase, the latest output line, and the post-update health gate result. A "View output" button opens the full log modal on demand, a dismiss control clears the band, and the band auto-clears a few seconds after a clean completion. The live progress socket is lifted to an always-mounted owner so the band streams without the modal; the default Modal style is unchanged. Operations carry their node so a band never bleeds onto a same-named stack on another node. The stack detail's redundant "CONTAINERS" section heading is removed; the band reserves that vertical space. * fix: keep inline deploy progress reachable off the stack detail Review of the inline presentation found a gap: a failed operation, an App Store install, or navigating away leaves the inline session with no visible surface, since the band only renders on the operation's own stack detail. Restore the minimized pill as the inline fallback, shown only when the band is not covering the session, so there is always a click-through to the log without ever overlapping the band. Closing the modal for a failed op now ends the session (the band has stepped aside) instead of only hiding it. Also document the unsupported mid-operation style switch, and refresh the deploy-progress, settings, appearance, and app-store docs for the renamed "Deploy progress" setting and the Modal/Inline choice. |
||
|
|
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. |
||
|
|
d369b03a38 |
feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions Add a backend idle-output backstop that stops a deploy/update compose step that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a hung image pull surfaces a fast failure instead of spinning indefinitely. Surface failed, timed-out, and stalled operations with recovery actions on the stack page: a desktop chip plus popover menu and an inline mobile card offering retry, restart, roll back (when a backup exists), refresh state, and copy diagnostics, all gated by deploy permission. The streaming deploy/update progress modal is now on by default and warns when output goes quiet. Container state is refreshed after a failed or stalled operation, and the UI never sits in an indefinite spinner. * fix: harden rollback against policy-blocked file mutation and refine recovery Address review findings on the stalled-update recovery work: - The rollback route restored backup files before running the policy gate, so a policy-blocked rollback could leave the on-disk config rolled back while the deployed containers were unchanged. Snapshot the current files first and revert them when the gate blocks; if that revert itself fails, escalate it on the persistent alert feed since the 409 is already sent. - Refresh container state after a successful manual rollback (rollback redeploys), without mis-recording a refetch failure as a rollback failure. - Suppress the stalled-output warning once live progress is unavailable. * test: mock snapshotStackFiles in the atomic-deploy rollback route tests The rollback route now snapshots stack files before restoring a backup, so its FileSystemService mock needs snapshotStackFiles. Without it the mocked call threw and the route returned 500, failing the success-path rollback assertions. |
||
|
|
8302048bc4 |
test(mobile): declare per-view mobile treatment and harden the visual gate (#1332)
* test(mobile): single-source map for per-view mobile treatment Declare how every top-level view behaves on a phone in one place: MOBILE_TREATMENTS is a Record<ActiveView, ...>, so adding a new view without classifying it (bespoke / responsive / desktop-only / detail) fails the type check. BESPOKE_MOBILE_VIEWS is derived from it instead of hand-maintained, and a unit test keeps the two in lockstep and pins the current bespoke set so a change is deliberate. EditorLayout consumes the derived set; behavior is unchanged. * ci(visual): run the desktop-unchanged gate in its own job Split the visual-regression spec into a dedicated Playwright "visual" project, excluded from the default chromium project the functional E2E job runs, so a missing or platform-mismatched baseline can no longer fail every PR. Add a Visual Regression workflow: a compare job gates PRs into main against committed baselines (and skips with a warning until they are seeded), and a manual seed job regenerates the baselines on the Linux runner and commits them to a feature branch (refusing main). Baselines are platform-specific, so they must be produced on the runner rather than locally. Drop the stack-detail view from the gate: a fresh CI app has no stack to open and its live log stream is not deterministic; the shell plus the four content views still catch a desktop base-class regression. |
||
|
|
928a3a8343 |
feat(mobile): bespoke phone layouts for dashboard, fleet, schedules, and settings (#1330)
* feat(mobile): masthead-led dashboard and 5-tab bottom nav on phones On phones (below the md breakpoint) the dashboard now renders a bespoke, masthead-led layout instead of the reflowed desktop workspace: - A status masthead leads with the overall system-health verdict, the node, and a live summary (stack counts, last sync, a "metrics stale" marker when polling stops). - A CPU hero card with a sparkline, then a memory / disk / network strip with threshold-colored bars, then a tappable stack-health list. - The bottom tab bar gains a Home tab (Home / Stacks / Fleet / Sched / Settings); the global top bar is dropped on this screen, with notifications and a "more" menu rehomed into the masthead. The health-verdict logic is extracted into a shared helper so the phone masthead and the desktop health bar read from one source, with unit tests. All changes are scoped below the md breakpoint or rendered only on the mobile shell; desktop layout is unchanged (verified against the desktop snapshot gate). * feat(mobile): bespoke fleet glance and node detail on phones On phones (below the md breakpoint) the Fleet view now renders a bespoke, masthead-led layout instead of the reflowed desktop workspace: - A fleet masthead leads with the overall fleet-health verdict and a running / cpu / mem summary band, then a list of node cards. The local node is marked with a cyan rail and a "you are here" tag; offline nodes are dimmed. - Tapping a node opens a full-screen node detail: state pill, resource bars (cpu / mem / disk), the stacks running on that node, and an Inspect action that switches to the node. Operators with the right permissions also get a Drain (cordon) action. - The screen polls the fleet overview every 30 seconds; the global top bar is dropped here, with notifications and a "more" menu in the masthead. All changes are scoped below the md breakpoint or rendered only on the mobile shell; desktop layout is unchanged. * feat(mobile): bespoke schedules and settings screens on phones On phones (below the md breakpoint) Schedules and Settings now render bespoke, masthead-led layouts instead of the reflowed desktop workspace: - Schedules: a "next up" glance leading with the next run time and countdown, then upcoming runs grouped by day with a per-action status dot and target. It is read-only on mobile; creating and editing schedules stays on desktop. - Settings: a grouped-card list of every reachable section; tapping one opens it full-screen with a back affordance and a section masthead. The section content itself is the same as on desktop. The settings section switch, lazy-loaded section chunks, and tier gating are moved into a shared component so the desktop and mobile screens render the same section content from one place. The global top bar is dropped on both screens, with notifications and a "more" menu in the masthead. All changes are scoped below the md breakpoint or rendered only on the mobile shell; desktop layout is unchanged. * fix(mobile): show notifications and more-menu on the stack detail header The full-screen stack detail on phones drops the global top bar, but its header was missing the notifications bell and the "more" navigation menu that the other mobile screens carry in their masthead, leaving no way to reach notifications or other destinations while viewing a stack. Render the same header-actions cluster in the detail header (and the loading placeholder), next to the back affordance. Desktop is unaffected. |
||
|
|
e8f271f5f6 |
feat(ui): make the core stack flow usable on mobile (#1327)
* feat(ui): make the core stack flow usable on mobile Below the md breakpoint the app collapses to a single full-width column: the stack list is full-screen, tapping a stack opens a full-screen detail with a Health / Logs / Compose segmented control (Logs first) and a back button, and a bottom tab bar switches Stacks, Fleet, Schedules, and Settings. Compose is read-only on a phone with a prompt to edit on desktop. Desktop (md and up) is unchanged: the mobile shell is gated behind a useIsMobile hook plus max-md/md variants, and the stack-detail blocks are shared with the desktop two-pane view so it renders identically. Also generalizes the unsaved-changes guard so leaving a dirty editor (back, tab bar, hamburger) prompts before discarding; adds 44px touch targets on list rows, filter chips, and actions; makes log and shell modals full-screen on mobile; and offsets toasts and the deploy pill above the bottom tab bar. * fix(ui): keep mobile nav in sync when opening views from outside the bottom bar On a phone the sidebar activity actions, the node switcher's Manage Nodes, the profile Settings entry, and the dashboard configuration links set the active view without flipping the mobile surface to content, so the user stayed on the stack list and never saw the destination. Route these through the mobile-aware navigation and settings helpers (a no-op on desktop). |
||
|
|
9d3055049f |
fix(ui): center create-stack label and drop duplicate plus on mesh CTA (#1310)
The Create Stack button left a redundant `mr-2` on its Plus icon. The Button base already applies `gap-2`, so the margin doubled the spacing and pushed the centered icon-plus-label block right of center. Removing `mr-2` restores the single correct gap. The meshed-state routing CTA rendered both a Plus icon (via ctaIconFor) and a literal "+ " prefix in the text, showing two plus glyphs. Dropped the literal prefix so only the icon renders. |
||
|
|
865d792874 |
feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral) Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral) to two: a generous free Community tier and a single paid Admiral tier. The Skipper tier is removed. Now free in Community: auto-heal, auto-update, scheduled operations, webhooks, notification routing, Fleet Actions and bulk operations, SSO preset providers (Google / GitHub / Okta), unlimited users with admin and viewer roles, and deploy safety (atomic deploys, auto-rollback, and one-click rollback). Admiral (paid) is focused on running and governing a fleet: blueprints, Fleet Secrets, deploy enforcement, vulnerability report export, audit log, host console, private registries, mesh networking, node cordon, managed cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles (deployer, node-admin, auditor) with per-resource scoped assignments. Internally the license variant distinction is removed so tier is binary (community / paid). License validation still verifies the Lemon Squeezy store and product before granting paid status. Docs and the contributor guide are updated to the two-tier model. * docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording The licensing docs page kept the old Admiral pricing plus a Founder Lifetime column and an Enterprise paragraph after the two-tier collapse. Update it to $12/month or $99/year, drop the lifetime and Enterprise content, and link to the pricing page for current pricing. Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title, and three test comments. Historical CHANGELOG entries and the retired-Skipper license-guard test are intentionally left as-is. * docs: align licensing and SSO pages with the two-tier model Correct the SSO overview so the Google, GitHub, and Okta presets read as available on every tier, matching the provider table; only LDAP and Active Directory require Sencho Admiral. Remove the lifetime-plan references from the licensing, settings, and troubleshooting pages so they reflect subscription-only Admiral pricing. * fix(rbac): omit scoped permissions from /me on the Community tier Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case. * docs: use custom-pricing wording on the contact page The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists. |
||
|
|
c0a252026d |
feat(appearance): add theme, accent, contrast, and typography personalization (#1307)
* feat(appearance): add theme, accent, contrast, and typography personalization Expand Settings to Appearance into a full personalization surface and add a quick switcher to the top bar (the palette button between search and notifications). Choices are saved to the browser, sync across tabs, and apply before first paint so there is no flash on reload. - Themes: Dim (the default raised charcoal), OLED true black, Light, and Auto (follows the OS and re-resolves live when it flips). - Accent: an eight-hue wheel (cyan default) that drives the one data color across charts, rails, focus rings, active states, and the ambient glow. - Fine-tune sliders: a master Contrast that spreads page, ink, and borders together, plus Border brightness and Ambient glow. - Typography: swappable interface (Geist / IBM Plex Sans / Hanken Grotesk) and data (Geist Mono / IBM Plex Mono / Fira Code) faces, and a text-size control (continuous slider in Settings, S/M/L/XL presets in the popover, kept in sync). The display serif stays locked as the signature face. Surfaces, borders, and ink derive from per-theme lightness values so the live knobs scale the whole UI through CSS, and the opaque directional borders read on every panel including true black. A live preview reflects changes in real time. Adds a documentation page under Features. * fix(appearance): keep contrast-driven tokens in gamut and add picker keyboard nav Clamp the contrast and knob driven surface, border, and ink lightness so the full slider range stays a valid color. The page now always sits below the card tone, so page/card separation no longer collapses at high contrast in Light; the lit border edge never reaches white in Light at the low end of the knobs; and the OLED and Dim extremes resolve to valid black/white instead of out-of-range values. Add the radiogroup keyboard model (roving tabindex plus Arrow / Home / End) to the accent and type pickers through a shared hook, matching the segmented control. One item is tabbable and arrow keys move focus and selection together. |
||
|
|
06b25262cc |
feat(stacks): guided first stack import flow (#1285)
* feat(stacks): add guided first stack import flow Add an Import mode to the Create Stack dialog and a zero-stacks empty state so a new user who already has compose files on disk can land their first stack without reading the docs first. A read-only scan of the compose directory (GET /api/stacks/import/scan) lists the compose files it finds with a dry preview of each file's services, ports, volumes, and env files. Each result is labelled by placement: already a stack, loose at the root of the compose directory, or one folder too deep, with the exact path to move misplaced files to. The scan never writes, moves, or changes any files. Manual stack creation (Empty, From Git, From Docker Run) is unchanged. * fix(stacks): read import-scan candidates via a single file handle Open the compose file once and stat plus read on the same descriptor so the size check and the read observe the same inode, instead of resolving the path twice (stat then readFile), which is a time-of-check/time-of-use race. Mirrors the existing handle-based readers in FileSystemService. * fix(stacks): confine import scan to the compose dir and refine the empty state Harden the read-only import scan: - Resolve symlinks and confirm the real target stays inside the compose directory before reading a candidate, and reject non-regular files, so a symlinked compose file or parent cannot expose a file outside the compose directory through the preview (matches resolveSafeStackPath). - Read at most the stat-reported size (bounded by the 1 MiB cap) from the open handle, so a file that grows after the size check cannot exceed the cap. - Log when the compose directory or a subdirectory cannot be read, so an access failure is not silently reported as "no compose files found". Only show the first-run "No stacks yet" prompt when no filter chip is active, so a filter that matches nothing is not mistaken for an empty fleet. |
||
|
|
979181875d |
fix(sidebar): require admin role for Schedule task and debounce search input (#1243)
The right-click Schedule task menu item and its keyboard shortcut were gated
only on isPaid, but the backend write routes under /api/scheduled-tasks
enforce requireAdmin + requirePaid on every action. Non-admin Skipper or
Admiral users would see the menu item and hit a 403 on click. The frontend
now mirrors the backend by gating Schedule task on isPaid && isAdmin so the
affordance only renders for users whose action will actually succeed.
Also adds a 120ms keystroke debounce to the sidebar search input. The
useStackListState filter rebuild was previously running on every keystroke
because <Command shouldFilter={false}> disables cmdk's own filter and the
existing 250ms timer only debounces state-invalidate events. Visible input
stays immediate via local state; the debounced emit drives the filter pass.
Adds a regression guard that /api/stacks/statuses is short-circuited by the
remote-node proxy (covers the sidebar status poll path) and updates the
sidebar feature docs to reflect the admin role requirement on Schedule task.
|
||
|
|
adcd04b01a |
refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)
* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only
The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.
The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.
Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
accessors, the three routes under /api/stacks/*/auto-update, the per-stack
skip in /api/auto-update/execute and SchedulerService.executeUpdate's
fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
optimistic toggle plumbing. Drop autoUpdateSettings state, the
/stacks/auto-update-settings fetch, and the auto-update-settings-changed
WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
now means "a covering enabled action='update' schedule exists" (per-stack
row or fleet row on this node, earliest next_run_at wins, per-stack row
wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
for the 60s poll. The broadcast is wrapped so a broken subscriber socket
cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
to describe the schedule-based model; update the matching troubleshooting
entry. The misleading fleet-update help text in ScheduledOperationsView
is corrected to reflect that every stack on the node is covered.
Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.
No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.
* docs: sweep remaining references to the per-stack auto-update toggle
The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:
- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
group description, the matching screenshot alt-text, and the Skipper
Note that listed it. Schedule task now carries the cross-link to
Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
"Auto-update stacks" to "Auto-update schedules" with the new value
shape, and rewrite the troubleshooting accordion to describe the
scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
Stacks row and helper text to reflect that every stack on the node is
covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
All Stacks schedules instead of the removed per-stack policy.
The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.
No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.
* fix(stacks): drop orphaned requireAdmin import after auto-update route removal
CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.
tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
|
||
|
|
5196f0440e |
feat(sidebar): surface unreachable nodes in cross-node stack search (#1195)
Per-node fetch failures in useCrossNodeStackSearch were silently
swallowed into []. The user could not tell the difference between
"this node has no matching stack" and "this node timed out or 502'd".
Adds a third return value to the hook: failedNodes: FailedNode[]
where FailedNode is { nodeId, nodeName, reason }. The reason captures
the HTTP status text (e.g. "list returned HTTP 502") or the
underlying Error message ("connect ECONNREFUSED 192.168.x.x:1852")
without leaking node URLs. AbortError from the effect cleanup path
is intentionally excluded - it's expected when the user keeps typing.
Threads the new field through useStackListState and EditorLayout into
StackList. StackList renders a warning chip below the "Other nodes"
header when the array is non-empty:
! N nodes unreachable > (expand)
Click expands the chip to a vertical list of "node: reason" lines.
Hover surfaces the full reasons in the title attribute too, so a
user inspecting at a glance can read them without clicking. The chip
is suppressed entirely when the search yields no failures (no zero
state). Color is the existing warning token, not error - the failure
is recoverable (node may come back) and not a system-wide problem.
GlobalCommandPalette also calls useCrossNodeStackSearch but ignores
the third return value; its destructuring is additive-safe.
Resolves M-2 from the stack-management audit.
|
||
|
|
7c84969b31 |
fix(editor): harden save-deploy, node-switch, delete, and stats reactivity (#1188)
* fix(stacks): validate input, bound YAML parses, and reorder delete steps
Backend hardening covering three editor-served routes:
- `/:stackName/containers` GET adds an explicit `isValidStackName` guard so
bad input is rejected at the call site even if the router-level param
validator changes in future.
- `MAX_COMPOSE_PARSE_BYTES` (1 MiB) bounds the two `YAML.parse` callsites
(`resolveAllEnvFilePaths`, `/services`) so a malformed or oversize compose
cannot exhaust heap during routine env/service lookups.
- `DELETE /:stackName` is reordered to abort before any database cleanup
if `FileSystemService.deleteStack` throws, keeping DB and FS in sync.
Partial-failure responses now describe the resulting state in human
terms instead of returning a generic 500.
Adds debug-mode entry-point traces (`[Stacks:debug] ...`) on save / down /
restart / delete handlers, all sanitised through `sanitizeForLog`. New
vitest covers the containers validator, the YAML size guard, and the
small-compose happy path.
* fix(editor): gate save-and-deploy on save success, abort stale loads
`saveFile` now returns a boolean: true on a successful PUT, false on any
failure. `handleSaveAndDeploy` short-circuits when save fails so a backend
500 on the compose write no longer slips through to a deploy with the
unsaved in-memory content. The diff-preview confirm path in ShellOverlays
applies the same guard.
`loadFile` now drives a per-hook `AbortController`. A stack switch, a
node switch (via `resetEditorState`), or hook unmount aborts the in-flight
GET chain so a late compose / env / containers / backup response from the
previous selection never overwrites freshly-loaded state.
`hasUnsavedChanges` is exported so EditorLayout can check it during the
node-switch lifecycle. New unit tests cover the boolean save contract and
the save-fail-blocks-deploy invariant.
* fix(editor): prompt on node switch when the editor has unsaved changes
Switching the active node previously called `resetEditorState()` without
checking the editor's dirty state, silently dropping in-progress edits.
The post-auth shell now intercepts the node-change effect: if the editor
is dirty, the attempted node is stashed via the existing
`pendingUnsavedNode` field, `pendingUnsavedLoad` is set to a sentinel
that routes `discardAndLoadPending` to `setActiveNode`, and `activeNode`
is reverted to the previous node so the dialog can be resolved without
losing content.
A re-entrant switch (clicking a third node while the dialog is still
open) is now ignored — the second switch reverts silently so the
dialog's anchor stays on the first attempt. When the previous node is no
longer in the registry and cannot be reverted to, the operator gets a
warning toast before the wipe so the loss is at least visible.
* fix(editor): split delete and deploy permission gates in the action bar
The action bar previously wrapped every affordance — including the Delete
menu item — in a single `can('stack:deploy')` check, even though the
backend route requires `stack:delete`. A user with `stack:deploy` only
saw a Delete button that 403'd, and a user with `stack:delete` only saw
no menu at all.
Each affordance now renders against its own permission: deploy / stop /
restart / update on `stack:deploy`, delete on `stack:delete`, rollback on
`canDeploy + isPaid + backupInfo.exists`, scan on `isAdmin +
trivy.available`. The overflow menu appears if any of {rollback, scan,
delete} is granted, so a delete-only operator still has a way to remove
the stack.
Adds a Monaco model dispose on EditorView unmount via the existing
editor ref, and a compact `Stats unavailable` chip in the CONTAINERS
header that lights up when the live-stats WebSocket reports a persistent
failure.
* fix(editor): make container-stats hook reactive to the active node
`useContainerStats` previously read the active node id from
`localStorage` on each WebSocket open, with a deps array of `[containers]`
only. After a node switch the stats stream stayed pointed at the
previous node's `/ws` endpoint until the containers array refreshed.
The hook now accepts `activeNodeId` as a second argument, depends on
`[containers, activeNodeId]`, and drops the localStorage read. The
return shape is `{ stats, error }`: the error field carries a string
when the stream fails, surfaced by EditorView as a small chip in the
CONTAINERS header. A per-WS `warnedOnce` set ensures a flaky daemon
emits at most one console.warn per stream lifetime, never at message
rate. Close codes 1000 / 1001 stay silent (normal teardown, navigation).
The error reset (`setError(null)`) is split into its own effect keyed on
`activeNodeId` so the banner does not flap on every containers-array
refresh tick against a persistently-flaky daemon. Tests cover the new
shape, the node-id reactivity, and the abnormal-close warn behaviour.
* docs(editor): describe new gate split and add troubleshooting entries
Updates the editor cockpit page to reflect that the action bar now
gates each affordance on its own permission (deploy / delete), and that
the bar appears for delete-only users so a stack can still be removed.
Adds three troubleshooting accordions covering the new behaviours: a
failed save that blocks the subsequent deploy, the unsaved-changes
prompt on node switch, and the live-stats chip when the daemon is
unreachable.
Adds an E2E spec verifying that a forced PUT 500 on the compose write
surfaces the failure toast and prevents the deploy POST from firing.
* fix(stacks): use printf-style format for compose-down warn
`console.warn` treats arg-1 as a printf format string when subsequent
args follow. The template literal here interpolated a sanitized but
not %-escaped stackName into arg-1 alongside an error argument, so a
stackName containing a `%s` placeholder could theoretically swallow
the error in the substitution. Switch to the file's established
`'... %s ...', value, err` pattern.
* test(editor): fix save-deploy spec; click both edit buttons, drop Monaco fill
The editor has two edit affordances: a lowercase 'edit' in the Anatomy
panel header that swaps the right column to the editor tabs, and a
capital 'Edit' in the editor toolbar that flips Monaco from read-only
into edit mode. The spec previously matched both with a case-insensitive
regex and only fired one click, so Monaco never entered edit mode.
It also tried to fill .monaco-editor textarea — that element is Monaco's
IME accessibility helper, hard-coded readonly; the real editable surface
is a contenteditable div.
`saveFile()` does not gate on a dirty buffer, so the spec does not need
to modify Monaco at all. Click both edit buttons with case-anchored
regexes and drop the fill step.
* test(editor): disambiguate Save & Deploy locator from sidebar row
The TEST_STACK fixture is named 'e2e-save-deploy-stack'. The sidebar
renders each stack into a div with role=button whose accessible name
includes the stack slug, so the regex /save.*deploy/i matches both the
sidebar row ('e2e-save-deploy-stack') and the editor toolbar's actual
Save & Deploy button — strict-mode bails. Anchor the locator to the
literal button text with exact:true.
|
||
|
|
bb44db0cb1 |
refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip (#1178)
* refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip Replace the simple notification ticker with a derived activity summary that picks one of six states (active-op, failure, automation, recent-event, quiet-live, disconnected) and routes per-state clicks to logs, schedules, or activity. The hook owns the cascade; the component is pure presentation; EditorLayout owns wiring. Failure detection covers unread errors in the last 24h; recent-event is limited to non-error stack notifications in the last hour; automation reads the next /scheduled-tasks?action=update run and a debounced state-invalidate listener; the deploy-panel composite key is used for elapsed-time tracking so close-then-immediately-reopen counts as a new session. * refactor(sidebar): apply Ops Pulse audit fixes - countEnabledAutoUpdates now defaults missing autoUpdateSettings entries to enabled, matching the backend's getStackAutoUpdateSettingsForNode contract. Previously the automation state could not render even with the documented per-row default-true. - findFailure now requires a stack_name so the sidebar does not select a system-level error whose click would no-op through navigateToNotification. System errors continue to surface via the top-bar NotificationPanel. - DeployPanelState gains a monotonic sessionId sourced from the existing internal counter, and the new usePanelSessionStartedAt hook keys the elapsed-time tracker off it so a same-stack rerun always resets even when isOpen stays true across succeeded then preparing. - buildConfig splits quiet-live out of the default and adds an exhaustiveness guard so future SidebarActivitySummary variants fail to compile. - New unit tests cover the default-true aggregation, the same-stack session reset, the non-stack failure guard, and the useNextAutoUpdateRun debounce and cleanup paths. Frontend suite: 276 / 276 pass. |
||
|
|
9d8d8abcba |
fix(stacks): close dialog and toast on Empty create (F-2) (#1168)
* fix(stacks): close dialog and toast on Empty create (F-2)
The Empty branch of the Create Stack dialog left no visual confirmation
on a successful POST: no success toast, no busy state on the Create
button, and no double-click guard. The first thing a new operator does
is create a stack, so a click that looks like a no-op is the day-one
impression killer.
What changed
- Empty handler now mirrors the Git and docker-run patterns: a
creatingEmpty busy guard, Loader2 spinner on the Create button,
disabled Cancel mid-flight, and a "Stack <name> created." success
toast fired synchronously before the editor-load handoff.
- The Empty panel is wrapped in a form so the Enter key submits the
same path as clicking Create.
- Mapped 403 to a clearer permission message; fall back to the
backend's error string for other non-OK statuses.
- Capture the active node id at handler entry and pass it through
onStackCreated. The parent compares against the live node ref and
skips the editor load if the user switched nodes mid-create, with
an info toast pointing at the prior node.
- Tightened the create-a-new-stack E2E (no more silent .catch on the
dialog-close assertion, asserts the toast, no longer relies on a
page reload to see the sidebar entry) and added a regression spec
that the busy guard collapses double-clicks into a single POST.
* fix(stacks): close double-click race in create handler (F-2)
The setState-based busy guard could race a second click that landed
before React committed the disabled state, so two POSTs got dispatched
when a user double-clicked Create. The new useRef-based check is
read and written synchronously inside the handler so a re-entrant
invocation bails before issuing a second fetch.
The accompanying E2E (`create dialog: double-clicking Create fires
only one POST`) was also hanging to its 30s test timeout: the second
click's locator resolution could outlive the dialog when the first
POST resolved quickly, so Playwright waited for `[role="dialog"]` to
reappear. Both clicks now fire in the same microtask via Promise.all
with a 1s timeout on the second click so the locator-resolution path
fails fast instead of hanging the test.
* fix(stacks): keep busy guard active across modal close (F-2)
Two issues from independent review.
1. Important: resetting creatingEmpty / creatingEmptyRef inside the modal
close handler created an Escape/backdrop race. A user could close the
dialog mid-flight (clearing both flags), reopen, click Create again,
and slip past the guard before the first POST settled. The finally
block already owns that lifecycle, so the close handler no longer
touches either flag.
2. Nit: bring 403 messaging to parity with the Git branch. The Empty
handler now reads the backend's error field first and falls back to
the original hardcoded copy if it is missing, mirroring how
handleCreateStackFromGit surfaces backend permission errors.
* test(stacks): rebuild double-click spec around observable disabled-state (F-2)
The Promise.all racing approach to "double-click fires only one POST"
hung to the 30s test timeout on CI. The page snapshot at timeout confirms
the product fix works correctly (dialog closed, stack created, editor
navigated); the test itself was the flake. Race-based assertions on a
single React-state commit boundary are inherently timing-sensitive.
The rewrite turns the test inside-out:
- The route handler holds POST responses for 400ms so the in-flight
window is large enough to observe deterministically.
- await route.continue() (instead of void fire-and-forget) closes a
subtle stall pattern Playwright's docs flag under load.
- After the first click the test asserts the button reaches a disabled
state within the in-flight window, which proves the busy guard
activated at the React-state layer.
- A best-effort force-click against the now-disabled button exercises
the user-mash path; the synchronous useRef guard inside the handler
catches it whether or not the browser dispatches the click.
- expect(postCount).toBe(1) still owns the actual regression assertion.
No product code change. The synchronous creatingEmptyRef guard already
landed in
|
||
|
|
6722335a79 |
fix(stack-update): refresh frontend state automatically after a stack update (#1113)
After applying a stack update the sidebar's "update available" dot stayed visible and the stack's status indicator was stuck on the optimistic value until the page was manually refreshed. Two root causes: 1. Image-updates state refresh was a fire-and-forget call in some paths and entirely missing from the bulk-update, auto-update, and state-invalidate WebSocket-handler paths. 2. stackActionsRef.current was resynced only at render time, so the post- update refreshStacks(true) running in the action's finally block read a stale "busy" map and preserved the optimistic mask via prev[file] ?? status. Backend now broadcasts a state-invalidate event with scope='image-updates' and action='stack-updated' after every successful update (single-stack route and auto-update loop). The frontend useNotifications hook routes this to a new onImageUpdatesChange callback wired to fetchImageUpdates in EditorLayout, so every connected client refreshes the dot through the same code path. Bulk update also calls fetchImageUpdates directly for fast local feedback, and setStackAction/clearStackAction now keep stackActionsRef synchronously in sync with state so the busy-stack check inside refreshStacks observes the cleared map immediately. Adds 3 unit tests covering the new WS branch (positive, scope-mismatch negative, auto-update-settings-changed negative). |
||
|
|
1cf996142b |
refactor(frontend): EditorLayout final shell (B4-7) (#906)
* refactor(frontend): extract useOverlayState hook from EditorLayout * refactor(frontend): extract useStackActions hook and wire useOverlayState into EditorLayout * refactor(frontend): fix quality issues in useStackActions post-review * fix(frontend): fix interval leak, RunResult contract, yml hardcode, and loadFile length in useStackActions * refactor(frontend): extract useSidebarContextMenu hook from EditorLayout * refactor(frontend): extract ShellOverlays component from EditorLayout * refactor(frontend): relocate Monaco layout effect and log-viewer event listener out of EditorLayout The Monaco tab-switch layout effect is now self-contained in EditorView, alongside its monacoEditorRef. The SENCHO_OPEN_LOGS_EVENT listener moves into useOverlayState, where openLogViewer lives. EditorLayout is left with the two coordination effects that depend on cross-hook state. |
||
|
|
6d4709db86 |
refactor(frontend): extract useTheme, useNotifications, useContainerStats hooks from EditorLayout (#903)
* refactor(frontend): add useViewNavigationState hook with tests * refactor(frontend): wire useViewNavigationState into EditorLayout * test(frontend): add skipper tier and handleOpenSettings no-arg coverage * refactor(frontend): extract useTheme hook from EditorLayout * refactor(frontend): extract useNotifications hook from EditorLayout * refactor(frontend): extract useContainerStats hook, remove containerStats from useEditorViewState * refactor(frontend): wire useTheme, useNotifications, useContainerStats into EditorLayout Removes all extracted effect clusters from EditorLayout.tsx and replaces them with calls to the three new focused hooks. Extracted code removed: - theme useState + 2 effects (system dark-mode listener, DOM class sync) - fetchNotifications, fetchNotificationsRef, markAllRead, deleteNotification, clearAllNotifications, and 5 notification effects (local WS, nodes refetch, remote per-node WS, remote cleanup, 60s safety-net poll) - container stats useEffect with 1.5s flush interval - formatBytes utility (moved to useContainerStats) - fetchForNode import (no longer used at this level) EditorLayout now at 17 useState / 4 useEffect, both under the <20 / <10 acceptance criteria required before the B4-7 shell PR. |
||
|
|
0a126e74a7 |
refactor(frontend): extract useViewNavigationState hook from EditorLayout (#902)
* refactor(frontend): add useViewNavigationState hook with tests * refactor(frontend): wire useViewNavigationState into EditorLayout * test(frontend): add skipper tier and handleOpenSettings no-arg coverage |
||
|
|
d5393a6027 |
refactor(frontend): extract useStackListState hook from EditorLayout (#901)
Moves the stack-list state cluster (14 useState calls, plus refs, memos, effects, and 7 absorbed sub-hooks) into a new hook at EditorLayout/hooks/useStackListState.ts, following the same pattern as useEditorViewState (B4-5). EditorLayout useState count: 42 -> 28. useEffect count unchanged. Also fixes a stale-closure bug in isStackBusy: previously read from stackActions state, making isBusy stale inside buildMenuCtx whenever a stack action fired between dep-array updates. Now reads from stackActionsRef.current so it is always current without needing to be listed as a dep. Wrapping it in useCallback makes the function reference stable. |
||
|
|
a85fbd5265 |
refactor(frontend): extract useEditorViewState hook from EditorLayout (#900)
Bundles 19 editor-view useState calls, 1 useRef, and 2 useEffects (copiedDigest timer cleanup, logsMode localStorage persist) into a co-located hook at EditorLayout/hooks/useEditorViewState.ts. EditorLayout destructures the result so existing reference sites keep their bare names. EditorView's prop interface is unchanged. EditorLayout.tsx: 61 -> 42 useState, 21 -> 19 useEffect. Adds 16 unit tests covering defaults, setters, logsMode hydrate/persist, and copiedDigestTimerRef cleanup on unmount. Behavior unchanged. Verified end-to-end in browser: stack switch populates content/env/containers, edit mode toggles, .env tab swap shows env content, Raw terminal logs button persists logsMode to localStorage. |
||
|
|
ae3cc3f0fd |
refactor(frontend): extract EditorView from EditorLayout (#899)
Move the inline renderEditor JSX (~535 lines) out of EditorLayout.tsx into a new EditorView component under components/EditorLayout/, matching the pattern established by ViewRouter (B4-1) and CreateStackDialog (B4-2). State ownership stays in EditorLayout for this PR; the next step lifts state slices into a useEditorViewState hook. The new file owns the compose-editor surface as a unit: command-center identity card, action bar (Restart/Stop/Update + overflow with Rollback, Scan config, Delete), per-container health strip with sparklines and service action menus, the Logs section with Structured / Raw toggle, and the right-column Monaco editor (when editingCompose) or StackAnatomyPanel (default). Three module-level helpers (extractUptime, healthcheckLabel, getStackStatePill) and the ContainerInfo / StackAction types move with it; EditorLayout re-imports the types so existing local consumers compile unchanged. One small consolidation done in this PR: the trash-can onClick used to call setStackToDelete(selectedFile) followed by setDeleteDialogOpen(true). Those are wrapped into a single requestDeleteStack callback owned by EditorLayout, exposed as one prop on EditorView. The sidebar context-menu remove path remains unchanged (it passes its own stackName to the setters). EditorLayout.tsx: 2,747 -> 2,169 LOC (-578). Also drops ~25 imports that were only consumed by the relocated block (Editor, Card*, DropdownMenu*, Select*, Tabs*, Sparkline, StackAnatomyPanel, StackFileExplorer, StructuredLogViewer, TerminalComponent, ErrorBoundary, copyToClipboard, cn, springs, and 18 lucide icons). EditorView.tsx: 822 LOC. Above the per-child < 500 target; further sub-decomposition (CommandCenterCard, ContainerHealthStrip, etc.) is a later step in the tracker, not this PR. |
||
|
|
b3382d07a1 |
refactor(frontend): extract dialog cluster from EditorLayout (#898)
Extract the two remaining inline ConfirmModal blocks at the bottom of EditorLayout into their own modules under components/EditorLayout/, matching the pattern established by CreateStackDialog (B4-2): - DeleteStackDialog: takes open/onOpenChange/stackName/onConfirm; owns the prune-volumes checkbox state internally and resets it on close. EditorLayout's deleteStack handler now accepts pruneVolumes as a parameter instead of reading parent state. - UnsavedChangesDialog: takes open/onCancel/onConfirm. The discard body is hoisted to a named handler in EditorLayout (discardAndLoadPending) so the dialog stays a thin presentational wrapper. Also removes a dead label-bulk-action surface that had no live entry point: bulkActionLabel and bulkAction were declared without setters, and setBulkActionOpen(true) was never called from anywhere. The multi-select bulk-stack-actions live elsewhere via useBulkStackActions and SidebarBulkBar; this removed code was unrelated and unreachable. Drops the BulkActionResult interface, four useState lines, the bulkAffected useMemo, the runLabelBulkAction handler, and the inline ConfirmModal. EditorLayout.tsx: 2,852 -> 2,747 LOC (-105). useState count: 66 -> 61. |
||
|
|
d492594189 |
feat(ui): add ConfirmModal, migrate EditorLayout inline confirms (#897)
Introduce ConfirmModal, an AlertDialog-rooted variant of the §10 modal chrome for Yes-No confirmations. Reuses the cyan or destructive rail, mono kicker, italic serif title, and footer hint via a parameterized HeaderShell that injects Title and Description components so the same helper renders Dialog or AlertDialog primitives correctly. Replace the three inline AlertDialog blocks in EditorLayout (delete stack, unsaved-load, label bulk action) with ConfirmModal. Hoist the label bulk-action handler out of inline JSX and memoize the affected stack list. Async confirms (returning a Promise from onConfirm) keep the dialog open so callers can render running state and close via onOpenChange; sync confirms let Radix auto-close. |
||
|
|
71b7a52def |
refactor(frontend): extract CreateStackDialog from EditorLayout (#895)
Continues the EditorLayout decomposition (B4-2). Pulls the inline three-tab Create Stack dialog (Empty / From Git / From Docker Run) out of EditorLayout into EditorLayout/CreateStackDialog.tsx. Parent now owns only the open boolean and a domain callback pair; all 14 form-state vars and 6 handlers move into the child. The slot renders a thin trigger button plus the new dialog. Metrics: - EditorLayout.tsx: 3,266 -> 2,843 LOC - new CreateStackDialog.tsx: 463 LOC (under the 500 ceiling) - useState count in EditorLayout: 81 -> 66 |
||
|
|
d203caf7dc |
refactor(frontend): extract ViewRouter from EditorLayout (#894)
Move the activeView switch from EditorLayout.tsx into a new EditorLayout/ViewRouter.tsx covering the nine non-editor views (settings, templates, resources, host-console, global-observability, fleet, audit-log, auto-updates, scheduled-ops) plus the HomeDashboard fall-through. The inline editor branch stays in EditorLayout via a renderEditor render slot; it gets its own extraction in a follow-up. EditorLayout shrinks from 3,356 to 3,266 LOC and sheds imports for SettingsPage, AppStoreView, ResourcesView, HomeDashboard, AdmiralGate, CapabilityGate, Skeleton, plus six lazy view declarations and the inline ViewSkeleton helper. The lazy declarations move into ViewRouter; SecurityHistoryView stays behind because it renders as a settings overlay, not as a top-level tab. ViewRouter introduces a small inline LazyView helper to deduplicate the LazyBoundary + Suspense + ViewSkeleton triple-wrap that repeats across six lazy views. First step of the EditorLayout decomposition tracker. |
||
|
|
1f8ce773ff |
feat(ui): hide paid features from community-tier dashboard (#891)
* feat(ui): hide paid features from community-tier dashboard Community installs render only the features they can use. Tier-locked sections, lock badges, upsell cards, and "Upgrade" buttons no longer appear anywhere except the License page in Settings, which is the single discoverable upgrade path. Concretely: - PaidGate and AdmiralGate now render null for non-qualifying tiers instead of upsell cards. - SectionGate (settings) hides tier-locked sections entirely. - Settings sidebar and command palette filter out items the operator cannot reach. - Configuration Status widget on the dashboard drops the Automation section for community and hides any locked rows in remaining sections. - Fleet > Status node cards drop locked summary rows. - Stack action menu, sidebar bulk bar, file upload / download, scan comparison, network topology toggle, node label picker all hide for community instead of showing disabled affordances or "Upgrade" literal text. - Removes tierUpsell, TierLockChip, and useDismissalState (no longer referenced). Backend tier guards remain authoritative; this changes UI discovery only. * test(e2e): assert upload control is absent in community tier The community-clean-ui change removes the "Upgrade to unlock upload" pill from the file explorer. Update the matching e2e assertion to verify the upload control is not rendered, instead of waiting for a pill that no longer exists. |
||
|
|
b843b89ca4 |
feat(frontend): add LazyBoundary for chunk-load failure recovery (#875)
* feat(frontend): add LazyBoundary for chunk-load failure recovery
When a lazy chunk fetch fails, the existing top-level ErrorBoundary shows
"Something went wrong" with a "Try again" CTA. "Try again" cannot succeed
against a chunk URL that no longer exists on the server (typical post-
deploy case where the user's tab was opened against an older bundle).
The right remedy is to reload the tab so the browser fetches the new
hashed chunks emitted by the current build.
Add LazyBoundary, a section-local error boundary that:
- Detects chunk-load errors via a substring union covering Chrome / Edge
("Failed to fetch dynamically imported module"), Safari ("Importing a
module script failed"), Firefox ("disallowed MIME type" thrown when a
deploy serves SPA index.html for a missing chunk URL), older Webpack
("Error loading dynamically imported module"), and Vite ("Loading
chunk/CSS chunk N failed").
- For chunk errors, renders a glass-card matching the LockCard aesthetic
with an AlertTriangle icon, a "This part of Sencho needs a reload"
message, and a Reload CTA that calls window.location.reload().
- For non-chunk runtime errors, falls back to "Something went wrong" +
the error message + a Try again CTA. Try again is safe on this path
because the lazy import has already resolved before the render error
fires.
- Logs to console.error in componentDidCatch so the underlying failure
is still observable.
- Has role="alert" so screen readers announce the failure.
Wrap every existing Suspense site (1 in SettingsPage, 7 in EditorLayout
including the security-history overlay, 1 in ResourcesView) with
LazyBoundary. The top-level ErrorBoundary remains the catch-all for
errors that escape the section-local boundary.
Includes a unit test enumerating each browser's documented chunk-load
message so a regression in any one runtime is caught early.
* fix(frontend): move isChunkLoadError to its own file to satisfy react-refresh/only-export-components
|
||
|
|
a8d1a9d461 |
feat(frontend): code-split non-settings paid views and security overlay (#872)
Extends the settings code-splitting from PR #870 to the full-screen views in EditorLayout. Six paid views (HostConsole, FleetView, AuditLogView, ScheduledOperationsView, AutoUpdateReadinessView, GlobalObservabilityView) and the SecurityHistoryView overlay used to ship statically into the main bundle, so every Community user downloaded ~300 kB raw / ~80 kB gzip of paid feature code on first page load even if they never clicked those tabs. Convert each to a lazy() declaration and wrap the call site in Suspense with a small ViewSkeleton fallback. SecurityHistoryView is an always-mounted overlay, so it is also conditionally mounted on its open state to keep the lazy import from firing on EditorLayout's first render. GlobalObservabilityView is a free-tier feature with no internal gate; it is split here purely for the bundle-size win, not for IP protection. The other paid views still have their existing PaidGate / AdmiralGate / CapabilityGate wrappers, which render a blurred preview with upsell card rather than short-circuiting. When a tier-locked or capability-missing operator opens one of those tabs, the chunk fetches to render the blurred preview. The gate-short-circuit refactor is a separate follow-up. Build evidence: 7 new chunks total ~308 kB raw / ~83 kB gzip; main bundle shrunk from 1,468 kB / 407 kB gzip to 1,165 kB / 332 kB gzip. Combined with PR #870, Community users save ~390 kB raw / ~107 kB gzip on initial load. |
||
|
|
f62716f557 |
refactor(design): align typography, colors, and card surfaces to DESIGN.md (#859)
* refactor(design): align surface tokens to DESIGN.md §2 * refactor(design): canonicalize tracked-mono kickers and display rungs * refactor(design): collapse to five-slot palette and align card surfaces |
||
|
|
a25acbec7c |
feat(editor): opt-in diff preview before save (#855)
* feat(editor): add useComposeDiffPreviewEnabled hook * feat(editor): add ComposeDiffPreviewDialog component * fix(editor): replace HTML entity with Unicode arrow in ComposeDiffPreviewDialog * feat(editor): add diff preview toggle to Appearance settings Added a new 'Diff preview before save' toggle in the Display section of the Appearance settings panel. Users can now enable or disable the side-by-side diff view before compose and env file edits are saved to disk. * feat(editor): wire diff preview dialog into compose save flow * fix(editor): snapshot diff content at open time and fix event name - Fix useComposeDiffPreviewEnabled and useDeployFeedbackEnabled to use the canonical SENCHO_SETTINGS_CHANGED constant from @/lib/events instead of the hardcoded string literal (wrong value) - Snapshot language, original, modified, and fileName into diffPreview state at click time to prevent tab-switching from corrupting dialog content mid-review - Remove React.MouseEvent from diffPreview state; pass a no-op stub to deployStack in the confirm path (preventDefault/stopPropagation are no-ops on an already-settled event anyway) - Add diff-modal screenshot and document the feature in editor.mdx and settings.mdx * docs(editor): add settings-toggle screenshot for diff preview feature |
||
|
|
3e01daf76f |
feat(stack): per-stack activity timeline with actor attribution (#852)
* feat(stack): per-stack activity timeline with actor attribution Adds an Activity tab to the Stack Anatomy panel showing a timestamped event log for each stack: deploys, restarts, starts, stops, and image updates, attributed to the user who triggered them or 'system' for automated actions. Backend: - Extends notification_history with actor_username column (idempotent migration) and a partial composite index on (node_id, stack_name, timestamp DESC) for efficient per-stack lookups. - NotificationService.dispatchAlert() accepts an optional actor that is written to the new column. - Success-side dispatchAlert calls added after deploy, bulkContainerOp (start/stop/restart), and update handlers in routes/stacks.ts so user-initiated operations are recorded, not just failures. - New GET /api/stacks/:stackName/activity?limit&before endpoint with stack:read permission gate and cursor-based pagination. Frontend: - StackAnatomyPanel grows an Anatomy / Activity tab pair using the existing Tabs primitive. - StackActivityTimeline fetches the initial 50 events, paginates on demand, and prepends live events arriving over the existing WS notifications stream without duplicates. - NotificationPanel bell dropdown suppresses user-initiated success events (start/stop/restart/deploy/update triggered by a real user), keeping the tray focused on alerts and system events. * docs(stack): add stack activity timeline feature page and internal arch docs * fix(test): add actor_username to notification-routing history assertions dispatchAlert now passes actor_username to addNotificationHistory after the activity timeline PR added the column. Update the two exact-match assertions that were failing because the expected object shape was missing this field. |
||
|
|
a0bf5b5bf5 |
feat(sidebar): bulk stack operations (#854)
* feat(sidebar): bulk stack operations (select, start/stop/restart/update)
- Add ⊞ bulk mode toggle in SidebarActions (cyan active state, tooltip "Bulk
mode (B)"); keyboard shortcut B toggles, Esc exits, Ctrl+A selects all
visible (chip-filtered) stacks
- Reserved checkbox column in StackRow becomes visible and interactive in bulk
mode; clicking a row in bulk mode toggles selection instead of opening the
stack; kebab and context-menu still work in either mode
- SidebarBulkBar appears below filter chips when >=1 stack selected: shows
count, Start / Stop / Restart / Update actions; Update is disabled with a
Skipper TierBadge for Community licenses
- useBulkStackActions hook fans out operations via Promise.allSettled and
surfaces an aggregate toast ("3 of 4 restarted; 1 failed: plex")
- Bulk update enforced Skipper-gated frontend-side (isPaid check in hook) and
sends x-bulk-mode header for backend defense-in-depth
- Extract isInputFocused / isPaletteOpen to lib/keyboard-guards.ts; both
useStackKeyboardShortcuts and the new bulk keyboard effect now share the
same guards instead of duplicating the logic
- chipFilteredFiles captured via useRef in bulk keyboard effect so the listener
is not torn down and re-added on every status-poll cycle
* fix(sidebar): separate TooltipProviders for bulk and scan icon buttons
Wrapping both icon buttons in a single TooltipProvider made them
render as one flex child, collapsing the gap-2 between them.
Splitting into two independent TooltipProviders restores the 8px
gap and right padding of the scan button.
|
||
|
|
4c0efcb9a8 |
feat(sidebar): §14 sidebar orchestration, filter chips, pinned rail, trailing column (#850)
* feat(sidebar): §14 sidebar orchestration (filter chips, pinned rail, trailing column) - Add All / Up / Down / Updates filter chips with live counts; active chip filters the list; chip-filtered files computed in EditorLayout with useMemo - Surface the PINNED group with a 3px cyan left rail and glow via the sidebarPinnedGroupRail token; reuses the brand token already on the active row - Compact brand row from three stacked elements to a single 44px horizontal bar - Restructure StackRow trailing column as fixed slots: label dots (max 3 + +N overflow), update-dot | git-pending icon (priority order), kebab; add reserved invisible checkbox slot for PR2 bulk mode - Export statusText / statusColor from StackRow and reuse them in StackList remote-results section to remove the duplicate inline logic - Lift filterChip state and chip-filtered files to EditorLayout; remove filterChip from StackListProps to eliminate the dual-path redundancy - Remove unused labels param from buildGroups and the void labels workaround - Wrap filteredFiles in useMemo so filterCounts memo is not defeated on every render * fix(sidebar): move statusText and statusColor to stack-status-utils react-refresh/only-export-components requires component files to export only components. Move the two utility functions and StackRowStatus type to a dedicated stack-status-utils.ts so StackRow.tsx is a pure component module. Update StackList.tsx and EditorLayout.tsx to import from the new source directly. |
||
|
|
eead195529 |
feat(settings): dress the page to match the audit (#849)
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)
Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.
Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.
Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.
Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.
* refactor(settings): drop react-router-dom and align with DESIGN.md
The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:
- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
useNavigate. SettingsPage owns currentSection via props lifted to
EditorLayout, so cross-component navigation (openLabelManager,
onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json
The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:
- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
--success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
tone now genuinely uses --success green; new brand tone for promotional
callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
aside gains text-card-foreground transition-colors per §10 canonical
card class
- SettingsPage main panel: text-card-foreground transition-colors added;
uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
tokens with literal fallbacks so Settings respects the comfortable/
compact toggle
* fix(e2e): update mfa openAccountSettings to match settings redesign
Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.
* test(e2e): fix MFA enrolment assertion after settings redesign
The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.
* test(e2e): fix low-backup-codes warning assertions after settings redesign
Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
identifies the zero-codes error card without hitting strict-mode
from two identically-labelled Regenerate buttons on the page
* test(e2e): navigate to root before re-opening settings for mock refresh
The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.
* test(e2e): scroll zero-codes callout into view before asserting visibility
The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.
* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion
The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.
* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue
The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
|
||
|
|
9a1c043189 |
refactor(settings): replace modal with nested full-page route (#848)
* refactor(settings): replace modal with nested full-page route
Settings sections are now URL-addressable at /settings/:sectionId, rendered
nested inside EditorLayout alongside the stack sidebar. Browser back/forward
navigates between sections. Deep links (e.g. /settings/cloud-backup) load
the section directly on hard reload.
- Add react-router-dom v7; BrowserRouter wraps the full app tree
- New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink
active styling, back-arrow), SectionGate (visibility + tier lock card)
- Rename SectionId 'appstore' to 'app-store' so slug === SectionId
- Decouple SystemSection, DeveloperSection, AppStoreSection from modal-
passed props; each fetches its own data on mount
- Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event
- Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard,
ConfigurationStatus; each calls useNavigate directly
- Delete SettingsModal.tsx
* fix(settings): validate sectionId against registry before property write
Prevents prototype pollution (CodeQL js/remote-property-injection #243).
URL param sectionId is checked against SETTINGS_ITEMS before being used
as a property key on scrollPositionsRef.
* fix(settings): eliminate remote property injection via Map and registry-sourced key
Two-part fix for CodeQL js/remote-property-injection:
1. currentSection is now derived from SETTINGS_ITEMS.find().id (trusted
registry data) instead of the raw sectionId URL param. The tainted
string never flows into any property access.
2. scrollPositionsRef uses Map<SectionId, number> with .get()/.set()
instead of a plain object. Map operations do not write to the prototype
chain, removing the prototype pollution vector entirely.
* test(e2e): align settings selectors with full-page route
The settings refactor (
|
||
|
|
3f2ff47c94 |
refactor(frontend): extract useImageUpdates hook from EditorLayout (#831)
EditorLayout owned the stack-image-update state plus a 5-minute polling interval as part of a 30-line useEffect that already juggled six other concerns (selected file, active view, stacks refresh, auto-update settings, git-source pending, …). The image- update slice has clean boundaries: it depends on activeNode.id, mutates one state object, and is otherwise unrelated to the rest of the effect. Move it into a dedicated hook at frontend/src/hooks/useImageUpdates.ts. The hook owns the stackUpdates state, runs an initial fetch on activeNode.id change, schedules the 5-minute poll, and exposes a refresh() callback for the four manual-trigger sites (deploy success, image-update action, manual registry-refresh poll). The hook destructure aliases refresh to fetchImageUpdates so existing call sites in EditorLayout don't need to be renamed. This is the first slice of audit finding 1.6 (EditorLayout 3129-line refactor); the next slice is useFleetNotifications. |
||
|
|
b5d038f395 |
perf(frontend): lazy-load Monaco editor + diff editor (#824)
Monaco-editor and @monaco-editor/react were imported eagerly from
main.tsx so that the locally-bundled Monaco was registered with
@monaco-editor/react (CSP blocks the default CDN load). This pulled
the ~3 MB monaco chunk into every cold app start regardless of
whether a user ever opened the editor.
Move the Monaco setup into a new frontend/src/lib/monacoLoader.tsx
module that exports React.lazy-wrapped Editor and DiffEditor
components. The lazy factory awaits a one-shot setupMonaco() that
dynamic-imports monaco-editor, @monaco-editor/react, and the editor
worker, then calls loader.config({ monaco }) and sets
window.MonacoEnvironment before resolving the underlying component.
Concurrent first mounts share a single setup promise so the work
runs at most once per process. The three consumers (EditorLayout,
FileViewer, GitSourceDiffDialog) wrap their editor in <Suspense>
with a transparent fallback that preserves layout while the chunk
loads.
main.tsx loses three eager imports plus the MonacoEnvironment +
loader.config bootstrap. The vite.config.ts manualChunks group from
PR #823 was already prepared for this; the monaco chunk now loads
on demand instead of being bundled into the entry chunk.
|
||
|
|
38a9f277c6 |
feat(stacks): add optional volume prune to delete confirmation (#788)
The Delete Stack dialog now includes an opt-in checkbox to also remove associated Docker volumes when the stack is deleted. The checkbox is unchecked by default and resets to unchecked on every open. Backend: DELETE /stacks/:name accepts ?pruneVolumes=true and calls pruneManagedOnly for volumes labeled with the stack project name after bringing the stack down. Prune failure is non-fatal and logged; the delete proceeds regardless. |
||
|
|
d7d8f9bfe8 |
feat(dashboard): replace 24h charts with Configuration Status and Recent Activity (#785)
* feat(dashboard): replace 24h charts with Configuration Status and Recent Activity The 24-hour CPU/Memory area charts summed per-container metrics normalized to each container's CPU quota, producing numbers that bore no honest relationship to host load. The live ResourceGauges strip already shows accurate host-level stats, making the historical charts both inaccurate and redundant. This commit replaces that row with two side-by-side cards: - **Configuration Status**: aggregates every toggleable feature on the active node (notification agents, alert rules, routing rules, auto-heal, auto-update, webhooks, scheduled tasks, MFA, SSO, vulnerability scanning, cloud backup, and alert thresholds) into a single at-a-glance card. Tier-locked rows display an upgrade indicator instead of a value. Each row is clickable and navigates to the relevant settings section. Data refreshes every 60 s and immediately on state-invalidate events. - **Recent Activity**: lists the ten most recent notification-history events for the active node (deployments, image updates, auto-heal actions, scan findings, cloud backup events, system notices) with category icons and relative timestamps. Refreshes every 30 s. New backend endpoints: - GET /api/dashboard/configuration - per-node feature status with locked/ requiredTier markers so the frontend renders upgrade chips without extra calls. The endpoint sits after authGate and before the remote proxy so remote-node requests are transparently forwarded. - GET /api/dashboard/recent-activity?limit=N - thin wrapper over DatabaseService.getNotificationHistory. - GET /api/fleet/configuration - fleet-wide fan-out using the same Promise.allSettled dead-node-tolerant pattern as /fleet/overview. Exposed as the new "Status" tab on the Fleet page (after Snapshots). Shared utilities: - visibilityInterval and formatCount extracted to frontend/src/lib/utils.ts so the three polling hooks and two components share a single copy. * docs(dashboard): fix stale alt text referencing removed historical charts |
||
|
|
801a098a5b |
feat(files): per-stack file explorer (#780)
* feat(files): backend foundation for stack file explorer
Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.
* fix(files): reject bare dot segments in isValidRelativeStackPath
* feat(files): add safe stack-scoped file I/O methods to FileSystemService
Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.
Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.
Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.
* feat(files): frontend API wrappers and Monaco language helper
* fix(files): tighten stackFilesApi error handling and localOnly support
* fix(files): FileSystemService safety and correctness fixes
* feat(files): add file explorer API endpoints to stacks router
* feat(files): FileTree and FileTreeNode components
* fix(files): route security hardening and stream cleanup
* fix(files): FileTree accessibility, icon stroke, stale fetch guard
Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.
* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm
* fix(files): resolve code quality findings in file explorer components
- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing
* test(files): unit tests for binary detection, stack path safety, and file explorer routes
- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
rejects matrix) and FileSystemService stack methods against a real temp dir
(listStackDirectory sort and protection flags, readStackFile text/binary/
oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
file explorer endpoints; covers auth gating, Community-tier 403 gates,
input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths
* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar
* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change
* test(files): add missing test coverage for file explorer routes and service
* feat(files): add Files tab to EditorLayout with StackFileExplorer integration
* fix(files): add defensive activeTab guard to saveFile and discardChanges
* test(files): unit tests for FileTree expand/collapse and FileViewer render modes
Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.
* test(e2e): file explorer community and skipper+ flows
Covers the full file-explorer feature surface in two describe blocks:
Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.
Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.
Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.
* fix(e2e): improve test isolation and selector stability in stack-files spec
Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.
Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.
* docs(files): add stack file explorer documentation
Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.
* fix(docs): use canonical Skipper tier name in file explorer overview card
* fix(files): resolve lint errors blocking CI
Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.
* test(files): fix e2e seeding to work on community-tier CI
Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.
Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
|
||
|
|
dd9d33813b |
feat(deploy-logs): opt-in deploy progress modal with structured log rows (#779)
* feat(notifications): dispatch deploy_failure alert on stack action errors
* feat(terminal): add onReady and onMessage callback props
* feat(deploy-logs): add DeployLogContext with runWithLog API
* feat(deploy-logs): add DeployLogPanel bottom drawer with resize and minimize
* feat(deploy-logs): wire DeployLogContext to App and EditorLayout action runners
* test(deploy-logs): add E2E test for deploy log panel open, failure, and minimize
* docs(deploy-logs): add user-facing and internal architecture docs
* feat(deploy-logs): redesign as opt-in modal with structured log rows
Replace the full-width bottom drawer (DeployLogPanel) with a centered
modal that streams structured log output for deploy, stop, restart,
update, install, and Git apply operations. The modal is disabled by
default; users opt in from Settings -> Appearance.
Core changes:
- New DeployFeedbackContext with runWithLog() API: if opt-in is off,
silently bypasses the UI so all call sites degrade to the existing
toast behavior without code changes.
- composeLogParser.ts: pure parser that strips ANSI escapes and
classifies compose output into stage badges (PULL, BUILD, CREATE,
START, STOP, DOWN, WARN, ERR, LOG). 15 unit tests.
- StructuredLogRow.tsx: memoized row with timestamp, stage badge, and
message. Error rows get a rose left rail; warn rows get a tinted bg.
- DeployFeedbackModal: Dialog-based, max-w-640px/max-h-70vh, elapsed
timer, auto-close 4s on success (hover cancels), persistent on
failure. Raw xterm output collapsible in footer.
- DeployFeedbackPill: minimized state anchored top-right, survives
navigation, click restores modal.
- Wires App Store install (action: install), Git apply (action: deploy),
and Git pull (action: update) in addition to the existing EditorLayout
actions.
- Fixes Terminal.tsx WS URL in generic mode (was connecting to root path
not proxied by Vite; now uses /ws).
- Settings: adds "Show deploy progress modal" checkbox to Appearance.
- Docs: renames deploy-logs.mdx to deploy-progress.mdx; updates
internal architecture doc.
* fix(deploy-logs): connect Terminal in generic mode and move pill to bottom-center
Terminal was passed stackName which routes it to the stack logs WS
(container stdout). In that mode onReady is never called, so the
deployStarted gate never resolves and the compose command never runs.
Remove stackName so Terminal uses generic WS mode, which calls onReady
on open and streams compose output.
Also reposition the minimized pill from top-right to bottom-center
(fixed bottom-6 left-1/2 -translate-x-1/2) per UX feedback.
* docs(deploy-logs): update pill position to bottom center
* test(deploy-logs): rewrite E2E spec for deploy feedback modal
The old spec targeted the removed bottom-drawer DeployLogPanel and used
the wrong field name when calling POST /api/stacks (sent 'name' but the
endpoint reads 'stackName'), causing every test to fail with a 400 before
any UI assertions ran.
Fixes:
- POST /api/stacks body now uses 'stackName' matching the API contract
- All locators updated to target the new DeployFeedbackModal and
DeployFeedbackPill components (data-testid attributes added)
- Added enableDeployFeedback helper to opt-in via localStorage before
each test that expects the modal (feature is off by default)
- Added opt-in OFF test to confirm the modal is suppressed when disabled
- Minimize/expand test now asserts the pill appears and contains the
stack name before clicking to restore the modal
* test(deploy-logs): fix compose file write endpoint in E2E helper
createStackViaApi was calling PUT /api/stacks/:name/files/docker-compose.yml
which does not exist. The correct endpoint is PUT /api/stacks/:name with
{ content } in the body.
* test(deploy-logs): use addInitScript to persist opt-in across reloads
The opt-in flag was set via page.evaluate before setupDeployStack, which
calls page.reload() and loginAs (a second navigation). Although localStorage
should persist across same-origin reloads, the React tree was reading
'false' on remount in CI. Switching to addInitScript guarantees the
localStorage value is set before any page script on every navigation, so
useDeployFeedbackEnabled's useState initializer always sees the right
value when React mounts.
* test(deploy-logs): verify localStorage and re-dispatch event before deploy
Adds syncDeployFeedbackState() called right before each deploy click in
the ON tests. It both verifies localStorage is set (failing the test
loudly with a clear message if not) and re-dispatches the
SENCHO_SETTINGS_CHANGED event to defeat any stale React state after
navigation. If the modal still does not appear with the assertion green,
the issue is downstream of localStorage and we have a clear signal.
* test(deploy-logs): wait for React re-render after dispatching opt-in event
After syncDeployFeedbackState dispatches SENCHO_SETTINGS_CHANGED, React
schedules the state update but does not flush it synchronously. The
click that follows can fire against the stale closure where isEnabled is
still false, so runWithLog takes its early-return path and the modal
never opens. A 200ms wait is enough to let React commit the new state
before the next interaction.
* test(deploy-logs): wait for stack file fetch before clicking deploy
deployStack() in EditorLayout returns early at 'if (!selectedFile)'
without calling runWithLog. selectedFile is set inside loadFile() after
GET /api/stacks/:name resolves. The previous setup clicked the stack in
the sidebar and immediately asked the test to click Deploy, racing the
fetch. CI backend logs confirmed no deploy POST ever fired for the ON
tests, while the OFF test passed only because it asserts non-existence.
Now setup awaits both the stack click and the file response together,
then verifies the action bar's deploy button is visible before returning.
* test(deploy-logs): wait for network idle and capture browser logs
Adds a networkidle wait plus a 500ms settle after the stack click so
React commits selectedFile and any follow-up env/container/backup
fetches drain before the deploy click. Also mirrors browser console
errors and pageerrors into the Playwright output so the next failure
ships with the React stack trace instead of just a 'modal not visible'
message.
* test(deploy-logs): temporary debug logging in runWithLog
Adds a console.log at the entry of runWithLog so we can see in CI logs
whether it is being called and what isEnabled value the closure has.
Also widens the test's console capture to include these debug lines.
This is diagnostic only and will be removed once the root cause of the
modal-not-opening-in-CI failure is identified.
* test(deploy-logs): debug log at deployStack entry to trace click path
Adds console.log at the first line of deployStack handler so we can
confirm in CI whether the click is reaching it at all and what
selectedFile/isStackBusy resolve to. Combined with the existing
runWithLog debug logs, this isolates whether the modal failure is in
deployStack guarding out, runWithLog early-returning, or something
else entirely.
* test(deploy-logs): drop filter, log every browser console msg
The previous filter only emitted error/warning plus the deploy-feedback
substring. The deploy-feedback debug logs never appeared, so we don't
yet know whether the log itself is firing. Remove the filter so the
full console stream shows up in CI.
* test(deploy-logs): app-level console log to verify capture pipeline
If even an unconditional log at App component render time does not
appear in CI browser logs, then the console capture listener is broken
or the dispatched logs are being filtered upstream of Playwright. This
isolates whether the issue is in the production code or the test
harness.
* test(deploy-logs): use testid locator for stack action button
Replaces the regex-based getByRole locator (/Deploy|Start/i) with
getByTestId('stack-deploy-button'). The regex matched something other
than the actual deploy button: backend logs proved no deploy POST ever
fired, and instrumentation confirmed neither deployStack nor runWithLog
ran on click despite the test claiming success.
Adds data-testid='stack-deploy-button' to both the Restart and Start
button branches in EditorLayout's action bar so the same locator works
whether the stack is running or not.
Also drops the temporary debug console.log entries in deployStack,
runWithLog, and App, and restores the test's console listener filter
to only emit error and warning messages.
* test(deploy-logs): park cursor in corner so auto-close countdown fires
After clicking the deploy button, the cursor lands inside the centered
modal. The modal pauses its 4s auto-close countdown on hover, so the
HAPPY test was waiting for a close that never happened. page.mouse.move
to (0,0) parks the cursor outside the modal before the success banner
appears, letting the countdown complete.
* test(deploy-logs): drop redundant loginAs after page.reload
page.reload preserves auth cookies, so the page lands back on the
dashboard without needing a fresh login. The loginAs call after reload
was racing on isLoginPage(): a transient login-page state during page
load made loginAs commit to filling #username, then the dashboard
committed and #username never came back. Playwright's auto-wait then
hung the fill until the test's 120s timeout, which also dragged later
stacks.spec tests down with collateral timeouts.
waitForStacksLoaded is enough to confirm we're on the dashboard with
the sidebar populated before clicking the new stack.
* test(e2e): make loginAs race-safe when login page is a false positive
isLoginPage() reports the page as a login screen if the Login button
locator reports visible at the moment of the check. Under CI load (more
real container deploys from the deploy-log-panel suite), the auth
context can render the login form for one paint, then redirect to the
dashboard. The original code committed to filling #username and hung
until the test timeout when the field was no longer there.
Now the login branch waits up to 2s for #username to actually appear
before filling. If it never appears, we fall through to the dashboard
check instead of hanging.
|
||
|
|
6986b927e3 |
feat(stacks): per-service start/stop/restart lifecycle actions (#778)
* feat(stacks): add per-service start/stop/restart lifecycle routes
Adds POST /:stackName/services/:serviceName/{start,stop,restart} routes
that operate on containers belonging to a single Compose service, using
the same Engine API pattern as the existing stack-level lifecycle routes.
Includes isValidServiceName validator and audit-summary entries for the
three new paths.
* test(stacks): add per-service action route tests
* test(stacks): fix test quality issues in service action tests
* feat(stacks): add per-service lifecycle menu to container cards
* fix(stacks): handle paused container state in service action menu
* docs(stacks): add per-service lifecycle actions documentation
* docs(stacks): add validation screenshots for per-service lifecycle actions
|
||
|
|
819d2a63fc |
feat(stacks): add Schedule task shortcut to stack context and kebab menus (#772)
Right-clicking a stack or opening its 3-dot menu now shows a 'Schedule task' entry in the lifecycle group (visible to paid tiers). Clicking it navigates to Scheduled Operations and opens the New Schedule dialog pre-filled with the stack name and active node, removing the need to navigate there manually and re-enter the target. - Added openScheduleTask to StackMenuCtx; wired in buildMenuCtx using the active node from NodeContext - Extended ScheduledOperationsView with optional prefill/onPrefillConsumed props; a ref-guarded effect calls openCreate() with the prefill data - openCreate refactored to accept an optional prefill arg, removing the duplication between the effect and the existing 'New Schedule' button |
||
|
|
af9cb0aa63 |
feat(auto-update): per-stack auto-update enable/disable toggle (#771)
* feat(auto-update): add per-stack auto-update enable/disable toggle Paid users (Skipper and Admiral) can now opt individual stacks out of scheduled auto-updates from the stack context menu without disabling the global feature. - Add stack_auto_update_settings table (node_id, stack_name) with default enabled=true; four typed DatabaseService accessors with parameterized queries. - Add GET /stacks/auto-update-settings, GET /stacks/:name/auto-update, and PUT /stacks/:name/auto-update (requirePaid + requireAdmin). PUT broadcasts state-invalidate with action auto-update-settings-changed so all open tabs refresh immediately. - Stack DELETE clears the auto-update setting row alongside stack_update_status. - autoUpdateRouter /execute skips disabled stacks before any registry call; skip is recorded in the results array. Manual Update actions are not affected. - Add Auto-update: Enabled/Disabled toggle in the stack inspect group (paid tiers only, hidden for Community, consistent with Auto-Heal). Toggle uses optimistic update with revert-on-error toast. - AutoUpdateReadinessView shows an Auto: Off pill and disables the Apply now button for stacks with auto-updates off. Detection still runs so the readiness card remains visible. - Add 21 backend Vitest tests covering DB round-trips, endpoint auth and tier gates, execute skip for both wildcard and named targets. Add 3 frontend hook tests for toggle visibility and callback behavior. * docs(auto-update): document per-stack auto-update control Add a Per-stack control section to the auto-update readiness page explaining how to disable and re-enable auto-updates for individual stacks, what disabling means (scheduled apply skipped; detection still runs; manual update unaffected), and a troubleshooting entry for scheduled runs not applying to a specific stack. |