Commit Graph

394 Commits

Author SHA1 Message Date
Anso fac753a808 chore(frontend): remove featured-blueprint banner from catalog (#958)
The "Featured · most-deployed" banner duplicated information already
present on the blueprint tile grid below it and added vertical clutter
to the Deployments tab. Drop the callout and the useMemo that picked
its target.
2026-05-06 21:59:05 -04:00
Anso 7fe90d9f3a feat(blueprints): capture compose snapshot before stateful eviction (#957)
Wire the snapshot_then_evict withdraw mode to actually persist the
blueprint's compose YAML to fleet_snapshots before running the
eviction. The mode previously recorded intent only. Capture failure
aborts the eviction with HTTP 500 rather than silently falling
through to a destructive withdraw.

Volume bytes remain out of scope: the snapshot holds the compose
definition only. UI copy and the Blueprints docs (Withdraw note,
Migrating stateful data section, two new Troubleshooting entries)
clarify that operators must move volumes by hand if they need the
data on another node.

Adds 9 route-level tests covering the success path, snapshot DB
write failure, orphan-row cleanup when insertSnapshotFiles fails,
empty compose_content, evict_and_destroy unchanged, stateless
unchanged, evict_blocked gate, omitted confirm field, and bad
confirm value.
2026-05-06 21:58:45 -04:00
Anso e2edc6ceb8 chore(frontend): expose Routing and Deployments tabs by default (#955)
Drop the SENCHO_EXPERIMENTAL gate from the Fleet Routing and
Deployments tabs so they ship in the default UI. Both have been
verified production-ready and promoted out of experimental.

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

Removes the SoonBadge component and "Coming soon" pill from the
preview placeholder so the tab bar shows only ready, tier-appropriate
tabs without ambiguous SOON labels.
2026-05-06 21:18:31 -04:00
Anso bc1077c722 refactor(frontend): migrate Blueprint dialogs to Modal chrome (#954)
Final phase E pass over the experimental blueprint surfaces (gated by
SENCHO_EXPERIMENTAL).

- BlueprintDetail's destructive delete dialog -> Modal +
  ModalDestructiveHeader. Kicker BLUEPRINT · DELETE · IRREVERSIBLE.
  Keeps the type-to-confirm input pattern in the body and uses a
  destructive-variant Button as the primary footer action
- DeploymentsTab's New Blueprint editor -> Modal + ModalHeader.
  Kicker BLUEPRINTS · NEW. The wide editor sits inside ModalBody;
  cancel/submit live inside the BlueprintEditor itself
2026-05-06 20:20:27 -04:00
Anso d76b54201c refactor(frontend): migrate settings AlertDialog confirms to ConfirmModal (#953)
Four settings panels with per-row destructive confirms now share the
§10 ConfirmModal chrome. Each per-row AlertDialog is replaced by a
single parent-level ConfirmModal driven by per-target state, with the
row's button calling setTarget(item).

- UsersSection: Reset 2FA confirm (default variant) + Delete user
  confirm (destructive)
- CloudBackupSection: Delete cloud snapshot confirm (destructive)
- RegistriesSection: Delete registry confirm (destructive)
- ApiTokensSection: Revoke token confirm (destructive)

Drop the unused AlertCircle import in CloudBackupSection that the
old AlertDialog header relied on.
2026-05-06 20:19:57 -04:00
Anso eb2d10af71 refactor(frontend): migrate Bash, Log, and Deploy feedback dialogs (#952)
- modal.tsx Modal now accepts a showClose prop that passes through to
  DialogContent, so callers that render their own close affordance
  (DeployFeedbackModal) can suppress the Radix close button
- DeployFeedbackModal -> Modal with showClose=false. Custom header,
  scroll body, embedded terminal, and footer stay in place; Modal
  provides the consistent overlay/blur chrome. DialogTitle is still
  imported from the dialog primitive purely as an sr-only
  accessibility wrapper, since this status panel doesn't fit the §10
  ModalHeader chrome
- BashExecModal -> Modal + ModalHeader. Kicker BASH · {CONTAINER}.
  The xterm container sits in the body
- LogViewer -> Modal + ModalHeader. Kicker LOGS · {CONTAINER}. The
  scroll region sits in the body
2026-05-06 20:19:34 -04:00
Anso 9ab7819b24 refactor(frontend): migrate Stack/Fleet confirms and Git source dialog (#951)
* refactor(frontend): migrate Stack/Fleet confirms and Git source dialog

- LocalUpdateConfirmDialog -> ConfirmModal with kicker LOCAL · UPDATE
- GitSourcePanel form -> Modal with kicker {STACK} · GIT SOURCE; the
  custom three-button footer (Remove / Pull now / Save) stays as-is
  since ModalFooter's two-slot pattern doesn't fit a left-aligned
  destructive action; ModalHeader provides the canonical chrome
- GitSourcePanel remove confirm -> destructive ConfirmModal with
  kicker {STACK} · GIT · DISCONNECT

* test(e2e): scope git-source dialog assertion to the heading

The migration adds a kicker rune "<STACK> · GIT SOURCE" inside the
dialog. The previous locator getByText('Git Source', { exact: false })
matched both the kicker (uppercase, mono) and the italic-serif title,
producing a strict-mode violation. Use getByRole('heading') to target
the title specifically.
2026-05-06 20:19:07 -04:00
Anso fe06838bf7 refactor(frontend): migrate FleetSnapshots dialogs to Modal chrome (#950)
Two AlertDialog instances move onto §10 Modal primitives:

- Snapshot delete confirm -> destructive ConfirmModal hoisted to a
  single parent-level instance driven by confirmDeleteId state. Each
  row's trash button now calls setConfirmDeleteId(snapshot.id) instead
  of wrapping its own AlertDialog
- RestoreButton's per-restore confirm -> ConfirmModal with the
  redeploy checkbox in the body slot. Promise-aware onConfirm closes
  the dialog from finally
2026-05-06 20:18:33 -04:00
Anso 0442bd29b3 refactor(frontend): migrate NodeManager dialogs to Modal chrome (#949)
* refactor(frontend): migrate NodeManager dialogs to Modal chrome

Bring all four NodeManager dialogs onto §10 Modal primitives:

- Add node form -> Modal at lg, kicker NODES · ADD LOCAL or
  NODES · ADD REMOTE depending on the form's type radio. The Add
  button is now a normal button that calls setCreateOpen(true)
  rather than a DialogTrigger
- Edit node form -> Modal at lg, kicker NODES · EDIT
- Pilot enrollment dialog -> Modal at xl, kicker NODES · PILOT ENROLLMENT
- Delete confirm -> destructive ConfirmModal, kicker
  NODES · DELETE · IRREVERSIBLE

handleDelete now closes from finally so the dialog clears on errors
too, matching the ConfirmModal Promise-aware contract.

* fix(frontend): make ModalBody scrollable, scope nodes test locator to dialog

Two related fixes for the Add Node modal regression on small viewports:

1. ModalBody now caps at max-h-[calc(85vh-12rem)] with overflow-y-auto.
   Tall forms that would push the footer offscreen on a 720px viewport
   (Add Node has 6 form sections plus header and footer) now scroll
   inside the body while the cyan-rail header and Cancel/Submit footer
   stay anchored. Benefits every form modal, not just NodeManager.

2. e2e/nodes.spec.ts now scopes the submit-button locator to
   getByRole('dialog'), removing the .last() pattern. The previous
   approach worked when the modal was guaranteed to render after the
   trigger in DOM order, but it doesn't survive an offscreen footer.
2026-05-06 20:18:13 -04:00
Anso 7c2cbd0882 refactor(frontend): migrate Routing and Security dialogs to Modal chrome (#948)
NotificationRoutingSection:
- Routing rule create/edit form -> Modal at lg with kicker
  ROUTING · NEW RULE or ROUTING · EDIT RULE
- Per-row delete AlertDialog -> a single parent-level destructive
  ConfirmModal driven by deleteRouteId state, opened by each row's
  trash button (no more inline AlertDialogTrigger pattern)
- handleDelete tightened to close from finally so the dialog clears on
  errors too

SecuritySection:
- Policy create/edit form -> Modal at md with kicker
  SECURITY · NEW POLICY or SECURITY · EDIT POLICY
- Policy delete confirm -> destructive ConfirmModal with kicker
  SECURITY · DELETE · IRREVERSIBLE
- Trivy uninstall confirm -> destructive ConfirmModal with kicker
  TRIVY · REMOVE · IRREVERSIBLE
- handleDelete already used finally; handleUninstallTrivy already
  closes the dialog before awaiting, so it works with the
  ConfirmModal Promise-aware path
2026-05-06 20:17:18 -04:00
Anso 165caf2102 refactor(frontend): migrate Labels and Suppressions dialogs to Modal chrome (#947)
Bring the two settings panels onto §10 Modal primitives.

LabelsSection:
- Create/edit form -> Modal at sm with kicker LABELS · NEW or
  LABELS · EDIT
- Delete confirm -> destructive ConfirmModal with kicker
  LABELS · DELETE · IRREVERSIBLE
- handleDelete now closes from finally so the dialog clears on errors
  too (the new ConfirmModal Promise-aware behaviour keeps it open until
  state closes it)

SuppressionsPanel:
- New suppression form -> Modal at md with kicker SUPPRESSIONS · NEW
- Remove confirm -> destructive ConfirmModal with kicker
  SUPPRESSIONS · REMOVE · IRREVERSIBLE
- handleDelete already closed from finally; no change needed there
2026-05-06 20:16:53 -04:00
Anso 141683d240 refactor(frontend): migrate ScheduledOperationsView dialogs to Modal chrome (#946)
Bring the create/edit task form and the delete confirm onto the §10
Modal primitives:

- Create/edit form -> Modal + ModalHeader + ModalBody + ModalFooter at
  size lg, with kicker SCHEDULER · NEW TASK or SCHEDULER · EDIT TASK
- Delete confirm -> destructive ConfirmModal with kicker
  SCHEDULER · DELETE · IRREVERSIBLE and a tighter body

Tighten handleDelete to close the dialog from finally so the
ConfirmModal Promise-aware behavior closes cleanly on both success and
error. handleSave intentionally keeps the form open on error so the
user can fix and retry.
2026-05-06 20:16:18 -04:00
Anso f0d03bc58f refactor(frontend): migrate ResourcesView dialogs to Modal chrome (#945)
Replace raw Dialog/AlertDialog usage in ResourcesView with the §10 Modal
primitives:

- Prune confirms (managed and all scopes) -> destructive ConfirmModal
  with single shared kicker and scope-conditional title/hint/label
- Delete confirms (image, network, volume) -> destructive ConfirmModal
- Bulk purge of unmanaged containers -> destructive ConfirmModal
- Create network form -> Modal + ModalHeader + ModalBody + ModalFooter

Also enforce single-line kickers in the modal primitive by adding
whitespace-nowrap to the header kicker, so the rune at the top never
wraps even at sm width.

Tighten handlePurgeOrphans to close the dialog in finally rather than
only on success, since the new ConfirmModal Promise-aware behaviour
keeps the dialog open until state closes it.
2026-05-06 19:15:33 -04:00
Anso 13cb49ce3a fix: harden MonitorService evaluation loop (#942)
- Change network metrics (net_rx/net_tx) from cumulative totals to MB/s rates
  so alert thresholds are operationally meaningful
- Wrap all external calls (Docker stats, systeminformation, docker df) in
  10-second timeout via Promise.race to prevent hung operations from
  blocking the evaluation loop indefinitely
- Parallelize host CPU/RAM/disk queries with Promise.all to bound worst-case
  latency at 10 seconds instead of 30
- Use epsilon comparison for == operator so floating-point metric values
  can match integer thresholds
- Clean up stale entries in activeBreaches and previousNetworkStats maps
  after rules are deleted or containers stop
- Add standard INFO logging for alert firings and WARN logging for slow
  cycles and timeouts; add diagnostic cycle timing and breach-count log
2026-05-06 13:28:07 -04:00
dependabot[bot] 79b4822dc1 chore(deps): bump the all-npm-frontend group in /frontend with 5 updates (#939)
Bumps the all-npm-frontend group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [yaml](https://github.com/eemeli/yaml) | `2.8.3` | `2.8.4` |
| [eslint](https://github.com/eslint/eslint) | `10.2.1` | `10.3.0` |
| [globals](https://github.com/sindresorhus/globals) | `17.5.0` | `17.6.0` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.1.0` | `29.1.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.1` | `8.59.2` |


Updates `yaml` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.3...v2.8.4)

Updates `eslint` from 10.2.1 to 10.3.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.3.0)

Updates `globals` from 17.5.0 to 17.6.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.5.0...v17.6.0)

Updates `jsdom` from 29.1.0 to 29.1.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.0...v29.1.1)

Updates `typescript-eslint` from 8.59.1 to 8.59.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.2/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: eslint
  dependency-version: 10.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: globals
  dependency-version: 17.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: typescript-eslint
  dependency-version: 8.59.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anso <dev@saelix.com>
2026-05-06 13:14:53 -04:00
Anso 0c3ce4b224 feat: implement file explorer context menus and dialogs (#934) 2026-05-06 08:46:02 -04:00
Anso 166ba21ff1 feat(sidebar): filter toggle + action button padding fix (#933)
* feat: open security basics, manual fleet ops, and basic fleet management to Community

Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

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

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

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

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

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.

* feat: add node last-contact tracking, fleet latency, and stack-restart summary

- DatabaseService: add last_successful_contact column to nodes table via
  idempotent migration; expose updateNodeLastContact() and getStackRestartSummary()
  methods; include the column in NODE_COLUMNS so getNodes/getNode return it
- fleet.ts: record latency_ms and last_successful_contact on each remote
  node overview fetch; pilot-agent nodes surface pilot_last_seen instead;
  pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls
- dashboard.ts: replace /recent-activity with /stack-restarts endpoint that
  groups notification_history events by stack and category (crash/autoheal/manual)
  over a configurable window (default 7 days, max 30)

* refactor(dashboard): remove redundant per-route authMiddleware

All routes under /api/ are covered by the global auth gate in app.ts.
The inline authMiddleware arguments on /configuration and /stack-restarts
were redundant with that gate and inconsistent with every other route in
the file. Remove them and drop the now-unused import.

* refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping

- Capture a single completedAt timestamp in fetchRemoteNodeOverview to
  eliminate two separate Date.now() calls and ensure latency_ms and
  last_successful_contact are derived from the same instant
- Inline the redundant contactedAt variable; use completedAt directly
- Move stack-restart aggregation from JS into SQL (GROUP BY stack_name
  with CASE/SUM counts), replacing the Map loop in the route handler
- Export StackRestartSummary interface from DatabaseService and remove
  the duplicate local definition in dashboard.ts; handler now returns
  the query result directly
- Add last_successful_contact normalization in decryptNodeRow, mirroring
  the existing pilot_last_seen pattern
- Add authGate reliance comment above dashboardRouter route handlers

* feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map

- Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time
  reachability, latency, and container count per registered node
- Local-only installs: shows Stack Restart Map — 7-day restart frequency
  per stack grouped by crash / auto-heal / manual category
- Conditional wrapper (DashboardActivityCard) switches states automatically
  when the node list changes, with no page reload required
- Deletes RecentActivity card and hook (duplicated data already in Recent Alerts)
- Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse

* fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel

* fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash

- Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview
  interface and to both the pilot-agent and HTTP-proxy return paths in
  fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch
  can fire correctly
- Remove the private formatRelativeTime from RecentAlerts.tsx and use
  the shared implementation from lib/utils, converting the millisecond
  timestamp at the call site
- Replace the em dash in getLatencyLabel with 'n/a' per project rules

* feat(sidebar): add filter toggle and fix action button padding

- Add collapsible filter chip row in SidebarFilterChips with Plus/Minus
  toggle button pinned to the far right of the row
- Persist expanded/collapsed state across reloads via localStorage key
  sencho:sidebar:filters-visible (default expanded)
- Active filter chip stays applied while the row is hidden; hiding is
  purely a visual noise reduction and does not reset the filter
- Fix flex overflow in SidebarActions by adding min-w-0 to the flex-1
  wrapper around the Create Stack slot, restoring the correct 16px right
  padding on the scan button
- Cap displayed counts at 99+ to bound chip render width; chips use
  min-w-0 overflow-hidden instead of shrink-0 so they flex-shrink
  proportionally rather than hard-clipping the last chip

* test: resolve failing CI tests and act warnings (#933)

- Fix SidebarActivityTicker to properly filter out stale activities
- Update StackGroup test to handle pinned star prefixes
- Refactor useNotifications tests to use waitFor and suppress un-awaitable act() warnings

* fix: add missing beforeAll/afterAll imports to useNotifications test

The tsc build step failed because beforeAll and afterAll were used but
not imported from vitest, unlike the other vitest functions already in
the import statement.

* fix: resolve ESLint errors in test and sidebar files

Replace any[] with unknown[] in useNotifications.test.ts console.error
mock, and add a comment to the empty catch block in StackSidebar.tsx.
2026-05-06 08:43:46 -04:00
Anso 775fab7d64 feat(dashboard): replace duplicate Recent Activity card with Fleet Heartbeat / Stack Restart Map (#932)
* feat: open security basics, manual fleet ops, and basic fleet management to Community

Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

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

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

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

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

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.

* feat: add node last-contact tracking, fleet latency, and stack-restart summary

- DatabaseService: add last_successful_contact column to nodes table via
  idempotent migration; expose updateNodeLastContact() and getStackRestartSummary()
  methods; include the column in NODE_COLUMNS so getNodes/getNode return it
- fleet.ts: record latency_ms and last_successful_contact on each remote
  node overview fetch; pilot-agent nodes surface pilot_last_seen instead;
  pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls
- dashboard.ts: replace /recent-activity with /stack-restarts endpoint that
  groups notification_history events by stack and category (crash/autoheal/manual)
  over a configurable window (default 7 days, max 30)

* refactor(dashboard): remove redundant per-route authMiddleware

All routes under /api/ are covered by the global auth gate in app.ts.
The inline authMiddleware arguments on /configuration and /stack-restarts
were redundant with that gate and inconsistent with every other route in
the file. Remove them and drop the now-unused import.

* refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping

- Capture a single completedAt timestamp in fetchRemoteNodeOverview to
  eliminate two separate Date.now() calls and ensure latency_ms and
  last_successful_contact are derived from the same instant
- Inline the redundant contactedAt variable; use completedAt directly
- Move stack-restart aggregation from JS into SQL (GROUP BY stack_name
  with CASE/SUM counts), replacing the Map loop in the route handler
- Export StackRestartSummary interface from DatabaseService and remove
  the duplicate local definition in dashboard.ts; handler now returns
  the query result directly
- Add last_successful_contact normalization in decryptNodeRow, mirroring
  the existing pilot_last_seen pattern
- Add authGate reliance comment above dashboardRouter route handlers

* feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map

- Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time
  reachability, latency, and container count per registered node
- Local-only installs: shows Stack Restart Map — 7-day restart frequency
  per stack grouped by crash / auto-heal / manual category
- Conditional wrapper (DashboardActivityCard) switches states automatically
  when the node list changes, with no page reload required
- Deletes RecentActivity card and hook (duplicated data already in Recent Alerts)
- Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse

* fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel

* fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash

- Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview
  interface and to both the pilot-agent and HTTP-proxy return paths in
  fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch
  can fire correctly
- Remove the private formatRelativeTime from RecentAlerts.tsx and use
  the shared implementation from lib/utils, converting the millisecond
  timestamp at the call site
- Replace the em dash in getLatencyLabel with 'n/a' per project rules
2026-05-05 15:23:05 -04:00
Anso ecf4dd5d52 feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

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

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

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

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

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
2026-05-05 12:54:26 -04:00
Anso a85af40cb5 fix(frontend): tighten bell notification panel toolbar (#929)
* fix(frontend): tighten bell notification panel toolbar

Restructure the popover so the segmented filter (All / Unread / Alerts)
and the action icons (filter toggle, mark all read, clear all) share a
single row inside the 360px panel. The previous layout wrapped the
segmented control onto a second row once the type filter was added.

Move the unread badge to the right of the title, collapse the node and
type dropdowns behind a filter-toggle icon (accent dot signals an active
filter), equalize both Select widths, and extract the duplicated
className strings into local constants.

* docs: refresh bell notification popover screenshot

Reflects the reworked toolbar (segmented filter + filter toggle +
mark-all-read + clear-all on a single row).
2026-05-05 09:36:49 -04:00
Anso 6f45a3b788 fix(frontend): align sidebar brand box with top nav chrome (#928)
* fix(frontend): align sidebar brand box with top nav chrome

Match the TopBar's 56px height so the bottom border of the brand
box meets the bottom border under the nav, removing the 7px seam.
Drop the duplicate uppercase "SENCHO" label and place the version
number baseline-aligned next to the italic wordmark. Center the
brand row, enlarge the logo (28→36px) and wordmark (22→28px), and
mark the logo as decorative since the wordmark already names it.

* test(e2e): key dashboard sentinel off logo src instead of alt text

The brand box change made the sidebar logo decorative (alt=""), since
the adjacent wordmark already names the brand. The Playwright helper
keyed loginAs() off img[alt="Sencho Logo"], so every login wait timed
out and 11 specs failed (with 25 cascading skips via suite teardown).

Switch DASHBOARD_INDICATOR to img[src*="sencho-logo"]: same DOM target,
unaffected by accessibility wording. The selector is unique to the
authenticated sidebar — login/setup screens do not render it.
2026-05-05 07:58:30 -04:00
Anso 49d775c61f feat(volumes): add read-only volume browser (#926)
* feat(volumes): add read-only volume browser

Adds a browser for the contents of any Docker named volume. Click the
folder icon on a volume row (admin only) to open a sheet with a directory
tree on the left and a file viewer on the right.

Backend
-------
New VolumeBrowserService spawns a one-shot Alpine 3.20 helper container
with the target volume mounted read-only at /v. The container runs as
nobody (65534:65534) with a read-only rootfs, no network, all caps
dropped, no-new-privileges, and capped at 64 PIDs and 128 MiB. The
helper image is pulled on first use per node.

Listing and stat use a portable busybox-compatible shell loop (find
-printf is not available on Alpine). Reads use head -c with an
explicit -- separator; the helper's working directory is /v so user
paths are passed as ./<path> argv elements and never as flags. The
container lifecycle is managed manually (create, attach, start, wait,
remove) to avoid the AutoRemove race where dockerode sees a 404 on
its post-exit container lookup.

Path safety: relative paths are sanitized server-side, rejecting
parent-escape segments, absolute paths, null bytes, and oversized
input. Symlinks are listed but never followed on read. Files larger
than 5 MB are truncated; binary content is detected via null-byte
scan and returned base64-encoded. Non-zero helper exits map to
404, 403, or 500 by classifying stderr.

Routes mounted at /api/volumes:
- GET /:name/list?path=
- GET /:name/stat?path=
- GET /:name/read?path=

All three require admin. The read endpoint always inserts an audit
log row (success or failure) with the actual response status code,
volume name, and relative path.

Frontend
--------
FileTree generalized to take a loadDir callback and a sourceKey
instead of a hard-coded stackName. The single existing consumer
(StackFileExplorer) was updated and its tests rewritten. The loader
is read through a ref so re-creating the arrow on every parent
render does not re-trigger the root fetch effect.

New VolumeBrowserSheet renders the tree against the volume API,
shows file content (hex view for binaries), and surfaces truncation.
Rapid sheet open and reopen on different volumes is generation-
checked to avoid stomping the visible result with a stale read.

A persistent footnote reminds the user that file reads are recorded
in the audit log, and the docs page warns about the typical contents
of database volumes.

Tests
-----
15 new vitest cases cover the pure helpers (path traversal, volume
name validation, binary detection). The Docker-facing exec path is
exercised by manual end-to-end via curl against a seeded volume.

* fix(volumes): truncate long volume names in browser sheet header

Wide volume names overlapped the close X. Reserve right padding on
the header, set min-w-0 on the flex title, mark the icon and refresh
button shrink-0, and truncate the name span.

* fix(volumes): satisfy lint on volume browser additions

prefer-const on sanitizeRelPath's local; drop unused FileTree entry
arg from the file-select callback (variance lets the arrow take fewer
params than the contract).
2026-05-05 00:09:40 -04:00
Anso 7e5dc2d9ea feat(resources): add image details sheet with layer history (#925)
Adds a read-only inspect panel for Docker images. Click the eye icon on
any image row to open a sheet showing:

- Overview: ID (with copy), size, created date, arch/OS, author, tags
- Config: Cmd, Entrypoint, WorkingDir, User, exposed ports, env (collapsible),
  labels (collapsible)
- Layers: ordered history list with size, age, and build command per layer.
  Empty layers (metadata-only) are dimmed.

Backend adds DockerController.inspectImage(id) which combines image.inspect()
and image.history() in parallel, exposed via GET /api/system/images/:id.
The route accepts both bare hex IDs and sha256-prefixed IDs, since the list
endpoint surfaces the prefixed form. Returns 400 for malformed IDs and 404
for missing images.

Documents the new panel in docs/features/resources.mdx under Images.
2026-05-04 23:45:54 -04:00
Anso d73ae59ab8 refactor(frontend): tighten Resources Hub header and relocate Scan history (#924)
Remove the page title (icon + 'Resources Hub' heading + remote-node
indicator) so the Reclaim hero leads the page. Active node identity is
already shown by the global node selector chip, so the inline indicator
was redundant.

Move the Scan history button out of the removed header and into the
secondary navigation bar, far right of the Images / Volumes / Networks /
Unmanaged tabs. Vertical centering comes from flex items-center on the
wrapper, which matches the h-9 TabsList height. Tier guard
(trivy.available && isPaid) is unchanged.
2026-05-04 23:44:50 -04:00
Anso cc22ac4cb7 refactor(frontend): extract NodeCardStackList from NodeCard (F5-9) (#923)
* refactor(frontend): extract NodeCardStackList from NodeCard (F5-9)

* refactor(frontend): align NodeCard stacks cache guard with StackSection (F5-9)
2026-05-04 23:15:16 -04:00
Anso 13f0c68723 refactor(frontend): extract useFleetOverview from FleetView (F5-8) (#922) 2026-05-04 22:34:52 -04:00
Anso 71678081f0 refactor(frontend): extract useFleetUpdateStatus + useFleetPolling from FleetView (F5-7) (#921)
Move the update workflow state machine and the polling driver out of the
FleetView shell into two dedicated hooks under FleetView/hooks/. No
behavior change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

FleetView.tsx: 1,630 -> 1,556 LOC. First step in the FleetView
decomposition tracker.
2026-05-04 14:20:09 -04:00
Anso 993ab98f31 refactor(frontend): migrate PolicyBlockDialog, StateReviewDialog, EvictionDialog to Modal chrome (D-6) (#910)
* refactor(frontend): migrate PolicyBlockDialog to Modal chrome (D-6)

* refactor(frontend): migrate StateReviewDialog to Modal chrome (D-6)

* refactor(frontend): migrate EvictionDialog to Modal chrome (D-6)
2026-05-04 13:16:02 -04:00
Anso 46b3d53274 refactor(frontend): migrate diff dialogs to Modal chrome (D-5) (#909)
* refactor(frontend): extend Modal size system with wide variant

Adds 'wide' (max-w-5xl w-[95vw]) to ModalSize for dialogs that render
Monaco DiffEditor side-by-side, which need ~1024px to display both panels
clearly. The existing 'xl' cap at max-w-xl (576px) is too narrow for that
use case.

* refactor(frontend): migrate ComposeDiffPreviewDialog to Modal chrome (D-5)

Replaces raw Dialog/DialogHeader/DialogFooter with Modal size="wide",
ModalHeader, and ModalFooter. Kicker carries the stack name and file type
context; title is the file name (accessible dialog name for tests). Footer
hint reuses the existing "ON DISK → UNSAVED" label via the hint prop.

* refactor(frontend): migrate GitSourceDiffDialog to Modal chrome (D-5)

Replaces Dialog + nested AlertDialog with Modal size="wide", ModalHeader,
ModalFooter, and ConfirmModal. Kicker is "GIT · PULL PREVIEW"; title is
the stack name. Short SHA moves to the sr-only description. The "Deploy
after apply" checkbox in the footer hint slot uses normal-case and
tracking-normal on the Label to prevent KICKER_CLASS uppercase/tracking
from being inherited. The overwrite confirmation uses ConfirmModal with
variant="destructive" (rose rail) since it replaces local file content.
2026-05-04 11:53:35 -04:00
Anso 945259f048 refactor(frontend): migrate CreateStackDialog to Modal chrome system (D-4) (#908)
* refactor(frontend): migrate CreateStackDialog to Modal chrome system (D-4)

Swap raw shadcn Dialog/DialogContent/DialogHeader/DialogFooter for the
Modal/ModalHeader/ModalBody/ModalFooter primitives shipped in D-1 and
introduce an inline ModeRail to replace the TabsHighlight chip. The new
chrome carries the cyan rail, mono kicker (STACKS · NEW), italic serif
title, and contextual footer hints per mode (ALPHANUMERIC · HYPHENS,
HTTPS REPOS ONLY, CONVERT FIRST / YAML READY + line-count accent).

ModeRail implements the full WAI-ARIA tabs pattern: aria-selected on the
active tab, aria-controls/role=tabpanel/aria-labelledby linkage to each
mode panel, roving tabIndex (active=0, inactive=-1), and ArrowLeft /
ArrowRight / Home / End keyboard navigation matching the contract that
the prior shadcn Tabs primitive provided.

Each mode also gains an explicit Cancel button alongside the existing
primary action, the empty-mode primary button is now disabled until the
stack name is non-empty, and the ModeRail disables itself while a Git
or docker-run create is in flight.

The async handlers (handleCreateStack, handleCreateStackFromGit,
handleConvertDockerRun, handleCreateStackFromDockerRun) and form-reset
helpers are unchanged; this PR is structural plus the segmented-control
redesign called out in the migration tracker as the D-4 risk note.

* test(e2e): align git-sources spec with new CreateStackDialog title

D-4 renamed the dialog title from "Create New Stack" to "New stack".
The shared openCreateStackDialog helper in git-sources.spec.ts was
still asserting the old literal, so three tests in the "Create stack
from Git" group failed at the helper's first assertion.

Update the assertion to match the shipped title and refresh the two
stale references in docs/features/stack-management.mdx so the docs
stay in sync with the UI copy.

* test(e2e): use dialog accessible name in openCreateStackDialog helper

The previous helper used getByText('New stack') which matched two
elements: the dialog title h2 and the sr-only description (which
starts "Create a new stack: empty, ..." and contains the substring).
Playwright fails with a strict mode violation.

Switch to getByRole('dialog', { name: 'New stack' }), which asserts
the dialog by its accessible name (provided by DialogTitle via the
Radix aria-labelledby wiring). One match, more precise, and
independent of any future description copy.
2026-05-04 11:17:29 -04:00
Anso 90ea26f7c7 refactor(frontend): migrate MFA dialogs to Modal chrome system (D-3) (#907)
Replace raw Dialog/AlertDialog scaffolding in MfaEnrollDialog,
MfaDisableDialog, and MfaBackupCodesDialog with the shared Modal*
primitives introduced in D-1. Extract the duplicated BackupCodeTicket
component into a shared file consumed by both enroll and regen flows.

Fix a CSS grid auto-column blowout in Modal: the dialog's implicit auto
column was sizing to the step rail's min-content (492px), overflowing
the 448px max-w-md constraint and clipping right-side content. Adding
grid-cols-1 to the DialogContent override forces the column to use
minmax(0, 1fr), preventing any grid item from expanding the track past
available space. Also add min-w-0 to the TOTP secret code element so
its long monospaced string can truncate rather than drive column sizing.

Kicker prefixes updated to SECURITY · per the tracker convention.
2026-05-04 09:08:44 -04:00
Anso 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.
2026-05-04 07:43:02 -04:00
Anso 34fcb2591f fix(frontend): remove unused MockWS constructor param (#904)
The `_url` parameter on the test-only MockWS class was unused, and
ESLint's no-unused-vars rule does not honor the underscore-prefix
convention in this project's config, so the lint job failed. The mock
is installed via vi.stubGlobal at runtime, so dropping the parameter
is safe — JS still permits callers to pass a url argument.
2026-05-03 21:40:20 -04:00
Anso 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.
2026-05-03 21:20:11 -04:00
Anso 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
2026-05-03 20:35:22 -04:00
Anso 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.
2026-05-03 18:34:32 -04:00
Anso 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.
2026-05-03 18:00:11 -04:00
Anso 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.
2026-05-03 15:32:26 -04:00
Anso 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.
2026-05-03 14:56:55 -04:00
Anso 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.
2026-05-03 14:23:26 -04:00
Anso 898ef1a0e8 feat(ui): add Modal chrome primitives, migrate file dialogs (#896)
Introduces shared <Modal>, <ModalHeader>, <ModalBody>, <ModalFooter>,
and <ModalDestructiveHeader> primitives that wrap shadcn Dialog and
encapsulate the canonical modal chrome: cyan rail at the left edge,
mono uppercase kicker, italic serif title, footer hint, standardized
button order (secondary outline, primary cyan / destructive).

Migrates NewFolderDialog and DeleteFileConfirm onto the new primitives
as the first proof points. The destructive variant flips the rail and
kicker color to the destructive token without changing the chrome
recipe.
2026-05-03 13:37:02 -04:00
Anso 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
2026-05-03 12:54:37 -04:00
Anso 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.
2026-05-03 12:19:54 -04:00
Anso c06f937d8d feat(license): simplify community license page to activate form + pricing link (#892)
Replace the in-app upgrade promotion (Try Admiral free callout, Skipper
and Admiral upgrade cards with feature lists, monthly/annual checkout
buttons) with a single subdued "See pricing" link to sencho.io/pricing.

The marketing site carries the upgrade story now; the dashboard's job
is to show the operator their current plan, accept a license key when
they have one, and point them to one place if they want to learn more.

Behavior by tier:
- Community: Plan info, Activate input, "See pricing" link.
- Trial (paid): Plan info, Activate input, no pricing link (the user
  already received a key by email).
- Active paid: Plan info with customer/product/key, Manage subscription
  and Deactivate. Unchanged from before.
- Expired paid: Plan info, Activate input, "See pricing" link as a
  recovery path.

Drops the SKIPPER / ADMIRAL_MONTHLY / ADMIRAL_ANNUAL Lemon Squeezy
checkout URL constants and the inline UpgradeCard component. -129 LOC
net.
2026-05-03 01:31:00 -04:00