Commit Graph

998 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
dependabot[bot] 3c30c2befe chore(deps): bump the all-npm-backend group in /backend with 6 updates (#846)
Bumps the all-npm-backend group in /backend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) | `1.37.5` | `1.37.6` |
| [openid-client](https://github.com/panva/openid-client) | `6.8.3` | `6.8.4` |
| [tar-stream](https://github.com/mafintosh/tar-stream) | `3.1.8` | `3.2.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.0` | `8.59.1` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1037.0` | `3.1038.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1037.0` | `3.1038.0` |


Updates `isomorphic-git` from 1.37.5 to 1.37.6
- [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases)
- [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.37.5...v1.37.6)

Updates `openid-client` from 6.8.3 to 6.8.4
- [Release notes](https://github.com/panva/openid-client/releases)
- [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/openid-client/compare/v6.8.3...v6.8.4)

Updates `tar-stream` from 3.1.8 to 3.2.0
- [Commits](https://github.com/mafintosh/tar-stream/compare/v3.1.8...v3.2.0)

Updates `typescript-eslint` from 8.59.0 to 8.59.1
- [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.1/packages/typescript-eslint)

Updates `@aws-sdk/client-ecr` from 3.1037.0 to 3.1038.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1037.0 to 3.1038.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: isomorphic-git
  dependency-version: 1.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: openid-client
  dependency-version: 6.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: tar-stream
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1038.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1038.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 16:46:57 -04:00
dependabot[bot] d6ddf2ae30 chore(deps): bump the all-npm-frontend group in /frontend with 3 updates (#845)
Bumps the all-npm-frontend group in /frontend with 3 updates: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react), [rollup-plugin-visualizer](https://github.com/btd/rollup-plugin-visualizer) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `lucide-react` from 1.11.0 to 1.14.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.14.0/packages/lucide-react)

Updates `rollup-plugin-visualizer` from 6.0.11 to 7.0.1
- [Changelog](https://github.com/btd/rollup-plugin-visualizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/btd/rollup-plugin-visualizer/compare/v6.0.11...v7.0.1)

Updates `typescript-eslint` from 8.59.0 to 8.59.1
- [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.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: rollup-plugin-visualizer
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: all-npm-frontend
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  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>
2026-04-29 16:32:52 -04:00
dependabot[bot] cb0161b77e chore(deps): bump contributor-assistant/github-action (#844)
Bumps the all-actions group with 1 update in the / directory: [contributor-assistant/github-action](https://github.com/contributor-assistant/github-action).


Updates `contributor-assistant/github-action` from 2.3.1 to 2.6.1
- [Release notes](https://github.com/contributor-assistant/github-action/releases)
- [Commits](https://github.com/contributor-assistant/github-action/compare/a895a435fcce79ecf28fbce61a4ef0f0dabc9853...ca4a40a7d1004f18d9960b404b97e5f30a505a08)

---
updated-dependencies:
- dependency-name: contributor-assistant/github-action
  dependency-version: 2.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 16:32:22 -04:00
SaelixCode b5d0e7e4db chore: verify docs sync in new organization 2026-04-29 15:53:45 -04:00
SaelixCode 0f45717d08 Merge branch 'main' of https://github.com/studio-saelix/sencho 2026-04-29 15:53:45 -04:00
Anso 9274584255 chore: migration heartbeat - verify CI (#847)
* chore: migration heartbeat - verify CI and GitHub App sync

* chore: add AnsoCode to CLA allowlist
2026-04-29 15:52:23 -04:00
SaelixCode a07790b190 chore: migration heartbeat - verify CI and GitHub App sync 2026-04-29 15:18:53 -04:00
SaelixCode 2c85bc7219 Merge branch 'main' of https://github.com/AnsoCode/Sencho
# Conflicts:
#	package.json
2026-04-29 09:28:43 -04:00
SaelixCode 3da0aa6036 chore: migrate repository URLs from AnsoCode/Sencho to studio-saelix/sencho
Updates all hardcoded GitHub repository references across 21 files:
- package.json: repository URL, bugs URL, homepage, description, author
- CONTRIBUTING.md: bug report template URL
- SECURITY.md: advisory URL, cosign cert-identity regexp
- .github/CODEOWNERS: @AnsoCode -> @studio-saelix/maintainers
- .github/workflows/ci.yml: repositories scope (Sencho -> sencho), docs-sync git URL
- .github/workflows/cla.yml: path-to-document URL
- .github/workflows/docker-publish.yml: cosign verify comment
- frontend/**/*.tsx: issues and changelog links (3 components)
- frontend/public/.well-known/security.txt: Contact and Policy URLs
- security/vex/sencho.openvex.json: @id field
- docs/openapi.yaml: license URL
- docs/docs.json: navbar and footer GitHub links (5 instances)
- docs/security.mdx: advisory and SECURITY.md links
- docs/reference/verifying-images.mdx: repo link + cosign regexp + legacy identity note
- docs/reference/contact.mdx: issues, LICENSE, advisory, policy, CoC links
- docs/reference/security-advisories.mdx: releases link
- docs/operations/verifying-images.mdx: cosign regexps and VEX download URL (6 instances)
- docs/operations/upgrade.mdx: releases links (2 instances)
- backend/src/utils/version-check.ts: GitHub Releases API endpoint

CHANGELOG.md intentionally excluded (release-please managed).
Legacy cosign identity note added for pre-migration image verification.
2026-04-29 09:24:20 -04:00
sencho-quartermaster[bot] a2d06a2ef6 docs: refresh screenshots (#843)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com>
2026-04-29 05:54:52 +00:00
sencho-quartermaster[bot] bc2b0a9725 chore(main): release 0.65.1 (#840)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-04-29 01:53:27 -04:00
SaelixCode c8287e43a0 chore(cla): add sencho-quartermaster[bot] to allowlist 2026-04-29 01:49:30 -04:00
SaelixCode 1a67c6c35b fix(cla): strictly enforce no-BOM UTF8 for signatures 2026-04-29 01:48:47 -04:00
SaelixCode 1f21526367 fix(cla): ensure signatures file has no BOM 2026-04-29 01:48:40 -04:00
SaelixCode 9d69ae73fc chore(cla): add bots to allowlist 2026-04-29 01:47:30 -04:00
SaelixCode 93569dc21e chore(cla): initialize signatures file on main branch 2026-04-29 01:43:43 -04:00
Anso 7afebc4e72 ci: force latest trivy version in scans (#841)
* fix(docker): upgrade CLI to v29.4.1 and Compose to v5.1.3, clear VEX

* fix(vex): restore CVE-2026-34040 not_affected statement for compose

* fix: correct VEX product matching for CVE-2026-34040

* ci: specify trivy-version latest to resolve CVE-2026-34040 false positive
2026-04-29 01:39:55 -04:00
Anso 7d4390a7e4 fix(backend): resolve ts-node dynamic import and TS2322 narrowing errors (#839)
* fix(backend): resolve ts-node dynamic import and TS2322 narrowing errors

* fix(backend): disable triple-slash reference lint error in convert.ts
2026-04-29 01:34:21 -04:00
Anso f788384b01 docs: update README header, revise CONTRIBUTING tier policy, add CLA (#838)
* docs: update README header, revise CONTRIBUTING tier policy, add CLA

* ci(github): pin CLA Assistant action to commit SHA
2026-04-29 01:31:36 -04:00
Anso 219dee720e fix(convert): resolve TS7016 and TS2322 for composerize dynamic import (#837)
* fix(convert): resolve TS7016 and TS2322 for composerize dynamic import

* fix(convert): remove triple-slash reference banned by ESLint
2026-04-29 01:18:23 -04:00
Anso e124874dac fix(docker): upgrade CLI to v29.4.1 and Compose to v5.1.3, clear VEX (#836)
* fix(docker): upgrade CLI to v29.4.1 and Compose to v5.1.3, clear VEX

* fix(vex): restore CVE-2026-34040 not_affected statement for compose

* fix: correct VEX product matching for CVE-2026-34040
2026-04-29 01:18:08 -04:00
Anso 154b811411 docs: cache-bust dashboard image in README (#835) 2026-04-28 13:01:48 -04:00
Anso f318ec5523 docs: updates to the README and screenshots (#834) 2026-04-28 12:57:45 -04:00
Anso bb50bed071 docs: overhaul README, CONTRIBUTING, and SECURITY (#833)
README: rewrite to lead with differentiation rather than a flat
feature list. Add "Why Sencho?" section covering the four key
competitive advantages (Pilot Agent NAT traversal, auto-heal
self-healing, atomic deployments, automation-first model). Expand
feature coverage from 8 bullets to six grouped domains reflecting
the full 38-feature surface. Add architecture note, docker run
one-liner, tier comparison table (Community/Skipper/Admiral), and
docs link section. Drop stale CI badge; add Docker Pulls badge.

CONTRIBUTING: add project layout reference, tier-gate usage guide,
TypeScript strictness reminder, and pointer to CLAUDE.md for full
coding standards.

SECURITY: update supported versions table from stale "0.2.x+" to
current "latest release" policy with a note on self-hosted update
cadence.
2026-04-28 12:29:48 -04:00
sencho-quartermaster[bot] 8d3fc1bc77 docs: refresh screenshots (#832)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com>
2026-04-28 14:33:18 +00:00
sencho-quartermaster[bot] fe53c7e3e5 chore(main): release 0.65.0 (#812)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-04-28 10:31:39 -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 46fae21e67 perf(backend): parallelize pruneManagedOnly removals (#830)
DockerController.pruneManagedOnly removed managed volumes,
networks, and images one at a time inside a serial for-await loop.
Each remove call hits the Docker daemon over the Unix socket with a
synchronous-from-the-caller's-perspective HTTP round-trip, so a
prune over N items took the sum of N round-trips. The daemon
handles concurrent removes fine for these resource types.

Wrap each loop in Promise.all so wall time tracks the slowest
single remove rather than the sum. The existing per-item try/catch
keeps the partial-failure semantics: a single resource that fails
to delete logs and continues; the rest still get removed.
JavaScript single-threading makes the shared reclaimedBytes
counter safe under the parallel awaits.
2026-04-28 10:26:49 -04:00
Anso 2000653fb4 perf(test): build baseline DB once via vitest globalSetup (#829)
Each test file's setupTestDb() previously re-ran the full
DatabaseService init path: initSchema (~30 CREATE TABLE IF NOT
EXISTS), 14 idempotent migrate*() methods, a bcrypt hash, and the
admin / settings seed inserts. With 82 files this was a meaningful
slice of the per-fork cold-start cost.

Move the build into a vitest globalSetup that runs once before any
worker boots. The baseline DB lands at a fixed temp path; each
worker's setupTestDb copies it into the per-file data dir, opens the
copy via DatabaseService.getInstance() (re-running the same
idempotent init as a no-op pass), then UPDATEs the seeded local
node's compose_dir to match the per-file COMPOSE_DIR (the baseline
recorded /app/compose because COMPOSE_DIR was unset when the seed
fired in initSchema; without realigning, file-routes tests 400 on
path traversal).

TEST_JWT_SECRET moves from a per-file randomBytes assignment to a
fixed constant in a new testConstants module so the value the
baseline seeds matches the value test files import for direct token
signing. setupTestDb re-exports it for back-compat with the existing
import sites.

A baseline-less measurement on the same machine flakes 30 of 82
files at the no-cap baseline; with this baseline copy, the same
tree drops to 0-3 failures (the residual environmental Windows
flakes) and ~47-52 s wall time.
2026-04-28 10:08:39 -04:00
Anso 65f43b8032 perf(test): cap vitest fork pool at 4 workers (#828)
Vitest's fork pool default scales with availableParallelism, which
on machines with many cores spawns dozens of fresh workers. Each
worker dynamic-imports the full Express stack (TypeScript transform
+ DB constructor + every migration) and saturates CPU on cold start.
Most of the previous suite wall time was spent waiting on this
contention rather than running tests.

Cap concurrency at 4 workers via the new top-level maxWorkers /
minWorkers options (Vitest 4 unified the previous
poolOptions.forks.maxForks under maxWorkers across pool types).
Local backend wall time drops from ~93 s to ~32-60 s on typical
runs. The pre-existing pre-cap fork-contention flakes (rate
limiting, metrics-routes, fleet integration) clear consistently
on the warm path; the remaining variance is environmental
(background processes, antivirus on Windows) and is what it was
before this change minus the contention floor.

testTimeout (30 s) and hookTimeout (45 s) stay generous so the
stress path in database-metrics and the HTTP integration suites
still cover their cold-start envelope on slow runners.
2026-04-28 10:08:26 -04:00
Anso f4338c9d6b perf(build): enable incremental tsc (#827)
backend/tsconfig.json had no incremental setting, so every tsc run
re-checked the full project from cold. The two frontend tsconfigs
already declared a tsBuildInfoFile path under node_modules/.tmp/ but
without incremental: true the file was never written, and the path
itself sits inside node_modules where npm ci wipes it on every fresh
install — neither of which actually persists incremental state.

Add incremental: true to all three configs and drop the broken
tsBuildInfoFile overrides. TypeScript's default places the buildinfo
next to the tsconfig (e.g. backend/tsconfig.tsbuildinfo); the root
.gitignore already covers *.tsbuildinfo so nothing leaks into git.

Local cold-vs-warm tsc --noEmit on the backend dropped from ~3.0s
to ~1.4s — ~2x speedup on the warm path. CI builds are still cold
because runners do not cache the buildinfo between jobs; that is a
separate workflow change.
2026-04-28 09:15:54 -04:00
Anso 405f9cd921 perf(frontend): parallelize auth bootstrap fetches (#826)
* perf(frontend): parallelize auth bootstrap fetches

AuthContext.checkAuth previously chained three sequential fetches:
/api/auth/status, then /api/auth/check, then /api/permissions/me.
The status check has to come first because its response decides
whether to early-return on the setup or mfa-pending path. The next
two are independent for an authenticated session and were costing
an extra round-trip on every cold load.

Run /api/auth/check and /api/permissions/me in parallel via
Promise.all. The permissions request is wasted on the rare
not-authenticated path (cookie expired or logged out) but that
trade-off is worth saving the round-trip on the common success
path. The .catch(() => null) on the permissions fetch and the
inner try/catch around .json() preserve the original fault
tolerance: a network failure or malformed body falls back to no
permissions data, with the global role still authoritative.

* fix(frontend): commit auth state without waiting on /permissions/me

The previous Promise.all awaited both fetches before calling
setAppStatus('authenticated'), so a slow /api/permissions/me would
delay the dashboard commit relative to the old serial code. The E2E
deploy-feedback tests at e2e/deploy-log-panel.spec.ts:168 and :236
race the dashboard render after page.reload() and were timing out
waiting for GET /api/stacks/<name> because the click target was not
yet wired when the slower permissions response held up state.

Keep both requests in parallel on the wire, but await only the auth
check before committing state. The permissions promise resolves in
the background and updates state via void permsPromise.then() so the
non-critical request never gates the bootstrap.
2026-04-28 09:02:08 -04:00
Anso e74b4db44d perf(frontend): lazy-load xterm chunk + addons (#825)
xterm-the-terminal-emulator and its three addons (fit, search,
serialize) used to be imported at module scope by Terminal.tsx,
BashExecModal.tsx, and HostConsole.tsx along with xterm's CSS.
Even though only Terminal.tsx is rendered eagerly inside the editor
layout, the static imports forced the ~660 KB xterm chunk plus the
xterm.css bytes into every cold app start regardless of whether a
user ever opened a terminal.

Move the bootstrap into a new frontend/src/lib/xtermLoader.ts
module. loadXtermModules() Promise.alls the four addon imports plus
the CSS, caches the result on a shared promise, and returns the
constructors. On rejection the cache is cleared so the next mount
can retry instead of rethrowing the same failed promise.

Three consumers (Terminal, BashExecModal, HostConsole) swap their
value imports for type-only InstanceType aliases from the loader,
then call loadXtermModules() inside their existing useEffect. A
mounted/cancelled flag in each effect closure prevents
initialisation if the component unmounts during the load.

The vite.config.ts manualChunks group from #823 already groups all
@xterm/* packages into the xterm chunk, so it now loads on demand
instead of being bundled into the entry chunk.
2026-04-28 08:31:03 -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 f5dd8af7db perf(frontend): split heavyweight vendors into manual chunks (#823)
* perf(frontend): split heavyweight vendors into manual chunks

Vite's default chunking grouped monaco-editor, the xterm addons,
recharts, @xyflow/react + @dagrejs/dagre, and motion into shared
chunks with the Sencho app code. Any small change in the app would
bust the cache for the heavyweight vendor code, costing repeat
visitors a fresh download.

Add explicit manualChunks for monaco, xterm, charts, flow, and
motion. Each vendor is large enough to justify its own HTTP/2
stream, and grouping them by library keeps their cache key stable
across feature releases.

Also wire rollup-plugin-visualizer behind ANALYZE=true so
`ANALYZE=true npm run build` emits dist/stats.html for ad-hoc
bundle inspection without affecting CI or production builds.

Bump build.chunkSizeWarningLimit from the default 500 KB to 1500 KB
since the monaco chunk is intentionally large; this preserves the
warning's signal value for genuine future regressions.

* fix(frontend): use manualChunks function form for vite 8 typing

Vite 8's OutputOptions overload narrows manualChunks to the
ManualChunksFunction shape, so the object form rejected the chunk
keys with TS2769. Convert to the equivalent function form using
node_modules path matching; same chunk groupings as before.

Also pin rollup-plugin-visualizer to ^6.0.0; 7.x raised the engine
floor to Node 22 and CI runs Node 20, so npm emitted EBADENGINE
warnings. Version 6 supports Node 18+ and exposes the same
visualizer({ filename, gzipSize, brotliSize }) API.
2026-04-28 03:00:08 -04:00
Anso eb1d627096 perf(docker): switch builder stages to npm ci (#822)
Both frontend-builder and backend-builder ran `npm install` even
though `prod-deps` already used `npm ci`. `npm install` walks the
dep graph and silently rewrites the lockfile when there is drift,
which costs build time and lets a stale lockfile slip into a release
image. `npm ci` enforces the lockfile, fails fast on drift, and skips
the resolution work since the dep tree is fully described by the
lockfile.

Both lockfiles are in sync (verified with `npm install
--package-lock-only` reporting "up to date"), so the change is a
pure tightening with no behavior delta in the happy path.
2026-04-28 02:35:50 -04:00
Anso 04f35fdf22 perf(backend): mark AWS SDK clients as optional dependencies (#821)
@aws-sdk/client-ecr and @aws-sdk/client-s3 each pull in dozens of
@smithy/* and middleware-* transitive packages but only fire when an
operator configures an ECR registry or cloud backup respectively. Move
both to optionalDependencies so the package classification matches
their runtime role and operators who never use either feature can run
`npm ci --omit=optional` for a ~150 MB-slimmer image.

The default Dockerfile install (`npm ci --omit=dev`) keeps shipping
the SDKs, so default installs are unchanged. The dynamic imports in
CloudBackupService.loadS3Sdk and RegistryService.fetchEcrToken now
catch a missing-module failure and throw a wrapped Error whose
message names the recovery path (`reinstall without --omit=optional`)
and whose cause propagates the original module-not-found error for
debugging.

Bumps tsconfig.json's target and lib to ES2022 so `new Error(msg,
{ cause })` is typed; Node 25 already supports this at runtime.
2026-04-28 02:08:39 -04:00
Anso 14c25a6dbc perf(backend): lazy-load @aws-sdk/client-s3 in CloudBackupService (#820)
@aws-sdk/client-s3 pulls in dozens of @smithy/* and middleware-*
transitive packages. Cloud backup is a Skipper+ opt-in feature and
the bulk of installs never configure it, but the eager top-level
import meant every cold start parsed the whole SDK regardless.

Wrap the import in a load-and-cache helper, make buildS3Client
async, and have it return both the S3Client and the SDK namespace
so each caller constructs commands from the same lazily-loaded
module. The pattern matches the existing dynamic import of
@aws-sdk/client-ecr in RegistryService and the lazy-loaded
composerize and isomorphic-git in PR #819.

Tests use vi.mock('@aws-sdk/client-s3', ...) returning named
exports, which works the same way for dynamic imports as it did
for the static ones.
2026-04-28 01:50:51 -04:00
Anso 329b4ec4e2 perf(backend): lazy-load composerize and isomorphic-git (#819)
Both modules are opt-in:
  - composerize (~2 MB) is only used by /api/convert when a user pastes a
    docker run command into the converter UI.
  - isomorphic-git plus isomorphic-git/http/node (~5 MB combined) only fire
    when a stack is created from a Git source.

Previously each was imported at module scope, parsing the whole package on
every cold start regardless of whether the feature was used. Wrap them in
small load-and-cache helpers so the first call resolves the module via
Node's loader and every subsequent call returns the cached reference.

The pattern matches the existing dynamic import of @aws-sdk/client-ecr in
RegistryService. Existing tests using vi.mock('isomorphic-git', ...) and
vi.mock('isomorphic-git/http/node', ...) keep working without changes
because dynamic and static imports share the same module registry.
2026-04-28 01:37:39 -04:00
Anso 279ec62dff perf(backend): replace docker system df shell-out with dockerode API (#818)
MonitorService.evaluate() forked the docker CLI every 30s and
walked the human-readable Reclaimable strings ("1.196GB", etc.)
with a regex to compute the janitor threshold check. The Docker
Engine API returns raw byte counts, and the existing
DockerController.getDiskUsage() already wraps it for images,
containers, and volumes. Extend that helper with reclaimable
build-cache bytes so MonitorService can sum the four categories
in one call.

Drops the child_process / promisify imports from MonitorService and
removes about 30 lines of stdout parsing. Also widens the explicit
return type of getDiskUsageClassified so the new fields aren't
silent runtime additions.
2026-04-28 01:16:07 -04:00
Anso 5cf4323511 perf(backend): batch audit_log inserts into a buffered transaction (#817)
Every mutating /api/* request runs an individual INSERT into audit_log
which serializes against other writers under burst load (SQLite's
single-writer model). Buffer the writes in DatabaseService and flush
them in a single transaction either every second or once the buffer
reaches 100 entries, whichever comes first.

Read paths (getAuditLogs, getAuditLogsInRange, cleanupOldAuditLogs)
drain the buffer first so callers always see a consistent view, which
keeps the existing test pattern of insert-then-read working.

Graceful shutdown flushes before db.close() so no entries are lost on
clean exit. The 1s flush timer is unref'd so the buffer cannot keep
the process alive on its own. The CLI resetMfa script flushes
explicitly before returning since it exits before the timer fires.
2026-04-28 00:59:04 -04:00
Anso 18cf2e65e8 perf(backend): parallelize independent startup initializers (#816)
The boot path awaited SelfUpdateService.initialize, DockerEventManager.start,
and TrivyService.initialize one at a time even though none of them depend on
each other. Group them into a single Promise.all so total cold-start time is
the slowest one rather than the sum.

Also convert the inner `docker compose version` probe in
SelfUpdateService.initialize from execFileSync to execFileAsync. Without that,
the synchronous spawn would block the event loop for up to 5 seconds and
silently serialize the other two members of the parallel block, defeating
the parallelization win for in-container deployments.

The synchronous service starts (Monitor, AutoHeal, ImageUpdate, Scheduler,
Mfa) are grouped together up front. They schedule timers whose first ticks
fire 5+ seconds out, so they safely run alongside the awaited block.
2026-04-28 00:33:17 -04:00
Anso 61a7e43d82 perf(proxy): cache LicenseService tier headers for the proxy hot path (#815)
The remote-node HTTP proxy and WebSocket forwarder read getTier() +
getVariant() on every forwarded request to set the Distributed License
Enforcement headers. Each call hits system_state 5+ times. Add a
30-second cached snapshot inside LicenseService and route every
license_status write through a new private setLicenseStatus() helper
so activate, deactivate, validate, and the auto-demote paths inside
getTier() all invalidate the cache.

Routing all license_status writes through one chokepoint also closes
a latent drift window: the self-heal paths in getTier() (trial
expired, offline grace exceeded, subscription expired) used to mutate
state silently and now invalidate the cache the same way explicit
license events do.

The TTL becomes a safety net against any future write that bypasses
the helper, not a load-bearing freshness bound. Existing 44 license
and distributed-license tests pass unchanged.
2026-04-28 00:13:07 -04:00
Anso 836e384d17 perf(backend): cache global_settings reads in DatabaseService (#814)
getGlobalSettings() runs a SELECT * on every call and is hit from 22
files, including the auth middleware (every authenticated request),
the WebSocket upgrade handler (every connection), and the debug-mode
gate (every diagnostic log line). Cache the result inside the service
on first read and invalidate on updateGlobalSetting().

The cached snapshot is Object.freeze'd and the public return type is
now Readonly<Record<string, string>> so accidental mutations are
caught at compile time. The settings GET handler that delete'd private
keys now takes a defensive shallow copy first.

The 5-second TTL cache in utils/debug.ts is now redundant and removed;
the service-level cache is strictly fresher (invalidates on write
rather than going stale for up to 5s).
2026-04-27 23:45:25 -04:00
Anso 502ee83438 chore(backend): move @types/* to devDependencies (#813)
The seven @types/* packages contain only TypeScript declaration files,
which are erased at compile time and have no runtime use. Listing them
under dependencies kept them installed in the production image for no
benefit; they belong under devDependencies so `npm ci --omit=dev`
prunes them from the runtime install.

Packages moved: @types/compression, @types/cors, @types/dockerode,
@types/express, @types/http-proxy, @types/semver, @types/ws.
2026-04-27 23:21:45 -04:00