Commit Graph

184 Commits

Author SHA1 Message Date
Anso 3e01daf76f feat(stack): per-stack activity timeline with actor attribution (#852)
* feat(stack): per-stack activity timeline with actor attribution

Adds an Activity tab to the Stack Anatomy panel showing a timestamped
event log for each stack: deploys, restarts, starts, stops, and image
updates, attributed to the user who triggered them or 'system' for
automated actions.

Backend:
- Extends notification_history with actor_username column (idempotent
  migration) and a partial composite index on (node_id, stack_name,
  timestamp DESC) for efficient per-stack lookups.
- NotificationService.dispatchAlert() accepts an optional actor that
  is written to the new column.
- Success-side dispatchAlert calls added after deploy, bulkContainerOp
  (start/stop/restart), and update handlers in routes/stacks.ts so
  user-initiated operations are recorded, not just failures.
- New GET /api/stacks/:stackName/activity?limit&before endpoint with
  stack:read permission gate and cursor-based pagination.

Frontend:
- StackAnatomyPanel grows an Anatomy / Activity tab pair using the
  existing Tabs primitive.
- StackActivityTimeline fetches the initial 50 events, paginates on
  demand, and prepends live events arriving over the existing WS
  notifications stream without duplicates.
- NotificationPanel bell dropdown suppresses user-initiated success
  events (start/stop/restart/deploy/update triggered by a real user),
  keeping the tray focused on alerts and system events.

* docs(stack): add stack activity timeline feature page and internal arch docs

* fix(test): add actor_username to notification-routing history assertions

dispatchAlert now passes actor_username to addNotificationHistory after
the activity timeline PR added the column. Update the two exact-match
assertions that were failing because the expected object shape was missing
this field.
2026-04-30 19:53:23 -04:00
Anso a0bf5b5bf5 feat(sidebar): bulk stack operations (#854)
* feat(sidebar): bulk stack operations (select, start/stop/restart/update)

- Add ⊞ bulk mode toggle in SidebarActions (cyan active state, tooltip "Bulk
  mode (B)"); keyboard shortcut B toggles, Esc exits, Ctrl+A selects all
  visible (chip-filtered) stacks
- Reserved checkbox column in StackRow becomes visible and interactive in bulk
  mode; clicking a row in bulk mode toggles selection instead of opening the
  stack; kebab and context-menu still work in either mode
- SidebarBulkBar appears below filter chips when >=1 stack selected: shows
  count, Start / Stop / Restart / Update actions; Update is disabled with a
  Skipper TierBadge for Community licenses
- useBulkStackActions hook fans out operations via Promise.allSettled and
  surfaces an aggregate toast ("3 of 4 restarted; 1 failed: plex")
- Bulk update enforced Skipper-gated frontend-side (isPaid check in hook) and
  sends x-bulk-mode header for backend defense-in-depth
- Extract isInputFocused / isPaletteOpen to lib/keyboard-guards.ts; both
  useStackKeyboardShortcuts and the new bulk keyboard effect now share the
  same guards instead of duplicating the logic
- chipFilteredFiles captured via useRef in bulk keyboard effect so the listener
  is not torn down and re-added on every status-poll cycle

* fix(sidebar): separate TooltipProviders for bulk and scan icon buttons

Wrapping both icon buttons in a single TooltipProvider made them
render as one flex child, collapsing the gap-2 between them.
Splitting into two independent TooltipProviders restores the 8px
gap and right padding of the scan button.
2026-04-30 19:53:12 -04:00
Anso 4c0efcb9a8 feat(sidebar): §14 sidebar orchestration, filter chips, pinned rail, trailing column (#850)
* feat(sidebar): §14 sidebar orchestration (filter chips, pinned rail, trailing column)

- Add All / Up / Down / Updates filter chips with live counts; active chip
  filters the list; chip-filtered files computed in EditorLayout with useMemo
- Surface the PINNED group with a 3px cyan left rail and glow via the
  sidebarPinnedGroupRail token; reuses the brand token already on the active row
- Compact brand row from three stacked elements to a single 44px horizontal bar
- Restructure StackRow trailing column as fixed slots: label dots (max 3 + +N
  overflow), update-dot | git-pending icon (priority order), kebab; add reserved
  invisible checkbox slot for PR2 bulk mode
- Export statusText / statusColor from StackRow and reuse them in StackList
  remote-results section to remove the duplicate inline logic
- Lift filterChip state and chip-filtered files to EditorLayout; remove
  filterChip from StackListProps to eliminate the dual-path redundancy
- Remove unused labels param from buildGroups and the void labels workaround
- Wrap filteredFiles in useMemo so filterCounts memo is not defeated on every
  render

* fix(sidebar): move statusText and statusColor to stack-status-utils

react-refresh/only-export-components requires component files to export
only components. Move the two utility functions and StackRowStatus type
to a dedicated stack-status-utils.ts so StackRow.tsx is a pure component
module. Update StackList.tsx and EditorLayout.tsx to import from the new
source directly.
2026-04-30 19:37:49 -04:00
Anso eead195529 feat(settings): dress the page to match the audit (#849)
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)

Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.

Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.

Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.

Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.

* refactor(settings): drop react-router-dom and align with DESIGN.md

The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:

- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
  inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
  useNavigate. SettingsPage owns currentSection via props lifted to
  EditorLayout, so cross-component navigation (openLabelManager,
  onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
  redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json

The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:

- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
  11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
  font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
  --success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
  tone now genuinely uses --success green; new brand tone for promotional
  callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
  size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
  filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
  aside gains text-card-foreground transition-colors per §10 canonical
  card class
- SettingsPage main panel: text-card-foreground transition-colors added;
  uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
  tokens with literal fallbacks so Settings respects the comfortable/
  compact toggle

* fix(e2e): update mfa openAccountSettings to match settings redesign

Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.

* test(e2e): fix MFA enrolment assertion after settings redesign

The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.

* test(e2e): fix low-backup-codes warning assertions after settings redesign

Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
  identifies the zero-codes error card without hitting strict-mode
  from two identically-labelled Regenerate buttons on the page

* test(e2e): navigate to root before re-opening settings for mock refresh

The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.

* test(e2e): scroll zero-codes callout into view before asserting visibility

The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.

* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion

The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.

* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue

The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
2026-04-30 19:37:38 -04:00
Anso 9a1c043189 refactor(settings): replace modal with nested full-page route (#848)
* refactor(settings): replace modal with nested full-page route

Settings sections are now URL-addressable at /settings/:sectionId, rendered
nested inside EditorLayout alongside the stack sidebar. Browser back/forward
navigates between sections. Deep links (e.g. /settings/cloud-backup) load
the section directly on hard reload.

- Add react-router-dom v7; BrowserRouter wraps the full app tree
- New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink
  active styling, back-arrow), SectionGate (visibility + tier lock card)
- Rename SectionId 'appstore' to 'app-store' so slug === SectionId
- Decouple SystemSection, DeveloperSection, AppStoreSection from modal-
  passed props; each fetches its own data on mount
- Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event
- Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard,
  ConfigurationStatus; each calls useNavigate directly
- Delete SettingsModal.tsx

* fix(settings): validate sectionId against registry before property write

Prevents prototype pollution (CodeQL js/remote-property-injection #243).
URL param sectionId is checked against SETTINGS_ITEMS before being used
as a property key on scrollPositionsRef.

* fix(settings): eliminate remote property injection via Map and registry-sourced key

Two-part fix for CodeQL js/remote-property-injection:

1. currentSection is now derived from SETTINGS_ITEMS.find().id (trusted
   registry data) instead of the raw sectionId URL param. The tainted
   string never flows into any property access.

2. scrollPositionsRef uses Map<SectionId, number> with .get()/.set()
   instead of a plain object. Map operations do not write to the prototype
   chain, removing the prototype pollution vector entirely.

* test(e2e): align settings selectors with full-page route

The settings refactor (4475afd) replaced the modal with a nested route.
The new sidebar renders sub-sections as NavLinks (role link, not button)
and adds a "Filter settings" button that collides with the loose
/settings/i regex used in mfa and nodes specs.

- Use exact 'Settings' match for the profile-dropdown menu row
- Switch the Nodes sub-section selector from button to link role
2026-04-30 12:57:02 -04:00
Anso 3f2ff47c94 refactor(frontend): extract useImageUpdates hook from EditorLayout (#831)
EditorLayout owned the stack-image-update state plus a 5-minute
polling interval as part of a 30-line useEffect that already
juggled six other concerns (selected file, active view, stacks
refresh, auto-update settings, git-source pending, …). The image-
update slice has clean boundaries: it depends on activeNode.id,
mutates one state object, and is otherwise unrelated to the rest
of the effect.

Move it into a dedicated hook at frontend/src/hooks/useImageUpdates.ts.
The hook owns the stackUpdates state, runs an initial fetch on
activeNode.id change, schedules the 5-minute poll, and exposes a
refresh() callback for the four manual-trigger sites
(deploy success, image-update action, manual registry-refresh
poll). The hook destructure aliases refresh to fetchImageUpdates so
existing call sites in EditorLayout don't need to be renamed.

This is the first slice of audit finding 1.6 (EditorLayout 3129-line
refactor); the next slice is useFleetNotifications.
2026-04-28 10:30:22 -04:00
Anso b5d038f395 perf(frontend): lazy-load Monaco editor + diff editor (#824)
Monaco-editor and @monaco-editor/react were imported eagerly from
main.tsx so that the locally-bundled Monaco was registered with
@monaco-editor/react (CSP blocks the default CDN load). This pulled
the ~3 MB monaco chunk into every cold app start regardless of
whether a user ever opened the editor.

Move the Monaco setup into a new frontend/src/lib/monacoLoader.tsx
module that exports React.lazy-wrapped Editor and DiffEditor
components. The lazy factory awaits a one-shot setupMonaco() that
dynamic-imports monaco-editor, @monaco-editor/react, and the editor
worker, then calls loader.config({ monaco }) and sets
window.MonacoEnvironment before resolving the underlying component.

Concurrent first mounts share a single setup promise so the work
runs at most once per process. The three consumers (EditorLayout,
FileViewer, GitSourceDiffDialog) wrap their editor in <Suspense>
with a transparent fallback that preserves layout while the chunk
loads.

main.tsx loses three eager imports plus the MonacoEnvironment +
loader.config bootstrap. The vite.config.ts manualChunks group from
PR #823 was already prepared for this; the monaco chunk now loads
on demand instead of being bundled into the entry chunk.
2026-04-28 08:09:15 -04:00
Anso 38a9f277c6 feat(stacks): add optional volume prune to delete confirmation (#788)
The Delete Stack dialog now includes an opt-in checkbox to also remove
associated Docker volumes when the stack is deleted. The checkbox is
unchecked by default and resets to unchecked on every open.

Backend: DELETE /stacks/:name accepts ?pruneVolumes=true and calls
pruneManagedOnly for volumes labeled with the stack project name after
bringing the stack down. Prune failure is non-fatal and logged; the
delete proceeds regardless.
2026-04-26 18:56:30 -04:00
Anso d7d8f9bfe8 feat(dashboard): replace 24h charts with Configuration Status and Recent Activity (#785)
* feat(dashboard): replace 24h charts with Configuration Status and Recent Activity

The 24-hour CPU/Memory area charts summed per-container metrics normalized
to each container's CPU quota, producing numbers that bore no honest
relationship to host load. The live ResourceGauges strip already shows
accurate host-level stats, making the historical charts both inaccurate
and redundant.

This commit replaces that row with two side-by-side cards:

- **Configuration Status**: aggregates every toggleable feature on the
  active node (notification agents, alert rules, routing rules, auto-heal,
  auto-update, webhooks, scheduled tasks, MFA, SSO, vulnerability scanning,
  cloud backup, and alert thresholds) into a single at-a-glance card.
  Tier-locked rows display an upgrade indicator instead of a value.
  Each row is clickable and navigates to the relevant settings section.
  Data refreshes every 60 s and immediately on state-invalidate events.

- **Recent Activity**: lists the ten most recent notification-history events
  for the active node (deployments, image updates, auto-heal actions, scan
  findings, cloud backup events, system notices) with category icons and
  relative timestamps. Refreshes every 30 s.

New backend endpoints:
- GET /api/dashboard/configuration - per-node feature status with locked/
  requiredTier markers so the frontend renders upgrade chips without extra
  calls. The endpoint sits after authGate and before the remote proxy so
  remote-node requests are transparently forwarded.
- GET /api/dashboard/recent-activity?limit=N - thin wrapper over
  DatabaseService.getNotificationHistory.
- GET /api/fleet/configuration - fleet-wide fan-out using the same
  Promise.allSettled dead-node-tolerant pattern as /fleet/overview.
  Exposed as the new "Status" tab on the Fleet page (after Snapshots).

Shared utilities:
- visibilityInterval and formatCount extracted to frontend/src/lib/utils.ts
  so the three polling hooks and two components share a single copy.

* docs(dashboard): fix stale alt text referencing removed historical charts
2026-04-26 18:42:28 -04:00
Anso 801a098a5b feat(files): per-stack file explorer (#780)
* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
2026-04-26 13:05:19 -04:00
Anso dd9d33813b feat(deploy-logs): opt-in deploy progress modal with structured log rows (#779)
* feat(notifications): dispatch deploy_failure alert on stack action errors

* feat(terminal): add onReady and onMessage callback props

* feat(deploy-logs): add DeployLogContext with runWithLog API

* feat(deploy-logs): add DeployLogPanel bottom drawer with resize and minimize

* feat(deploy-logs): wire DeployLogContext to App and EditorLayout action runners

* test(deploy-logs): add E2E test for deploy log panel open, failure, and minimize

* docs(deploy-logs): add user-facing and internal architecture docs

* feat(deploy-logs): redesign as opt-in modal with structured log rows

Replace the full-width bottom drawer (DeployLogPanel) with a centered
modal that streams structured log output for deploy, stop, restart,
update, install, and Git apply operations. The modal is disabled by
default; users opt in from Settings -> Appearance.

Core changes:
- New DeployFeedbackContext with runWithLog() API: if opt-in is off,
  silently bypasses the UI so all call sites degrade to the existing
  toast behavior without code changes.
- composeLogParser.ts: pure parser that strips ANSI escapes and
  classifies compose output into stage badges (PULL, BUILD, CREATE,
  START, STOP, DOWN, WARN, ERR, LOG). 15 unit tests.
- StructuredLogRow.tsx: memoized row with timestamp, stage badge, and
  message. Error rows get a rose left rail; warn rows get a tinted bg.
- DeployFeedbackModal: Dialog-based, max-w-640px/max-h-70vh, elapsed
  timer, auto-close 4s on success (hover cancels), persistent on
  failure. Raw xterm output collapsible in footer.
- DeployFeedbackPill: minimized state anchored top-right, survives
  navigation, click restores modal.
- Wires App Store install (action: install), Git apply (action: deploy),
  and Git pull (action: update) in addition to the existing EditorLayout
  actions.
- Fixes Terminal.tsx WS URL in generic mode (was connecting to root path
  not proxied by Vite; now uses /ws).
- Settings: adds "Show deploy progress modal" checkbox to Appearance.
- Docs: renames deploy-logs.mdx to deploy-progress.mdx; updates
  internal architecture doc.

* fix(deploy-logs): connect Terminal in generic mode and move pill to bottom-center

Terminal was passed stackName which routes it to the stack logs WS
(container stdout). In that mode onReady is never called, so the
deployStarted gate never resolves and the compose command never runs.
Remove stackName so Terminal uses generic WS mode, which calls onReady
on open and streams compose output.

Also reposition the minimized pill from top-right to bottom-center
(fixed bottom-6 left-1/2 -translate-x-1/2) per UX feedback.

* docs(deploy-logs): update pill position to bottom center

* test(deploy-logs): rewrite E2E spec for deploy feedback modal

The old spec targeted the removed bottom-drawer DeployLogPanel and used
the wrong field name when calling POST /api/stacks (sent 'name' but the
endpoint reads 'stackName'), causing every test to fail with a 400 before
any UI assertions ran.

Fixes:
- POST /api/stacks body now uses 'stackName' matching the API contract
- All locators updated to target the new DeployFeedbackModal and
  DeployFeedbackPill components (data-testid attributes added)
- Added enableDeployFeedback helper to opt-in via localStorage before
  each test that expects the modal (feature is off by default)
- Added opt-in OFF test to confirm the modal is suppressed when disabled
- Minimize/expand test now asserts the pill appears and contains the
  stack name before clicking to restore the modal

* test(deploy-logs): fix compose file write endpoint in E2E helper

createStackViaApi was calling PUT /api/stacks/:name/files/docker-compose.yml
which does not exist. The correct endpoint is PUT /api/stacks/:name with
{ content } in the body.

* test(deploy-logs): use addInitScript to persist opt-in across reloads

The opt-in flag was set via page.evaluate before setupDeployStack, which
calls page.reload() and loginAs (a second navigation). Although localStorage
should persist across same-origin reloads, the React tree was reading
'false' on remount in CI. Switching to addInitScript guarantees the
localStorage value is set before any page script on every navigation, so
useDeployFeedbackEnabled's useState initializer always sees the right
value when React mounts.

* test(deploy-logs): verify localStorage and re-dispatch event before deploy

Adds syncDeployFeedbackState() called right before each deploy click in
the ON tests. It both verifies localStorage is set (failing the test
loudly with a clear message if not) and re-dispatches the
SENCHO_SETTINGS_CHANGED event to defeat any stale React state after
navigation. If the modal still does not appear with the assertion green,
the issue is downstream of localStorage and we have a clear signal.

* test(deploy-logs): wait for React re-render after dispatching opt-in event

After syncDeployFeedbackState dispatches SENCHO_SETTINGS_CHANGED, React
schedules the state update but does not flush it synchronously. The
click that follows can fire against the stale closure where isEnabled is
still false, so runWithLog takes its early-return path and the modal
never opens. A 200ms wait is enough to let React commit the new state
before the next interaction.

* test(deploy-logs): wait for stack file fetch before clicking deploy

deployStack() in EditorLayout returns early at 'if (!selectedFile)'
without calling runWithLog. selectedFile is set inside loadFile() after
GET /api/stacks/:name resolves. The previous setup clicked the stack in
the sidebar and immediately asked the test to click Deploy, racing the
fetch. CI backend logs confirmed no deploy POST ever fired for the ON
tests, while the OFF test passed only because it asserts non-existence.

Now setup awaits both the stack click and the file response together,
then verifies the action bar's deploy button is visible before returning.

* test(deploy-logs): wait for network idle and capture browser logs

Adds a networkidle wait plus a 500ms settle after the stack click so
React commits selectedFile and any follow-up env/container/backup
fetches drain before the deploy click. Also mirrors browser console
errors and pageerrors into the Playwright output so the next failure
ships with the React stack trace instead of just a 'modal not visible'
message.

* test(deploy-logs): temporary debug logging in runWithLog

Adds a console.log at the entry of runWithLog so we can see in CI logs
whether it is being called and what isEnabled value the closure has.
Also widens the test's console capture to include these debug lines.

This is diagnostic only and will be removed once the root cause of the
modal-not-opening-in-CI failure is identified.

* test(deploy-logs): debug log at deployStack entry to trace click path

Adds console.log at the first line of deployStack handler so we can
confirm in CI whether the click is reaching it at all and what
selectedFile/isStackBusy resolve to. Combined with the existing
runWithLog debug logs, this isolates whether the modal failure is in
deployStack guarding out, runWithLog early-returning, or something
else entirely.

* test(deploy-logs): drop filter, log every browser console msg

The previous filter only emitted error/warning plus the deploy-feedback
substring. The deploy-feedback debug logs never appeared, so we don't
yet know whether the log itself is firing. Remove the filter so the
full console stream shows up in CI.

* test(deploy-logs): app-level console log to verify capture pipeline

If even an unconditional log at App component render time does not
appear in CI browser logs, then the console capture listener is broken
or the dispatched logs are being filtered upstream of Playwright. This
isolates whether the issue is in the production code or the test
harness.

* test(deploy-logs): use testid locator for stack action button

Replaces the regex-based getByRole locator (/Deploy|Start/i) with
getByTestId('stack-deploy-button'). The regex matched something other
than the actual deploy button: backend logs proved no deploy POST ever
fired, and instrumentation confirmed neither deployStack nor runWithLog
ran on click despite the test claiming success.

Adds data-testid='stack-deploy-button' to both the Restart and Start
button branches in EditorLayout's action bar so the same locator works
whether the stack is running or not.

Also drops the temporary debug console.log entries in deployStack,
runWithLog, and App, and restores the test's console listener filter
to only emit error and warning messages.

* test(deploy-logs): park cursor in corner so auto-close countdown fires

After clicking the deploy button, the cursor lands inside the centered
modal. The modal pauses its 4s auto-close countdown on hover, so the
HAPPY test was waiting for a close that never happened. page.mouse.move
to (0,0) parks the cursor outside the modal before the success banner
appears, letting the countdown complete.

* test(deploy-logs): drop redundant loginAs after page.reload

page.reload preserves auth cookies, so the page lands back on the
dashboard without needing a fresh login. The loginAs call after reload
was racing on isLoginPage(): a transient login-page state during page
load made loginAs commit to filling #username, then the dashboard
committed and #username never came back. Playwright's auto-wait then
hung the fill until the test's 120s timeout, which also dragged later
stacks.spec tests down with collateral timeouts.

waitForStacksLoaded is enough to confirm we're on the dashboard with
the sidebar populated before clicking the new stack.

* test(e2e): make loginAs race-safe when login page is a false positive

isLoginPage() reports the page as a login screen if the Login button
locator reports visible at the moment of the check. Under CI load (more
real container deploys from the deploy-log-panel suite), the auth
context can render the login form for one paint, then redirect to the
dashboard. The original code committed to filling #username and hung
until the test timeout when the field was no longer there.

Now the login branch waits up to 2s for #username to actually appear
before filling. If it never appears, we fall through to the dashboard
check instead of hanging.
2026-04-26 00:43:54 -04:00
Anso 6986b927e3 feat(stacks): per-service start/stop/restart lifecycle actions (#778)
* feat(stacks): add per-service start/stop/restart lifecycle routes

Adds POST /:stackName/services/:serviceName/{start,stop,restart} routes
that operate on containers belonging to a single Compose service, using
the same Engine API pattern as the existing stack-level lifecycle routes.
Includes isValidServiceName validator and audit-summary entries for the
three new paths.

* test(stacks): add per-service action route tests

* test(stacks): fix test quality issues in service action tests

* feat(stacks): add per-service lifecycle menu to container cards

* fix(stacks): handle paused container state in service action menu

* docs(stacks): add per-service lifecycle actions documentation

* docs(stacks): add validation screenshots for per-service lifecycle actions
2026-04-25 17:26:04 -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 5c5021846a feat(events): broadcast state-invalidate on docker events so dashboard updates live (#768)
Dashboard and sidebar status indicators previously only refreshed on a
5-30 second polling cadence: a container restart, a degraded -> healthy
transition, or a stack update was invisible until the next tick.

Add a lightweight, non-persisted "state-invalidate" envelope on the
existing /ws/notifications WebSocket:

Backend
- NotificationService.broadcastEvent: sibling of dispatchAlert that
  pushes an arbitrary {type, ...} envelope to every subscriber WITHOUT
  writing to the alerts history (these are pure ephemeral signals).
- DockerEventService.handleEvent: emit the envelope for state-changing
  container actions (start/die/kill/destroy/create/restart/pause/
  unpause/health_status/rename/update). Carries node id, stack name
  (from the compose project label), container id, action, and
  timestamp.

Frontend
- EditorLayout's two notification WebSocket handlers (local plus
  per-remote-node) branch on type. On state-invalidate they re-emit a
  window CustomEvent and trigger a debounced (250ms) refreshStacks so
  a burst of events from compose recreating multiple services
  collapses to one refetch. The refresh callback is held in a ref so
  the long-lived WS effect never closes over a stale function.
- useDashboardData listens for the same window event and refetches
  /stats, /system/stats, and /stacks/statuses on every signal.
  Historical metrics stay on their 60s polling cadence (10-minute
  trend data, not a live indicator).

Tests
- Three new docker-event-service cases assert broadcastEvent fires on
  start and health_status events with the correct envelope shape, and
  does not fire on non-state actions like exec_create.
- Existing 28 cases updated with the broadcastEvent mock so the
  subscriber stub matches the new shape.

Polling stays as a safety net at the same intervals; the WS path is
the fast path. Multi-node fleets benefit on the local node today;
extending the remote forwarder to relay state-invalidate is a
recommended follow-up.
2026-04-24 23:38:08 -04:00
Anso a962654a3b fix(env): return empty body for missing .env files; surface non-OK responses cleanly (#767)
Previously, fetching the .env file for a stack with no env files at all
returned a 404 with a JSON error body. The frontend's secondary loader
(changeEnvFile) called res.text() without checking res.ok, which caused
the error body to be stuffed directly into the editor as if it were
file content.

Two-part fix:

Backend (routes/stacks.ts):
- For the default GET /stacks/:name/env (no ?file= query) when the
  stack has no env files, respond 200 with an empty body and an
  X-Env-Exists: false header instead of 404.
- For an explicit ?file= query that resolves to a missing file, keep
  the 404 (the caller asked for something specific).
- Catch a TOCTOU ENOENT between access() and readFile() and return the
  same friendly empty-body shape, not a generic 500.

Frontend (EditorLayout.tsx::changeEnvFile):
- Check res.ok before reading the body. On a non-OK response, clear the
  editor content and surface a friendly toast instead of pasting the
  server's JSON error string into the file.
2026-04-24 23:37:54 -04:00
Anso 57461043b0 fix(frontend): clear sidebar update dot after toolbar Update click (#763)
The toolbar Update button calls updateStack(), which refreshed
containers and stacks but never re-fetched the image-updates list.
The sidebar's blue "update available" dot therefore stayed visible
until the 5-minute polling interval. The right-click context menu
path (executeStackActionByFile, action='update') already calls
fetchImageUpdates() on success; mirror that call here so both paths
behave the same.
2026-04-24 23:36:19 -04:00
Anso 4c35226719 fix(frontend): make copy buttons work over plain HTTP (#757)
The Clipboard API requires a secure context, so navigator.clipboard is
undefined when Sencho is accessed over HTTP on a LAN IP. Most copy
buttons therefore failed silently and a few even fired success toasts
without writing anything to the clipboard.

Extract a shared copyToClipboard helper that prefers the modern API in
secure contexts and falls back to a hidden-textarea execCommand path
otherwise, then route every existing call site through it.
2026-04-24 22:26:12 -04:00
Anso e4fdb1cd6c fix(security): convert scan history from full page to sheet overlay (#720)
Scan history is now a right-side sheet that layers over the current view
(typically Resources Hub) instead of a full-page activeView branch. The
sheet opens via the existing navigation event, fetches only when open,
dismisses on Escape or overlay click, and preserves the nested scan-details
and scan-compare sheets intact via Radix portal stacking.

The fetch effect now resets selection and page state on active-node change
exactly once, avoiding a double-fetch on node switches.
2026-04-21 08:01:31 -04:00
Anso 661b9c638b feat(security): enforce scan policies as a pre-deploy gate (#719)
Policies with block_on_deploy=1 now scan every stack image before
docker compose up runs and reject the deploy with HTTP 409 on violation.
The UI opens a dialog listing offending images; admins can override per
deploy with ?ignorePolicy=true, and every bypass is recorded in the
audit log with the originating route, actor, policy, and image list.

When Trivy is not installed on the target node the gate fails open with
a warning notification, so teams are never locked out by tooling state.
Post-deploy and scheduled scans still evaluate matching policies and
dispatch warnings on violations to surface drift on long-running stacks.

Public API additions: policy and suppression CRUD under /api/security,
plus the documented 409 block-response shape on all deploy paths.
2026-04-21 00:14:11 -04:00
Anso 3e1fb76bd0 feat(notifications): add per-node filter and 60s refetch safety net (#717)
Two polish improvements to the aggregated notifications inbox:

- Per-node filter dropdown in the bell panel (hidden on single-node
  installs) so fleet operators can triage events from a specific box.
  Selected node falls back to "All nodes" automatically if that node is
  removed from the registry.
- 60-second safety-net poll that reconciles the list so events missed
  during a WebSocket reconnect backoff appear without a manual refresh.
  Uses a ref indirection to pin the interval to the latest
  fetchNotifications closure.
2026-04-20 21:37:34 -04:00
Anso 856de35a52 feat(search): add global Ctrl+K command palette (#711)
* feat(search): add global command palette with cross-node stacks

Press Ctrl+K from anywhere to open a search palette that jumps to
pages, switches nodes, or opens stacks on any online node in the
fleet. Trigger lives as an icon in the top bar. Replaces the former
sidebar-scoped Ctrl+K handler.

The cross-node stack search fan-out is extracted into a shared hook
so the sidebar and palette stay in sync on the same debounce +
abort shape.

* fix(search): drop render-time ref write and effect-based query reset

Replace `nodesRef.current = nodes` during render with direct use of
`nodes` in the stack select callback, and fold the query-reset logic
into a single `onOpenChange` handler so it no longer runs via an
effect. Both changes resolve react-hooks rule violations that broke
frontend lint in CI.
2026-04-20 13:57:40 -04:00
Anso af59836538 chore(sidebar): document labels menu invariants and remove dead code (#707)
Document the hidden constraints that PR #706 left in the codebase so they
are not accidentally removed in a future cleanup pass:

- DropdownMenuSubContent and ContextMenuSubContent must stay Portal-wrapped
  so sub-menus escape ancestors with overflow-x-hidden.
- refreshLabels must stay stable via useCallback because it is captured by
  buildMenuCtx's memoization and passed as a prop.

Also delete LabelAssignPopover.tsx, which had zero consumers after the
create-and-assign flow moved into the menu-layer inline form.
2026-04-20 10:34:45 -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
Anso ef4455f68d refactor(ui): rework top bar nav as cockpit switch row (#697)
Replace the sliding accent pill and blurred underline with material-at-
rest tabs that speak the cyan identity language used elsewhere in the
chrome. Each tab is a full-height button with a tracked-mono uppercase
label, and the active tab carries a crisp 2px cyan rail flush with the
bar's bottom edge.

Drops the <Highlight> primitive, the springs import, and the unused
navTabValue memo/prop from EditorLayout.
2026-04-19 15:11:16 -04:00
Anso 5589110925 feat(ui): redesign top bar as chrome-glass masthead (#696)
Extract the app header into a dedicated TopBar component and apply the
same translucent chrome language used by the sidebar: bg-sidebar +
backdrop-blur-md + inset top highlight. A new --chrome-top-highlight
token drives the highlight in both themes via the shadow-chrome-top
utility alias.

Restore the pulsing-dot unread indicator on the notification bell
(replaces the temporary count pill) and keep its aria-label dynamic so
screen readers still hear the unread count. Tighten nav a11y with
aria-current on the active item, aria-label on icon-only buttons, and a
proper <nav> landmark around the desktop Highlight group.
2026-04-19 14:42:59 -04:00
Anso e721742560 feat(ui): redesign node switcher as sidebar identity anchor (#694)
Replace the inline Select in the sidebar with a dedicated NodeSwitcher
component that always renders as an identity card, regardless of node
count. With two or more nodes it opens a Popover listing every node
with status dot, type, version, last-seen metadata, and an active-row
accent rail, matching the design language of the user menu and
notification panel.

Extract the relative-time formatters out of NodeManager into a shared
@/lib/relativeTime module with formatTimeUntil and formatTimeAgo, and
add a 'just now' / '<1m' branch so fresh heartbeats and imminent runs
read naturally.
2026-04-19 12:15:31 -04:00
Anso ed2a16af79 feat(notifications): deep-link bell rows to source stack and container logs (#692)
Add stack_name and container_name columns to notification_history so bell
rows can act as jump points. Producers (AutoHeal, Docker events) pass the
container context through dispatchAlert; the panel renders routable rows
as buttons that load the target stack and, when a container name is
present, open its logs modal. Non-structural notifications stay as
passive display rows.
2026-04-19 03:56:59 -04:00
Anso 7c01906e70 feat(ui): redesign user menu and notification panel (#691)
* feat(ui): redesign user menu and notification panel per design audit

Align both floating surfaces with the stack-view design language: cyan
signal rails, identity headers, strip containers, day-banded streams,
severity rails, Instrument Serif for hero words, and tracked-mono for
metadata. Extract the notifications popover out of EditorLayout into its
own component and introduce a reusable SegmentedControl primitive for
3-way radiogroups (Auto/Light/Dark theme, All/Unread/Alerts filter).

* fix(ui): pin accessible names on avatar and bell buttons

After the redesign the avatar button's text content became the user's
initials and the bell button's text became the unread count, which
displaced the title-based accessible names and broke
getByRole('button', { name: /profile/i }). Set aria-label explicitly on
both triggers and mark the bell badge aria-hidden so screen readers and
role-based selectors see stable names regardless of dynamic content.
2026-04-19 03:10:10 -04:00
Anso 9e41d5e6b8 feat(stack-view): anatomy panel replaces always-open yaml (#690)
* feat(stack-view): anatomy panel replaces always-open yaml

Introduce StackAnatomyPanel as the default right-column surface of the stack
view, replacing the always-visible Monaco YAML editor. The panel parses
compose.yaml client-side and surfaces services, ports, volumes, restart
policy, env file with missing-variable detection, network, and git/local
source in a compact, read-only format. An inline banner surfaces pending
image updates with risk classification (patch/minor/major) and an apply
button gated on edit permissions.

Compose editing remains one click away: the anatomy header exposes an
"edit compose.yaml" toggle that slides the Monaco editor into the same
right-column slot with the full tabs, Git Source, Save, and Save & Deploy
toolbar. Closing the editor discards unsaved changes and restores the
anatomy view.

The overall stack view is now a two-column grid: left column stacks the
identity header, per-container health strip, and logs viewer; right
column holds the anatomy panel or the Monaco editor. Missing-variable
detection covers the full compose interpolation grammar, including
defaulted (:- / -) and required (:? / ?) modifiers.

* fix(stack-view): restore Git Source accessible name on anatomy source row

The anatomy panel's source row is the primary Git Source control on the
default stack view. Without an explicit accessible name, screen readers
announced only the raw git ref or 'local', and role-based locators could
not target it.
2026-04-19 01:13:33 -04:00
Anso a65a1c0e86 feat(stack-view): per-container health strip and structured logs viewer (#689)
* feat(stack-view): per-container health strip and structured logs viewer

Replaces the flat container list with a per-container health strip showing
healthcheck state, uptime, port mapping with an open-app link, and live
cpu/memory/network sparklines fed by a 60-sample ring buffer on the stats
WebSocket.

Adds a structured logs viewer that parses docker timestamps (emitted by the
-t flag on the logs stream) and classifies each line by level. Rows render
as a DOM grid with filter pills (all / info / warn / err with count),
following indicator, and plain-text download. A segmented toggle switches
between the structured viewer and the original xterm view; the choice is
persisted in localStorage.

* fix(stack-view): disable no-control-regex for ANSI escape pattern

ANSI escape sequences start with ESC (0x1B), which is a control character.
The regex is intentional and cannot be rewritten without it.
2026-04-19 00:17:51 -04:00
Anso 82aabfe64c feat(stack-view): identity header with health state and action hierarchy (#688)
* feat(stack-view): identity header with health state and action hierarchy

Redesign the stack view header around three questions: what is this, is it
healthy, what does it do. Surface Docker healthcheck state, primary image
tag, and image digest; group actions by frequency.

- Backend: extend /api/stacks/:name/containers with healthStatus (healthy /
  unhealthy / starting / none), Image, and ImageID by inspecting each
  container in parallel.
- Frontend: replace flat CardHeader with breadcrumb, italic serif title,
  colored state pill with pulse, and a mono image/digest line with a
  one-click copy button for the full digest.
- Frontend: action hierarchy - primary cyan Restart/Start, outline Stop and
  Update, and an overflow menu for Rollback, Scan config, and Delete.
- Docs: new Stack header section and updated controlling-a-running-stack
  tables showing primary/secondary/overflow grouping.

* test(e2e): open overflow menu to reach stack Delete action

Destructive actions now live under the stack toolbar overflow menu rather
than as a flat top-level button, so the delete flow must click More actions
before selecting the Delete menu item.
2026-04-18 23:38:56 -04:00
Anso bd94ef9e15 feat(sidebar): global multi-node stack search with status (#685)
Sidebar search now fans out to every online node and surfaces matches
from the whole fleet under an "Other nodes" section, with UP/DN status
badges fetched in parallel via the bulk /stacks/statuses endpoint.
Clicking a remote result switches the active node and opens the stack.

Also fixes a cmdk reconciliation crash ("Failed to execute 'appendChild'
on 'Node'") that fired when typing in the search input, by disabling
cmdk's internal filtering since filtering is already controlled.
2026-04-18 19:20:08 -04:00
Anso 95278843cf feat(schedules): next-24h timeline + merge auto-update into schedules (#681)
* feat(backend): add stack update-preview endpoint for readiness board

Adds GET /api/stacks/:stackName/update-preview that returns per-image
semver diff, bump classification, and a stack-level summary powering
the Auto-Update readiness board.

- New UpdatePreviewService parses compose images, inspects local
  digests, fetches remote digests and tag lists, and finds the
  highest compatible semver tag.
- Major bumps are flagged blocked until human review; unknown bumps
  rank below real semver so they cannot mask a major.
- Rollback target is reconstructed through parseImageRef to preserve
  registry ports and drop the Docker Hub library/ prefix.
- Registry helpers (httpGet, auth token, digest, tag list, ref parse)
  are extracted into registry-api.ts and shared with ImageUpdateService.
- 28 Vitest cases cover parse, selection, bump math, digest rebuilds,
  blocked policy, and rollback target construction.

* feat(schedules): next-24h timeline, merge auto-update crud, add readiness board

Replace the flat task table with a Timeline view as the default, showing the
next 24 hours of scheduled work across four lanes (Restart, Update, Scan,
Prune) with a live now rail and per-firing pills. The All tasks tab preserves
the existing CRUD surface.

Merge Auto-update Stack into Schedules as a first-class action and replace the
standalone Auto-Update Policies view with a per-stack Readiness board that
surfaces version diffs, risk tags, changelog previews, and rollback targets
sourced from the stack update-preview endpoint.
2026-04-18 17:48:02 -04:00
Anso 5bb4b01953 feat: auto-heal policies for unhealthy containers (#671)
* feat(db): add auto_heal_policies and auto_heal_history schema and CRUD

Adds two new SQLite tables (auto_heal_policies, auto_heal_history) to
DatabaseService.initSchema() and exposes CRUD methods: getAutoHealPolicies,
getAutoHealPolicy, addAutoHealPolicy, updateAutoHealPolicy,
deleteAutoHealPolicy, recordAutoHealHistory, getAutoHealHistory,
incrementConsecutiveFailures, resetConsecutiveFailures, setPolicyEnabled.
Also adds AutoHealPolicy and AutoHealHistoryEntry TypeScript interfaces.

* feat(events): track health-status duration and expose state accessors

- Add healthStatus and unhealthySince fields to InternalContainerState
- onHealthStatus now records unhealthySince timestamp on first transition
  to unhealthy, and clears it when the container recovers or restarts
- onStart resets both fields so a restarted container begins from 'starting'
- Add listContainerStates() and getContainerState() public accessors for
  use by the upcoming AutoHealService evaluator

* fix(auto-heal): key allowlist in updateAutoHealPolicy, cascade delete, extract ContainerHealthSnapshot

* feat: add AutoHealService evaluator singleton

Polls every 30 s, matches containers to enabled policies via Compose
labels, and restarts containers that have been unhealthy beyond the
configured threshold. Enforces cooldown, per-hour rate cap, and
recent-user-action suppression; auto-disables policies after repeated
consecutive failures. Also adds DockerEventManager.getService() accessor
required by the evaluator.

* fix(auto-heal): prune stale restartTimestamps, guard undefined policy id

- Prune restartTimestamps entries for containers no longer running after
  each container list fetch, preventing unbounded map growth from dead
  container IDs.
- Guard against policies with undefined id at the start of the per-policy
  loop; warn and skip rather than proceed with a non-null assertion.
- Extract handleAutoDisable private helper to bring executeHeal under 30
  lines and isolate the auto-disable side-effect sequence.
- Move ContainerInfo type to module scope.

* feat: add auto-heal API routes and wire AutoHealService lifecycle

Registers five REST endpoints under /api/auto-heal/policies (list, create,
patch, delete, history) with requirePaid + requireAdmin guards and Zod
validation. Wires AutoHealService.start()/stop() into the server startup
and graceful-shutdown blocks alongside MonitorService.

* test: add AutoHealService and DatabaseService auto-heal unit tests

- 15 unit tests for AutoHealService.shouldHeal covering all decision branches
  (healthy state, duration threshold, user-action suppression, cooldown,
  rate limiting, and correct skipReason values)
- 13 integration tests for DatabaseService auto-heal CRUD: policy round-trip,
  stack-name filter, partial update, cascade delete, history ordering/limit,
  consecutive failure counters, and setPolicyEnabled toggle

* fix: log AutoHealService shutdown errors consistently

* fix(api): requireAdmin-first guard order and try/catch on auto-heal routes

* feat(ui): add StackAutoHealSheet component

* feat(ui): add Auto-Heal context menu item to EditorLayout

* fix(ui): StackAutoHealSheet label, token, a11y, and useEffect fixes

- Rename 'All services in stack' to 'All services' in combobox options and placeholder
- Replace text-green-600 with text-success design token in actionColorClass
- Add htmlFor/id pairs to all four numeric form inputs for accessibility
- Inline fetch logic into useEffect, removing stale closure risk and eslint-disable comment
- Remove now-unused fetchPolicies and fetchServices standalone functions
- Update 'Auto-disable after' label to 'Auto-disable after (failures)' for clarity
- Add toast.error in policy fetch failure path; services fetch silently skips as before

* docs: add auto-heal-policies feature documentation

* test(e2e): add auto-heal policies CRUD spec

* fix(docs): correct auto-heal-policies nav position in docs.json
2026-04-17 22:45:06 -04:00
Anso a95bf1ff33 feat(security): secret and misconfiguration scanning (#651)
Extends Trivy scans with secret detection in image filesystems and
misconfiguration scanning for Compose stacks. Adds tabs to the scan
drawer for vulnerabilities, secrets, and misconfigs. Secret matches
are redacted server-side (first 8 chars + ellipsis) before storage.

- TrivyService: --scanners vuln,secret for images; trivy config for stacks
- DB: scanners_used/secret_count/misconfig_count cols; secret_findings,
  misconfig_findings tables; cache key scoped by scanners
- Routes: POST /security/scan accepts scanners array (requirePaid when
  secret requested); POST /security/scan/stack; GET .../secrets and
  .../misconfigs (paid-tier reads)
- UI: tabs in VulnerabilityScanSheet; scan-options dropdown on images;
  Scan config button on stack header
2026-04-17 07:57:08 -04:00
Anso 8ee0c0c476 feat(security): scan comparison UI (#648)
Side-by-side vulnerability scan comparison with two entry points:

- Compare button plus inline baseline picker inside the scan drawer.
- New Scan History page reachable from the Resources Hub, grouping
  completed scans by image with a checkbox selection flow.

The comparison sheet shows a severity delta ribbon, Added/Removed/Unchanged
filters, and a paginated CVE table. Cross-image comparisons are allowed
but flagged with a warning. Compare access is gated to Skipper and
Admiral tiers; the underlying /security/compare endpoint is unchanged.
2026-04-16 23:15:36 -04:00
Anso b2f341b43d feat: docker run to compose converter (#623)
* feat(convert): harden /api/convert endpoint with auth, validation, and tests

Applies authMiddleware to the docker run to compose endpoint, validates
that the payload is a non-empty string within an 8192 character budget,
rejects inputs containing null bytes, and wraps composerize in a
try/catch that surfaces a 422 with a clear message when the library
cannot produce a services block. Adds a Vitest suite covering the auth
gate, happy path, common flag coverage, boundary and null byte
placement variants, and malformed command handling.

* feat(editor): add From Docker Run tab to create stack dialog

Introduces a third tab in the Create New Stack dialog that accepts a
docker run command, calls the converter endpoint, and previews the
returned compose YAML before writing it to a new stack directory. Uses
the defensive toast pattern, clears the stale preview when the input
changes, and rolls back the empty stack directory if saving the
converted YAML fails so the user never ends up with an orphan stack.

* docs(stack-management): document docker run to compose converter

Adds a Convert from a docker run command section to the stack
management page covering how to use the new tab, the list of supported
flags, and troubleshooting for unparseable inputs. Screenshots show the
empty tab, a successful conversion with the compose preview, the
resulting stack in the editor, and the error toast surfaced when the
input cannot be converted. Appends a matching entry to the
troubleshooting page.
2026-04-15 20:23:12 -04:00
Anso 6529a24530 feat(git-sources): harden create-from-git with LFS + submodule warnings (#609)
* feat(git-sources): surface LFS and submodule warnings on create

Creating a stack from a Git repo now detects two common anomalies and
tells the user about them rather than silently producing broken stacks.

- LFS-pointer compose/env files fail early with a clear error instead
  of writing a 130-byte pointer stub to disk as real content.
- Repositories containing .gitmodules produce a non-fatal warning so
  the user knows build contexts or volumes inside submodules will be
  empty at deploy time.

Also refines the create dialog: sr-only DialogDescription for a11y,
short commit SHA suffix on the success toast, env-path hint under the
"Sync .env" checkbox showing which path will be read, and a route-level
diagnostic log line gated on developer mode for support debugging.

* test(git-sources): cover LFS, submodule, and nested env_path paths

Adds unit coverage for the new LFS-pointer rejection and submodule
warning plumbing, plus a nested compose_path case that exercises the
default env_path resolution ("apps/web/compose.yaml" with sync_env on
and env_path unset writes "apps/web/.env" both to disk and to the DB).

Extends the E2E suite with a happy-path assertion that the full-length
commit SHA is returned in the create response, and a UI flow that
verifies the short-SHA suffix appears in the success toast.

* docs(git-sources): add troubleshooting for LFS, submodules, HTTPS-only

Adds troubleshooting entries for the newly surfaced LFS and submodule
anomalies, expands the clone-timeout entry with the bounded-fetch
explanation, and adds a dedicated HTTPS-only entry. Also consolidates
the known limitations into a single list covering LFS, submodules,
branch-tracking, and HTTPS-only.

* fix(settings): use Route icon for notification routing

The routing section in Settings previously used GitBranch, which now
clashes with the Git Source feature's icon across the editor. Switch
to Route (a branching-flow glyph) so routing rules have a distinct
visual identity and aren't visually conflated with Git-backed stacks.

* fix(git-sources): return 400 for upstream auth failures and disambiguate 404s

Upstream git-host auth failures were mapping to HTTP 401, which the frontend
apiFetch treats as a Sencho session expiry and fires the global logout event.
They now return 400 with code=AUTH_FAILED in the body so the UI can branch on
the discriminator without logging the user out. The status mapping moved into
utils/gitSourceHttp so it can be unit-tested without booting the app.

mapGitError also relied on the HttpError class alone, so any non-2xx response
(including 404) was classified as auth failure. It now inspects the numeric
status on err.data and considers whether a token was supplied, producing more
actionable messages for missing repos, private repos, and wrong-scope tokens.
2026-04-15 11:31:29 -04:00
Anso 3955267bbe feat(git-sources): create a stack from a Git repository (#606)
* refactor(git-sources): extract GitSourceFields from GitSourcePanel

Pure extraction of the repo/branch/path/auth/apply-mode form fields into a
reusable controlled component so the upcoming Create Stack from Git flow can
render the same form in the Create Stack dialog. No behavior change.

* feat(git-sources): create a stack from a Git repository

Add a From Git tab to the Create Stack dialog so users can name a new
stack, point it at a repo + branch + compose path, and have the compose
fetched, validated, written to disk, and linked in one shot. Optional
deploy-after-create runs the initial bring-up when requested.

Backend: new POST /api/stacks/from-git route gated by stack:create.
GitSourceService.createStackFromGit() fetches and validates before
touching disk, then creates the stack, writes the compose (and .env if
sync is enabled), and seeds the git source row with the fetched commit
so future pulls produce a clean diff. Runs under the per-stack lock so
a concurrent webhook cannot race the create. Deploy failure is
non-fatal and surfaced to the caller.

Frontend: the existing Create Stack dialog is now tabbed, with Empty
keeping the original single-field flow unchanged.

* test(git-sources): cover create-from-git endpoint and e2e flow

Service tests verify createStackFromGit seeds the last_applied columns
on success, writes the env file when sync is enabled, refuses an
invalid apply-matrix without fetching, rejects invalid compose
without leaving orphan state, and rolls back the on-disk stack dir
when a post-create step fails.

Route tests cover auth, missing stack_name, invalid stack name,
http:// rejection, oversized repo_url, and the 409 collision guard.

E2E adds a Create-stack-from-Git block covering tab visibility,
client-side HTTPS check, backend .git/config rejection, and a
happy-path fetch against a public demo repo (skipped on network
failure).

* docs(git-sources): document create-stack-from-git tab

Add a new section near the top describing the From Git tab in the
Create Stack dialog: what it does, the Deploy after create checkbox,
and the four failure modes (name collision, unreachable repo,
invalid compose, deploy-after-create failure).
2026-04-15 08:05:10 -04:00
Anso 377df7e546 feat(git-sources): link stacks to Git repositories with diff-and-apply workflow (#600)
* feat(git-sources): link stacks to Git repositories with diff-and-apply workflow

Add Git Sources so any stack can point at an HTTPS Git repository, branch, and
compose file path. Pulls fetch + validate the incoming commit, store a
diffable pending snapshot, and apply writes only after explicit confirmation
(or automatically, per the configured apply mode). Sibling .env sync is
optional. Works on the Community tier.

Apply modes:
- Review only: mark pending, wait for manual apply in the diff dialog
- Auto-write: write compose + env to disk, do not redeploy
- Auto-deploy: write files and run docker compose up -d

Webhook integration: webhooks can target the new "git-pull" action to trigger
a sync from CI. Per-source debounce prevents runaway pipelines from hammering
the repository host. Tokens are encrypted at rest and never returned to the
frontend.

Docs and tests included. Screenshots and Playwright E2E flows to follow.

* fix(git-sources): drop unnecessary useMemo on commit sha slice

React Compiler's lint rule rejected the manual dependency list because the
inferred dep ('pull') was less specific than the written one ('pull?.commitSha').
The computation is a cheap 7-char slice, so drop the useMemo entirely rather
than fight the rule.

* test(git-sources): add Playwright E2E flows and drop orphan source rows on stack delete

- E2E coverage: non-HTTPS URL rejected client-side, unreachable repo surfaces
  a toast error on save, and configure+remove walks the AlertDialog confirm path.
- Deleting a stack now also drops its linked Git source row so a future stack
  with the same name starts clean rather than inheriting a stale config.
2026-04-14 21:13:17 -04:00
Anso a695251f38 fix(labels): harden stack labels with nodeId filtering, concurrency guard, and test coverage (#552)
* fix(labels): add nodeId filter, existence check, stale cleanup, concurrency guard, and body validation

- Fix getStacksForLabel to filter by node_id (prevents cross-node data leak)
- Add getLabel(id, nodeId) for single-label existence check
- Add getLabelCount(nodeId) for enforcing per-node label limit (50)
- Add cleanupStaleAssignments to remove orphaned assignments for deleted stacks
- Add label/assignment cleanup to deleteNode transaction
- Add label existence check on bulk action endpoint (returns 404 for missing labels)
- Add concurrency guard on bulk actions (returns 429 if already in-flight)
- Add requireBody guard on all mutation endpoints
- Extract isSqliteUniqueViolation helper to deduplicate constraint checks
- Add MAX_LABELS_PER_NODE constant (50) with limit enforcement on create
- Add diagnostic logging on all label endpoints (gated behind developer_mode)
- Add operational log line for bulk action results

* fix(labels): use ScrollArea, show failure details, add loading feedback, deduplicate constants

- Replace overflow-y-auto div with ScrollArea in LabelAssignPopover (design system)
- Show failed stack names in bulk action error toast
- Add loading toast for context menu label toggle
- Disable bulk action menu items while a bulk action is running
- Disable "New Label" button at 50-label limit with "Limit reached" text
- Export LABEL_COLORS and MAX_LABELS_PER_NODE from LabelPill (single source of truth)
- Import shared constants in LabelAssignPopover and LabelsSection (remove duplicates)
- Add BulkActionResult interface to replace inline type assertion

* test(labels): add comprehensive coverage for label CRUD, assignments, and bulk edge cases

42 tests covering:
- getLabels: empty, ordered, node isolation
- getLabel: found, wrong node, nonexistent
- createLabel: returns with ID, duplicate name constraint
- getLabelCount: correct count, zero for empty node
- updateLabel: name, color, both, not found, wrong node
- deleteLabel: removes label, cascades assignments, wrong node no-op
- setStackLabels: assign, replace, clear, invalid ID throws
- getLabelsForStacks: correct mapping, empty result
- getStacksForLabel: correct results, node filter, empty for nonexistent
- cleanupStaleAssignments: removes stale, preserves valid, handles empty
- deleteNode: cascades labels and assignments
- Edge cases: atomicity, cascade across stacks, multi-label assignment

* docs(labels): document 50-label limit and bulk action failure details

* fix(labels): add missing LabelColor type imports and explicit parameter types

* refactor(labels): extract label types and constants to label-types.ts

Moves LabelColor, Label, LABEL_COLORS, and MAX_LABELS_PER_NODE out of
LabelPill.tsx into a dedicated non-component file. This fixes the
react-refresh/only-export-components lint error caused by mixing
constant exports with component exports.
2026-04-13 12:49:03 -04:00
Anso 2465f7607e fix(stacks): harden stack management with security, validation, and logging (#520)
* fix(stacks): harden stack management with security fixes, validation alignment, and logging

Validate WebSocket stack names with isValidStackName() to close a
path-traversal gap on the /api/stacks/:stackName/logs WS endpoint.
Align POST /api/stacks to use the canonical validator (allows underscores).
Replace error: any catch blocks with error: unknown + type narrowing.
Add cache invalidation to PUT /api/stacks/:stackName/env.
Rename DELETE param from :name to :stackName for consistency.

Add standard [Stacks] lifecycle logs and diagnostic [Stacks:debug] logs
gated behind the Developer Mode toggle (with 5s TTL cache).
Extract shared isDebugEnabled() and getErrorMessage() utilities.

Frontend: roll back optimistic status on API failure, guard unsaved
changes when switching stacks, pre-check duplicate names in App Store.

* docs(settings): update Developer Mode description to mention debug diagnostics
2026-04-12 05:43:15 -04:00
Anso 4950cd0bd0 fix(updates): scan all filesystem stacks for image updates (#514)
ImageUpdateService previously discovered stacks by iterating Docker
containers, which meant stacks without containers (e.g. after
docker compose down) were silently excluded from update checks.

Switch to a hybrid discovery approach: enumerate stacks from the
filesystem via FileSystemService.getStacks(), parse compose files
for image refs with .env variable resolution, then augment with
container-based image discovery for running stacks.

Also cleans up stale stack_update_status entries when stacks are
deleted or no longer exist on disk, and replaces the plain
update-available tooltip with an animated cursor follow pattern.
2026-04-12 03:17:37 -04:00
Anso 3ee4fe6e44 feat(topology): overhaul network topology with dagre layout, enriched nodes, and click-to-logs (#447)
Replace N+1 Docker API calls (inspectNetwork per network) with a
container-centric approach that fetches all networks and containers
in 2 parallel calls, then maps relationships in memory.

Add dagre auto-layout algorithm for hierarchical DAG visualization,
replacing the static two-row layout that caused edge spaghetti at scale.

Add "Show system networks" toggle, enrich container nodes with running
state indicators, stack badges, and base image names. Clicking a
running container opens its log viewer directly from the topology graph.
2026-04-08 20:05:22 -04:00
Anso 8920d8c55a feat(nav): add pulsing animation to local node status dot (#418)
* feat(nav): add pulsing animation to local node status dot

Apply the same animate-ping pattern used by the notification bell badge
to the local node context pill in the top header bar. The green dot
now pulses to give a stronger "live connection" signal.

* fix(nav): use animate-pulse instead of animate-ping for node status dot

Switch the local node pill dot from the ping pattern (expanding ring)
to animate-pulse for consistency with the remote node pill. Only the
notification bell badge should use animate-ping.
2026-04-07 01:44:33 -04:00
Anso 5b06992e05 fix(nav): remove toggle behavior on navigation tabs (#417)
Clicking an already-active navigation tab previously toggled back to
the dashboard/editor view. This was a holdover from the old UI where
the tab buttons were different. Under the current design it causes
confusion, so clicking the active tab now does nothing.
2026-04-07 01:44:28 -04:00
Anso a55d1245f8 fix(fleet): resolve version detection pipeline for Docker builds (#402)
* fix(fleet): resolve version detection pipeline for Docker builds

The Dockerfile backend-builder stage was missing a COPY of the root
package.json, causing generate-version.js to fall back to "0.0.0-dev"
at build time. At runtime, the filesystem walk also failed (root
package.json not in the final image), producing the string "unknown"
which the frontend rendered as "vunknown".

Changes:
- Dockerfile: copy root package.json into backend-builder stage
- CapabilityRegistry: return null (not "unknown") for unresolvable
  versions; add isValidVersion() type guard; normalize remote meta
  responses to strip "unknown"/"0.0.0-dev" sentinel values
- Fleet endpoints: hoist gateway version validation outside per-node
  loops; treat unresolvable remote versions as "potentially outdated"
  instead of silently marking them up to date
- FleetView: guard all version display points (card badge, update
  button, gateway label, modal columns) via shared formatVersion()
- EditorLayout, CapabilityGate: use shared isValidVersion utility
- New frontend/src/lib/version.ts shared utility
- Docs: add troubleshooting section for version display edge cases
- Screenshots: updated Fleet Overview and Node Updates modal

* docs: update fleet node updates screenshot with live remote node
2026-04-06 03:03:56 -04:00
Anso 2089e75ef1 fix(fleet): filter invalid version strings from UI display (#399)
Treat 'unknown' and '0.0.0-dev' as absent versions in the node
switcher, fleet overview, and capability gate. Remote nodes running
older Docker images that return these fallback values now show a
clean label instead of "vunknown".
2026-04-06 01:43:52 -04:00