Commit Graph

30 Commits

Author SHA1 Message Date
Anso 25586fc8ab feat(ui): add Classic, Smart, and Compact desktop navigation styles (#1642)
* feat(ui): add Classic, Smart, and Compact desktop navigation styles

Introduce a shared app-nav registry and reachable model so TopBar, the
command palette, and mobile menus share one destination source. Smart bar
is the default; Appearance gains a Navigation subsection with quick links.

* fix(e2e): stop clearing top-nav prefs on every reload

The desktop-navigation suite used addInitScript to wipe mode storage,
which re-ran on reload and undid the classic/compact values under test.

* fix(ui): harden nav PR docs and Compact quick-link coverage

Drop the partial docs-refresh import that left missing image assets and a stale Display screenshot, keep navigation-scoped operator docs against main, and add an E2E path that proves Compact quick-link add, persist, and render after reload.

* fix(ui): polish Compact quick links and menu mastheads

Align Smart/Compact menus with Theme chrome, keep pin labels always visible, and drive add capacity from persisted pins (max five) with a trailing + picker and per-pin remove.

* test(e2e): exact-match Compact Networking pin locator

Avoid Playwright strict-mode clash with Actions for Networking.

* fix(ui): simplify Compact quick link removal and fix + button trailing

Remove the (...) dropdown per quick link in Compact mode. Right-click
context menu remains as the sole on-bar removal affordance. Fix the +
add-button to trail quick links rather than pinning to the far right
by removing flex-1 from the quick-link rail.
2026-07-16 21:48:03 -04:00
Anso b70a529656 feat: add developer-mode startup and stack hydration timing (#1619)
* feat: add developer-mode startup and stack hydration timing

Instrument boot-to-list and detail hydration with commit-aligned milestones, truthful request stages, and destination/gateway debug duration logs so performance work is guided by measurements.

* fix: redact stack names and complete hydration request stages

Stop logging stack identifiers in containers debug timing, and record state_dispatch (plus detail fetch spans) so copied reports match the advertised stage breakdown.
2026-07-14 17:24:25 -04:00
Anso 0cd03c6f87 chore(ui): hide Mesh, Fleet Secrets, and Host Console behind experimental discovery (#1624)
Gate Routing, Secrets, Host Console, and Mesh dashboard/settings surfaces on the existing useExperimental readiness flag so immature operator surfaces stay out of the default UI while paid and admin backend gates remain unchanged.
2026-07-13 11:55:50 -04:00
Anso d113004359 feat: add confirmed Take down stack action with optional volume removal (#1599)
* feat: add confirmed Take down stack action with optional volume removal

Expose Take down in the stack header and sidebar with a confirmation dialog
that runs compose down while keeping the stack definition on disk. Optional
volume removal is gated by node capability and stack:deploy permission, with
remote gateway preflight before proxying removeVolumes requests.

Closes #1582

* fix: reset take-down volume checkbox when dialog closes

* test: align getStackMenuVisibility assertions with showTakeDown key

getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown,
but three exhaustive toEqual assertions still listed only the prior four
keys and failed. Add the expected showTakeDown value to each: true for
the partial and exited running-stack cases, false for the self stack.

* test: cover Take down visibility for running non-self stacks

The getStackMenuVisibility assertions exercised the partial and exited
branches and the self-stack guard, but not the raw === 'running' literal
that drives showTakeDown for a normal running stack. Add a case so a
regression dropping 'running' from that check is caught.

* fix: drop Take down from header overflow and wire activity shortcut

Remove duplicate Take down from More actions.

Keep inline button when running, sidebar menu, and Cmd+ArrowDown.

Record stack_taken_down in activity on successful POST /down.
2026-07-09 12:20:13 -04:00
Anso 0f9925e04f feat: block self-stack lifecycle ops with UI and preflight guardrails (#1569)
* feat: block self-stack lifecycle ops with UI and preflight guardrails

Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project.

Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions.

Add SelfStackProtectedDialog and self-managed-stack preflight warning.

Closes #1564

* fix: add missing stackSelfFlags mock to useSidebarContextMenu test

The production hook now reads stackListState.stackSelfFlags[file], but the
test mock did not include it, causing 6 tests to fail with TypeError:
Cannot read properties of undefined (reading 'web.yml').

* fix: harden self-stack protection during startup

Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR.

Align status decoration and route guards on Docker label fallback detection.

Block rollback and service-level stop on the protected self stack.

* fix: add self_stack_location to diagnostics-route expected check IDs
2026-07-06 02:08:16 -04:00
Anso b65daf6845 feat: add notification suppression rules (#1525)
* feat: add notification suppression rules

* fix: restore label routing and routing test mocks for suppression

* fix: allow bell mute shortcuts for history-only notification categories

Suppression rule validation used the routable category whitelist, which rejected history-only categories such as update_started that appear in the bell during stack updates.

* feat: expand Mute Rules UX with compose-first entry points and activity badges

* fix: add missing NodeContext mocks for notification suppression tests
2026-07-02 15:26:48 -04:00
Anso b5810a9b55 feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501)
A batch of UI/UX polish:

- New independent "Reduced motion" appearance setting (separate from Reduced
  effects). Drives framer-motion via MotionConfig and clamps CSS transitions via
  data-motion on <html>; toasts are unaffected. Defaults off (OS preference
  still honored).
- Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted
  word-wrap toggle to the file viewer (default on), and add a fullscreen toggle
  that collapses the Command Center + Logs column so the editor fills the width.
- Create Stack > From Git: remove the nested scroll clamp so the deploy toggle
  and footer are reachable.
- Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only
  Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state
  headings (calm drops the italic), and fix the Actions card body overlapping
  the action-row divider.
- Snapshots: restyle Restore and Restore all to the ghost button design used by
  View / Preview / Download, and right-align the per-stack Restore.
- Settings sidebar: App Store gradient active style and standard font size.
- Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the
  findings change, via a shared fingerprint-keyed hook.
- Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet
  Snapshots tab (FleetView tabs are now controlled to support the deep link).
2026-06-28 06:18:02 -04:00
Anso 60536aa614 feat: sortable resource tables and richer dashboard stack-health columns (#1498)
Make the Resources Images, Volumes, and Networks tables sortable with a
shared useTableSort hook and SortableTableHead, move the Images scan-history
control into the Images tab header, and keep the network List/Topology toggle
anchored with Create Network visible in both modes.

Rework the dashboard Stack health table: drop the redundant Host column, add
sortable Stack/Up/CPU/Mem headers, and add Source (local/git) and Port columns.
The status endpoint now labels each stack with its git/local source, computed
outside the cache so linking changes show immediately.

Extract a reusable CreateNetworkDialog and add a create-network action to the
stack-detail Networking tab.
2026-06-28 05:06:04 -04:00
Anso d9b7911f12 fix: distinguish failed image-update checks from "up to date" (#1470)
* fix: distinguish failed image-update checks from "up to date"

The image-update detector collapsed every failure (registry unreachable,
missing auth, rate limit, unresolved local digest) into hasUpdate:false and
dropped the captured reason, so a failed check was indistinguishable from a
current image and never raised a notification, even while a manual stack
update still pulled a newer image.

Detection now records a tri-state per stack (ok / partial / failed) with the
failure reason, exposed via a new GET /api/image-updates/detail (the boolean
GET / is unchanged so fleet aggregation is unaffected). A fully-failed check
preserves the last known has_update, so a transient outage neither erases a
real update nor flaps the notification state. The sidebar shows a muted
"couldn't check" indicator with the reason on hover, and the Update board
lists stacks whose check failed in a "could not be checked" advisory.

Detector hardening: the manifest digest lookup issues HEAD first (falling back
to GET) so it no longer draws down Docker Hub's anonymous pull-rate budget, and
local RepoDigest matching is normalized so official library/* images resolve
their digest instead of falling through to a silent "no update".

* fix: preserve confirmed updates through partial checks; tighten failure surfacing

Address review findings on the tri-state image-update detection:

- A partial check (some images errored) no longer erases a previously
  confirmed update; only a fully-ok check can lower has_update, so a single
  image's registry blip cannot drop the stack's update and re-fire the
  notification on recovery. Adds a regression test.
- The image-level catch stores getErrorMessage(e) rather than raw String(e),
  since that value surfaces verbatim in the sidebar tooltip and readiness
  advisory.
- useImageUpdates and the readiness detail fetch now log unexpected non-ok
  responses instead of silently leaving stale state.
- Remove an unused checkFailedCount derivation (the row indicator is driven by
  the checkStatus prop).
- Reword the recordStackCheckFailure docstring and the HEAD-first comment.
2026-06-26 16:16:34 -04:00
Anso f1f64ec7f6 feat: show container name in structured log output (#1452)
* feat: show container name in structured log output

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

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

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

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

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

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

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

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

Refine the Security page around the existing design system and add the
data the dashboard needs.

- Overview leads with four charts (30-day risk trend, severity donut, top
  exposed images, findings by type); the signal-rail counts become a
  secondary summary, and the scanner and deploy-enforcement posture follow.
- Images becomes a recessed table with search, a severity filter, sortable
  columns, a last-scan column, and inline scan actions; the findings cell is
  clickable into the scan sheet, and the per-row cursor tooltip is dropped
  where the columns already carry that information.
- Policies puts deploy-enforcement first, collapses the policy packs into an
  accordion, and uses the standard primary button for Add policy.
- Suppressions and acknowledgements move their titles and Add buttons outside
  the cards, matching the Fleet tab layout.
- History switches from the detail sheet to an inline table (search, sortable
  columns, two-scan compare, pagination); the now-unreachable scan-history
  overlay is removed.
- Add GET /api/security/overview/trend, a node-scoped daily critical/high
  rollup backing the risk-trend chart.
- Extract the shared image-scan hook and the severity classifier, and harden
  the overview data fetch so a malformed non-critical response can never read
  as a clean security state.

* fix: treat malformed Security responses as errors, not empty or clean states

Address an independent review of the data-fetch paths so a 200 with an
unexpected shape can never read as a benign "no findings" view.

- SecurityView: validate that the image-summaries body is a scan-summary map; an
  unexpected shape now sets the error state instead of an empty map. Isolate the
  trend fetch in its own self-catching promise so a transport failure on the
  non-critical chart can no longer poison the overview or summaries error state.
- useImageScan: only a "completed" poll counts as success (a malformed or unknown
  status now throws), and a failed post-scan summaries refresh is logged instead
  of silently dropped.
- HistoryTab: a 200 whose body lacks an items array is treated as an error, not
  an empty "no completed scans" list.
2026-06-12 14:35:03 -04:00
Anso 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.
2026-06-12 10:49:36 -04:00
Anso f253276303 fix: make published port links open reliably (#1359)
* fix: make published port links open reliably

Container published-port links now render as real anchors that open on
desktop and mobile, replacing ad hoc window.open calls. A shared service
URL builder centralizes host resolution (configured host, remote node
API host, or the browser host, with no browser fallback for unreachable
remote nodes), protocol selection (HTTPS for port 443), and known app
sub-paths (Plex opens its web path). The container port mapping itself is
the link, with a Copy URL action beside it. The stack Open App menu and
the anatomy panel footer use the same builder, and the menu only offers
Open App when a reachable URL can be built.

* fix: skip UDP ports and scope known-app paths to the container port

Two follow-ups to the published-port links:

- The known-app path (Plex web sub-path) was borrowed from the published
  host port even when the container port was known and unregistered, so a
  non-Plex service published on host port 32400 wrongly inherited it. The
  container-port lookup now wins when known; the published-port lookup stays
  a fallback for the menu and anatomy footer, which only have the host port.

- UDP ports could surface as HTTP links. The backend now carries the port
  protocol through both container-mapping paths and skips UDP when choosing
  the main web port (extracted as selectMainWebPort), and the container card
  filters UDP before selecting a port to link.
2026-06-11 16:37:53 -04:00
Anso 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.
2026-06-11 10:33:57 -04:00
Anso 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.
2026-06-10 10:12:24 -04:00
Anso 865d792874 feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral)

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

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

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

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

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

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

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

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

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

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

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

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

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

The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
2026-06-04 17:45:53 -04:00
Anso 7d7e0a6264 feat(fleet-sync): gate sync-status polling on admin role (#1271)
* feat(fleet-sync): gate sync-status polling on admin role

The fleet sync status poll requires a paid admin on the server, but the
useFleetSyncStatus hook only checked the paid tier. A paid non-admin who
opened the Fleet Status tab or Settings, Nodes would poll the endpoint
every 30 seconds and get a 403 each time, with the policy sync rows
silently never loading. Gate the hook on both paid tier and admin role
so the client mirrors the server and the request only fires when it can
succeed.

Also add a one-time log when an instance transitions from control to
replica, and a real-database integration test covering the full receive
path: role flip, identity and watermark persistence, wholesale row
replacement with local rows preserved, replica read-only enforcement,
and the stale-watermark and control-anchor rejection branches. Includes
a hook test asserting the gate across the paid and admin matrix.

* fix(fleet-sync): drop sync-status rows that resolve after the gate flips

A sync-status fetch started while the user was an eligible paid admin
could resolve after the user lost eligibility mid-flight (role or tier
change), re-populating the status rows that the gate-false path had
already cleared. That briefly re-enabled the policy-sync rows and the
anchor-mismatch banner for a now-ineligible client.

Track the latest gate state in a ref and skip the state writes when the
fetch resolves after eligibility was lost, so the rows stay empty under
role and tier transitions. Adds a regression test for the late-resolve
path.

* fix(security): sanitize control identity before logging replica transition

The control to replica transition log interpolated the control fingerprint
straight from the inbound sync payload, so a sibling pushing a forged
controlIdentity with embedded CR/LF could split or spoof log lines. Wrap the
value in the shared sanitizeForLog helper, matching every other tainted log
sink, so control characters are stripped before the entry is written.
2026-06-01 17:26:11 -04:00
Anso 98049e3b1c fix(global-search): surface unreachable nodes and harden the command palette (#1253)
* fix(global-search): surface unreachable nodes and harden the command palette

The command palette discarded the cross-node search hook's failedNodes, so a search run while a fleet node was down silently returned partial results with no sign a host was skipped. It now renders an "N nodes unreachable" line and still shows the stacks it could gather.

The shared cross-node search hook fetched every node's stack list and statuses on every keystroke. It now fans out once per search session and filters the cached inventory client-side as the query is refined, cutting per-keystroke fleet traffic. A 200 response with an unparseable status body degrades stacks to unknown instead of failing the whole node.

The palette now owns result matching (cmdk's built-in fuzzy filter is disabled), so Pages, Nodes, and Stacks match by case-insensitive substring in a deterministic order and the 50-row cap applies to the real match set rather than a re-sorted slice.

Adds unit coverage for the hook and palette, plus a Playwright journey spec.

* fix(global-search): clear stale cross-node results when the active node changes

When excludeNodeId changed mid-search (the sidebar switches it on active-node change), the hook started a fresh fanout but left the previous session's inventory and failedNodes visible until the refetch resolved, so the newly active node could briefly appear under the other-nodes results or a stale unreachable warning could persist. The new session now drops prior results synchronously before refetching.
2026-05-29 19:04:39 -04:00
Anso 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.
2026-05-28 14:16:46 -04:00
Anso 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.
2026-05-26 11:08:33 -04:00
Anso 2a29fed117 feat(labels): harden Stack Labels (gate parity, abort, dry-run, cap) (#1232)
* feat(labels): harden stack-labels surface (gate parity, abort, dry-run policy, cap)

- Cap labelIds array at MAX_LABELS_PER_NODE on PUT /api/stacks/:name/labels.
- Hide sidebar Labels submenu and Settings mutation buttons for roles
  without stack:edit, matching the backend requirePermission gate.
- Break the per-label bulk-action loop on req.aborted so cancelled requests
  release the per-node lock once the in-flight op completes.
- Reset saving state on success in LabelInlineCreateForm so the form stays
  interactive when reused outside the kebab/context menus.
- Invoke enforcePolicyPreDeploy inside the dry-run deploy branch and report
  blocked stacks honestly; previously dry-run skipped the gate and would
  predict success for stacks the real deploy would block.

* fix(labels): split sidebar gate so inline create matches unscoped backend guard

Codex review of #1232 surfaced a parity miss: POST /api/labels is guarded
by unscoped requirePermission('stack:edit'), but the sidebar inline "New
label" entry was gated by canEditLabels (per-stack scoped). An Admiral
user with only scoped grants on a stack could toggle existing labels but
the inline create request would 403.

Splits the sidebar gate:
- canEditLabels (scoped) keeps gating the Labels submenu trigger and toggle
  items, matching PUT /api/stacks/:name/labels.
- canCreateLabels (unscoped) now gates the inline "New label" entry,
  matching POST /api/labels.

Also restores the swallow-catch in LabelInlineCreateForm. The earlier
catch-to-finally change in #1232 let the rethrow from createAndAssignLabel
surface as an unhandled event-handler rejection in the browser console.
Parents already toast on failure; swallowing in the form is the intended
behavior with the finally reset still in place.
2026-05-25 23:48:12 -04:00
Anso 2e82eb44fe feat(fleet): multi-mode topology with label grouping and persisted positions (#1054)
The Topology view now offers three layouts so the canvas adapts to how the
fleet is organised, not the other way around:

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

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

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

Includes unit tests for the layout module (hub/grouped/free coverage) and
the preferences hook (round-trip plus corrupt-JSON fallback).
2026-05-15 08:59:52 -04:00
Anso b52323036b fix: harden stack label permissions (#1036)
* fix: harden stack label permissions

* fix: avoid test db init from debug logging
2026-05-13 11:56:22 -04:00
Anso 1f8ce773ff feat(ui): hide paid features from community-tier dashboard (#891)
* feat(ui): hide paid features from community-tier dashboard

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

Concretely:

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

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

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

The community-clean-ui change removes the "Upgrade to unlock upload"
pill from the file explorer. Update the matching e2e assertion to
verify the upload control is not rendered, instead of waiting for a
pill that no longer exists.
2026-05-03 01:17:02 -04:00
Anso 72919ccd1b chore(frontend): polish error boundaries and dismissal sync (#877)
Bundles three small follow-ups deferred from the recent lazy-loading
and gate-refactor PRs:

ErrorBoundary visual harmonization. The top-level boundary used a red
banner with custom button styling that no longer matched the glass-
card aesthetic the lock cards and LazyBoundary settled on. Replace
with the same glass-card + AlertTriangle layout. The user already
knows something broke when this fires; a calm card with a clear Try
again CTA is more actionable than the louder treatment, and a
consistent recovery surface across both boundaries means a user never
sees two different "something went wrong" treatments depending on
which boundary catches the error.

Keyboard focus on boundary trip. When either boundary trips, focus
typically falls back to <body> because the throwing subtree
unmounted. Keyboard users would have to tab from the top to reach the
recovery action. Add a ref on the CTA button and a componentDidUpdate
gate that focuses it on the false-to-true hasError transition. The
gate fires once per trip, not on every error-state re-render, so a
user who tabbed elsewhere within the card does not get focus stolen
back. Verified the gate also fires on a re-error after Try again
(setState({hasError:false}) re-renders with prevState.hasError=false,
the next throw flips to true and the transition condition triggers).

Cross-tab dismissal sync in useDismissalState. The hook previously
read localStorage only in the lazy initializer, so dismissing in tab
A did not propagate to tab B until tab B re-mounted. Add a useEffect
that listens for storage events on the configured key. The browser
fires storage events only in OTHER tabs than the one that wrote the
change, so this handles the tab B receives tab A's dismiss case;
same-tab updates flow through setDismissed directly, unchanged.
Malformed event.newValue (NaN, empty string) defaults to dismissed=
false, the conservative outcome.

Adds 4 new vitest cases for the storage-event paths: recent
timestamp, null newValue (restore), unrelated key, and stale
timestamp.
2026-05-02 04:15:49 -04:00
Anso 677f0778e7 refactor(frontend): extract shared parts from PaidGate and AdmiralGate (#876)
PaidGate and AdmiralGate were ~95% identical: same state machine
(unlocked / compact-blurred / dismissed-pill / full-upsell-card), same
24h localStorage-backed dismissal logic, differing only in license
predicate, dismiss-storage key, icon, and copy strings. Two reviewers
flagged the duplication after PRs #874 and #875 landed identical
changes in both files; the rule-of-three threshold is met.

Extract the shared parts compositionally rather than as one big config-
driven gate (the latter would just inline both gates' contents behind
8 props of indirection):

- frontend/src/hooks/useDismissalState.ts owns the localStorage
  dismissal pattern. Lives under hooks/ to dodge the
  react-refresh/only-export-components lint rule that would fire if a
  hook coexisted with components in the same file. Validates the stored
  timestamp via Number.isFinite so a hand-edited or stale-extension
  garbage value defaults to "show the upsell" instead of crashing.
- frontend/src/components/tierUpsell.tsx exports CompactBlurredLock,
  DismissedPill, and FullUpsellCard plus a shared TierGateProps
  interface. The compact-mode JSDoc lives on TierGateProps so the doc
  string lives in exactly one place.

PaidGate and AdmiralGate become ~50-line compositions reading like a
state machine. Public API of both gates is byte-stable: all 13+
consumers across the app continue to use <PaidGate featureName="X">
and <AdmiralGate featureName="X" compact> exactly as before.

Two pre-existing security/polish issues fixed in passing while there
is one source of truth for the affected JSX:

- FullUpsellCard's window.open now passes 'noopener,noreferrer' to
  prevent the destination tab from accessing window.opener (reverse
  tabnabbing).
- The Number.parseInt + Number.isFinite guard replaces a bare
  parseInt that would have happily accepted any prefix-numeric input.

Adds a Vitest spec for useDismissalState covering: empty / recent /
expired / non-numeric storage values, dismiss() / restore() side
effects, the 24h boundary on fresh mount, and key independence.
2026-05-02 04:01:54 -04:00
Anso 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
2026-04-25 11:49:01 -04:00
Anso 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.
2026-04-25 10:50:21 -04:00
Anso 75370d8fce feat(sidebar): inline label create, live sync, and kebab submenu parity (#706)
Portal-wrap DropdownMenuSubContent so the kebab Labels submenu renders outside
the clipped dropdown container, matching ContextMenuSubContent and fixing the
empty/broken kebab submenu.

Thread onLabelsChanged from LabelsSection through SettingsModal to
EditorLayout so label creates and deletes in Settings propagate to the
sidebar menus without a page refresh.

Add an inline "New label" form in both the kebab and context menu label
submenus that creates and assigns a label in one interaction, removing the
Settings round-trip from the label assignment flow.
2026-04-20 09:57:20 -04:00
Anso 370b67d7ec feat(sidebar): cockpit redesign with grouped stacks and activity footer (#702)
* chore: ignore .superpowers/ brainstorm scratch dir

* feat(sidebar): add useStackMenuItems hook with grouped menu model

Pure transform hook that converts StackMenuCtx into four ordered MenuGroup
arrays (inspect, organize, lifecycle, destructive). Shared type contract in
sidebar-types.ts gives both the ContextMenu and DropdownMenu a single source
of truth so they cannot drift. Covered by 8 unit tests.

* refactor(sidebar): stabilize useStackMenuItems memoization deps

Destructure menuVisibility flags into primitive deps so inline object
literals from callers do not defeat memoization. Add a test confirming
isBusy disables all lifecycle items.

* feat(sidebar): add usePinnedStacks hook with per-node localStorage

* refactor(sidebar): stabilize usePinnedStacks eviction signal and isPinned dep

Change evictedOldest shape to { file, seq } so consumer effects re-fire on
repeated evictions. Narrow isPinned's useCallback dep to the current node's
pinned list so it only rebinds on local changes. Add test for eviction
side-effect and a second test for the seq counter.

* feat(sidebar): add useSidebarGroupCollapse hook with per-node keys

* refactor(sidebar): tighten useSidebarGroupCollapse effect ordering

Collapse the two write/read effects into a single skip-next-write ref
pattern so switching nodes no longer writes the previous node's map under
the new key before hydration. Also skip the no-op mount write. Add test
for setCollapsed.

* feat(sidebar): add row + group-header style helpers

* feat(sidebar): add SidebarBrand with mono kicker + serif hero

* feat(sidebar): add SidebarActions wrapper for create + scan

* feat(sidebar): extract SidebarSearch with kbd pill

* feat(sidebar): add StackRow with cyan-rail active state

* refactor(sidebar): dedupe tooltip markup in StackRow, widen test coverage

Extract a local RowTooltip helper so the update and git-pending branches
share the CursorProvider scaffolding. Add four behavioral tests covering
click, keyboard activation, kebab stop-propagation, and the busy loader
branch.

* feat(sidebar): unify context + kebab menus via useStackMenuItems

* feat(sidebar): add StackGroup with collapse and pinned variant

* feat(sidebar): add StackList with pinned + label groups

* feat(sidebar): add SidebarActivityTicker with idle fallback

* feat(sidebar): add StackSidebar container composing the regions

* feat(sidebar): replace sidebar block with StackSidebar composition

* docs(sidebar): add stack sidebar feature page with screenshots

* fix(sidebar): satisfy react-hooks purity and memoization rules

* fix(sidebar): restore "Sencho Logo" alt text for E2E selector
2026-04-19 21:45:01 -04:00