diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43c70321..3d7be7dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,7 +242,7 @@ jobs: branch: chore/refresh-screenshots commit-message: "docs: refresh screenshots" title: "docs: refresh screenshots" - body: "Automated screenshot refresh — generated by the `update-screenshots` CI job on push to `main`." + body: "Automated screenshot refresh - generated by the `update-screenshots` CI job on push to `main`." add-paths: docs/images/ delete-branch: true @@ -276,7 +276,7 @@ jobs: if git clone "${REMOTE}" sencho-docs 2>/dev/null; then echo "Cloned existing sencho-docs." else - echo "Clone failed (repo is empty or has no default branch) — initializing." + echo "Clone failed (repo is empty or has no default branch) - initializing." mkdir -p sencho-docs cd sencho-docs git init diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d72de9..1a9d6833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,29 +47,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **auth:** Login and Setup pages redesigned with split-panel branding layout (dark branding panel + theme-aware form) * **auth:** Optional admin email field on Setup for future license recovery * **ui:** Mobile-responsive login/setup with compact logo header -* **fleet:** Fleet health summary cards — aggregated container, CPU, memory, and alert counts across all nodes (Pro) -* **fleet:** Container-level drill-down — expand stacks to see individual containers with state, uptime, and quick navigation (Pro) +* **fleet:** Fleet health summary cards - aggregated container, CPU, memory, and alert counts across all nodes (Pro) +* **fleet:** Container-level drill-down - expand stacks to see individual containers with state, uptime, and quick navigation (Pro) * **fleet:** Node sorting by name, CPU, memory, container count, or status with persistent preferences (Pro) * **fleet:** Status, type, and critical resource filtering with pill-style toolbar (Pro) * **fleet:** Fleet-wide search across node names and stack names (Pro) * **fleet:** Critical node detection with red badge for nodes exceeding 90% CPU or disk usage * **fleet:** Error toasts on stack and container fetch failures (replaces silent error swallowing) * **fleet:** ProGate now wraps placeholder content instead of empty children for a better upgrade preview -* **webhooks:** Custom CI/CD webhooks — create webhooks targeting specific stacks and actions (deploy, restart, stop, start, pull), trigger them from GitHub Actions, GitLab CI, or any HTTP client with HMAC-SHA256 signature authentication (Pro) -* **webhooks:** Execution history tracking — last 100 executions per webhook with status, duration, and error details +* **webhooks:** Custom CI/CD webhooks - create webhooks targeting specific stacks and actions (deploy, restart, stop, start, pull), trigger them from GitHub Actions, GitLab CI, or any HTTP client with HMAC-SHA256 signature authentication (Pro) +* **webhooks:** Execution history tracking - last 100 executions per webhook with status, duration, and error details * **webhooks:** Webhook management UI in Settings with create/edit/delete, enable/disable toggle, one-time secret reveal, and copy-to-clipboard for trigger URLs -* **rbac:** Role-based access control with admin and viewer roles — viewers get full read-only access to dashboard, stacks, logs, and stats while admins retain full control (Pro) -* **rbac:** User management UI in Settings — create, edit, and delete users with username/password/role controls, protected by admin-only access +* **rbac:** Role-based access control with admin and viewer roles - viewers get full read-only access to dashboard, stacks, logs, and stats while admins retain full control (Pro) +* **rbac:** User management UI in Settings - create, edit, and delete users with username/password/role controls, protected by admin-only access * **rbac:** Automatic migration of existing single-admin credentials to the new multi-user system on first boot -* **rbac:** Viewer restrictions across the entire UI — read-only editor, hidden action buttons, disabled host console, and disabled resource management -* **atomic:** Atomic deployments with automatic rollback — Sencho backs up compose.yaml and .env before deploying, and auto-restores if health probes detect crashed containers (Pro) -* **atomic:** Manual rollback button in the stack action bar — restore the previous deployment with one click when a backup exists (Pro) -* **atomic:** Health probes added to stack updates (previously only on deploys) — crashed containers trigger auto-rollback during updates too +* **rbac:** Viewer restrictions across the entire UI - read-only editor, hidden action buttons, disabled host console, and disabled resource management +* **atomic:** Atomic deployments with automatic rollback - Sencho backs up compose.yaml and .env before deploying, and auto-restores if health probes detect crashed containers (Pro) +* **atomic:** Manual rollback button in the stack action bar - restore the previous deployment with one click when a backup exists (Pro) +* **atomic:** Health probes added to stack updates (previously only on deploys) - crashed containers trigger auto-rollback during updates too * **atomic:** Webhook-triggered deploys and updates now use atomic rollback for Pro users -* **fleet:** Fleet-wide backups — snapshot all compose files and .env files across every node (local and remote) into a central backup stored in SQLite (Pro) -* **fleet:** Snapshot detail view — browse captured files per node and stack with inline preview in a collapsible tree -* **fleet:** Per-stack restore from any snapshot — overwrite current compose files with the snapshot version, with optional one-click redeploy (Pro admin) -* **fleet:** Graceful handling of offline nodes during snapshot creation — skipped nodes are recorded with reason and displayed as warnings +* **fleet:** Fleet-wide backups - snapshot all compose files and .env files across every node (local and remote) into a central backup stored in SQLite (Pro) +* **fleet:** Snapshot detail view - browse captured files per node and stack with inline preview in a collapsible tree +* **fleet:** Per-stack restore from any snapshot - overwrite current compose files with the snapshot version, with optional one-click redeploy (Pro admin) +* **fleet:** Graceful handling of offline nodes during snapshot creation - skipped nodes are recorded with reason and displayed as warnings + +* **licensing:** In-app "View Pricing" button redirects to sencho.io for plan selection - license key activation is the primary flow for self-hosted instances + +### Changed + +* **pricing:** Personal Pro - $7.99/month, $69.99/year, $249 lifetime (1 admin + 3 viewers) +* **pricing:** Team Pro - $49.99/month, $499.99/year, $1,499 lifetime (unlimited accounts) +* **pricing:** Lifetime pricing marked as time-limited early-adopter offer (90-day launch window) +* **licensing:** License variant (Personal/Team) now stored from Lemon Squeezy metadata and exposed in license API response + +### Removed + +* **licensing:** Server-side Lemon Squeezy checkout URL generation, webhook endpoint, and billing portal - not applicable to self-hosted model where each user runs their own instance +* **rbac:** Seat limits enforced server-side based on license variant - Personal Pro: 1 admin + 3 viewers, Team Pro: unlimited ### Fixed @@ -144,7 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **editor:** bundle Monaco locally to fix stuck Loading state ([0eaa45b](https://github.com/AnsoCode/Sencho/commit/0eaa45bd7f5a4b5db9d51a577d25175bbcb4ff77)) * **editor:** bundle Monaco locally to fix stuck Loading state and CSP block ([79fde6e](https://github.com/AnsoCode/Sencho/commit/79fde6e2bd598085abfc7c702f5745bdfd692aec)) * **editor:** Monaco CSP fix + release pipeline fixes ([36a9bf3](https://github.com/AnsoCode/Sencho/commit/36a9bf3109c096ddd5d8095089a6ffb7bd6dee8d)) -* **editor:** Monaco CSP fix + release pipeline fixes — v0.2.1 ([36a9bf3](https://github.com/AnsoCode/Sencho/commit/36a9bf3109c096ddd5d8095089a6ffb7bd6dee8d)) +* **editor:** Monaco CSP fix + release pipeline fixes - v0.2.1 ([36a9bf3](https://github.com/AnsoCode/Sencho/commit/36a9bf3109c096ddd5d8095089a6ffb7bd6dee8d)) ## [0.2.0](https://github.com/AnsoCode/Sencho/compare/v0.1.0...v0.2.0) (2026-03-24) @@ -169,15 +183,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- **fix(editor):** Monaco editor stuck on "Loading…" — `@monaco-editor/react` was fetching Monaco's loader from `cdn.jsdelivr.net` at runtime, which the Helmet CSP (`scriptSrc: 'self'`) correctly blocked. Fixed by configuring `loader.config({ monaco })` in `main.tsx` to use the locally bundled `monaco-editor` npm package and wiring a Vite `?worker` blob URL for the editor worker — no CDN requests, no CSP changes needed. -- **fix(ci):** `release-please.yml` used `GITHUB_TOKEN` to create release tags — GitHub's security model prevents `GITHUB_TOKEN`-triggered events from cascading to other workflow runs, so `docker-publish.yml` never fired after a release. Switched to `DOCS_REPO_TOKEN` (PAT) so tag creation correctly triggers the Docker Hub publish workflow. -- **fix(ci):** Screenshot refresh PR now auto-merges via `gh pr merge --auto --squash` after `peter-evans/create-pull-request` creates it — no more manual merge required on every `develop` push. +- **fix(editor):** Monaco editor stuck on "Loading…" - `@monaco-editor/react` was fetching Monaco's loader from `cdn.jsdelivr.net` at runtime, which the Helmet CSP (`scriptSrc: 'self'`) correctly blocked. Fixed by configuring `loader.config({ monaco })` in `main.tsx` to use the locally bundled `monaco-editor` npm package and wiring a Vite `?worker` blob URL for the editor worker - no CDN requests, no CSP changes needed. +- **fix(ci):** `release-please.yml` used `GITHUB_TOKEN` to create release tags - GitHub's security model prevents `GITHUB_TOKEN`-triggered events from cascading to other workflow runs, so `docker-publish.yml` never fired after a release. Switched to `DOCS_REPO_TOKEN` (PAT) so tag creation correctly triggers the Docker Hub publish workflow. +- **fix(ci):** Screenshot refresh PR now auto-merges via `gh pr merge --auto --squash` after `peter-evans/create-pull-request` creates it - no more manual merge required on every `develop` push. ### Fixed -- **fix(ci):** `docker-publish.yml` was triggered by `release: types: [published]` (GitHub Release event) instead of `push: tags: v*` — pushing a git tag never fired the workflow. Changed trigger to `push: tags: v*` and updated `enable` conditions from `github.event_name == 'release'` to `startsWith(github.ref, 'refs/tags/v')` so any `v*` tag push automatically builds and publishes `latest` + semver tags to Docker Hub without requiring a manual GitHub Release. +- **fix(ci):** `docker-publish.yml` was triggered by `release: types: [published]` (GitHub Release event) instead of `push: tags: v*` - pushing a git tag never fired the workflow. Changed trigger to `push: tags: v*` and updated `enable` conditions from `github.event_name == 'release'` to `startsWith(github.ref, 'refs/tags/v')` so any `v*` tag push automatically builds and publishes `latest` + semver tags to Docker Hub without requiring a manual GitHub Release. ### Added -- **feat(ci):** Automated versioning via `release-please` — on every push to `main`, the `release-please` workflow opens or updates a Release PR with a generated CHANGELOG entry and `package.json` version bump. Merging the Release PR creates the `vX.Y.Z` tag which triggers `docker-publish.yml`. Version bumps follow Conventional Commits: `fix:` → patch, `feat:` → minor, `feat!:` / `BREAKING CHANGE:` → major. +- **feat(ci):** Automated versioning via `release-please` - on every push to `main`, the `release-please` workflow opens or updates a Release PR with a generated CHANGELOG entry and `package.json` version bump. Merging the Release PR creates the `vX.Y.Z` tag which triggers `docker-publish.yml`. Version bumps follow Conventional Commits: `fix:` → patch, `feat:` → minor, `feat!:` / `BREAKING CHANGE:` → major. --- @@ -185,64 +199,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- **Fixed:** Missing `authMiddleware` on `GET /api/notifications`, `POST /api/notifications/read`, `DELETE /api/notifications/:id`, `DELETE /api/notifications`, `POST /api/notifications/test`, and `POST /api/system/console-token` — any unauthenticated client could reach these endpoints. -- **Fixed:** Remote node `api_url` accepted without validation — an attacker could set it to `http://localhost:6379` to SSRF into internal services. Now validates: must be a well-formed `http://` or `https://` URL and the hostname may not be `localhost`, `127.x.x.x`, `[::1]`, or `0.0.0.0`. -- **Fixed:** `env_file` paths in `compose.yaml` were accepted without boundary checking — absolute paths like `/etc/passwd` could be read or written. All resolved env file paths are now validated to stay within the stack directory. -- **Fixed:** Stack name validated in write routes but not GET routes — path-traversal names now return 400 on all routes. +- **Fixed:** Missing `authMiddleware` on `GET /api/notifications`, `POST /api/notifications/read`, `DELETE /api/notifications/:id`, `DELETE /api/notifications`, `POST /api/notifications/test`, and `POST /api/system/console-token` - any unauthenticated client could reach these endpoints. +- **Fixed:** Remote node `api_url` accepted without validation - an attacker could set it to `http://localhost:6379` to SSRF into internal services. Now validates: must be a well-formed `http://` or `https://` URL and the hostname may not be `localhost`, `127.x.x.x`, `[::1]`, or `0.0.0.0`. +- **Fixed:** `env_file` paths in `compose.yaml` were accepted without boundary checking - absolute paths like `/etc/passwd` could be read or written. All resolved env file paths are now validated to stay within the stack directory. +- **Fixed:** Stack name validated in write routes but not GET routes - path-traversal names now return 400 on all routes. - **Fixed:** `stackParam` query parameter on `/api/system/host-console` now validated against `path.resolve` + `startsWith(baseDir)` to prevent directory traversal when setting the PTY working directory. -- **Fixed:** `HostTerminalService` no longer forwards full `process.env` to spawned PTY shells — `JWT_SECRET`, `AUTH_PASSWORD`, `AUTH_PASSWORD_HASH`, and `DATABASE_URL` are stripped before the shell is spawned. +- **Fixed:** `HostTerminalService` no longer forwards full `process.env` to spawned PTY shells - `JWT_SECRET`, `AUTH_PASSWORD`, `AUTH_PASSWORD_HASH`, and `DATABASE_URL` are stripped before the shell is spawned. - **Fixed:** Host Console and container exec WebSocket endpoints now reject `node_proxy` scoped JWT tokens with HTTP 403. - **Fixed:** `GET /api/settings` no longer leaks `auth_username`, `auth_password_hash`, or `auth_jwt_secret` to the frontend. -- **Fixed:** `POST /api/settings` enforces a strict allowlist of writable keys — auth credential keys and unknown keys are rejected with a 400 error. -- **Added:** Rate limiting on `/api/auth/login` and `/api/auth/setup` — 5 attempts per 15-minute window per IP, using `express-rate-limit`. +- **Fixed:** `POST /api/settings` enforces a strict allowlist of writable keys - auth credential keys and unknown keys are rejected with a 400 error. +- **Added:** Rate limiting on `/api/auth/login` and `/api/auth/setup` - 5 attempts per 15-minute window per IP, using `express-rate-limit`. - **Added:** `helmet` middleware for security response headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, etc.). - **Changed:** CORS is now restricted to `FRONTEND_URL` env var in production; development continues to allow any origin. ### Added #### Infrastructure & CI -- `linux/arm64` platform support in the Docker Hub publish workflow (Raspberry Pi 4/5, Oracle ARM VMs) — native modules (`bcrypt`, `better-sqlite3`, `node-pty`) cross-compiled via `tonistiigi/xx` to eliminate the `SIGILL` crash caused by Node.js v20 using ARMv8.1 LSE atomic instructions unsupported by GitHub Actions QEMU. -- `docker/setup-qemu-action@v3` step to `docker-publish.yml` — without it multi-platform builds hung indefinitely. +- `linux/arm64` platform support in the Docker Hub publish workflow (Raspberry Pi 4/5, Oracle ARM VMs) - native modules (`bcrypt`, `better-sqlite3`, `node-pty`) cross-compiled via `tonistiigi/xx` to eliminate the `SIGILL` crash caused by Node.js v20 using ARMv8.1 LSE atomic instructions unsupported by GitHub Actions QEMU. +- `docker/setup-qemu-action@v3` step to `docker-publish.yml` - without it multi-platform builds hung indefinitely. - Automated Docker Hub CI/CD pipeline publishing `dev` and `latest` tags. - Automated documentation pipeline with Mintlify sync and screenshot refresh CI job. -- `HEALTHCHECK` directive in `Dockerfile` — Docker polls `/api/health` every 30 s and restarts an unhealthy container. +- `HEALTHCHECK` directive in `Dockerfile` - Docker polls `/api/health` every 30 s and restarts an unhealthy container. - `GET /api/health` public endpoint returning `{ status: "ok", uptime }`. -- `docker-entrypoint.sh` — runs as root, fixes `$DATA_DIR` volume ownership, then drops to the non-root `sencho` user via `su-exec` before starting Node. Eliminates `SQLITE_READONLY` crashes on host-mounted volumes. +- `docker-entrypoint.sh` - runs as root, fixes `$DATA_DIR` volume ownership, then drops to the non-root `sencho` user via `su-exec` before starting Node. Eliminates `SQLITE_READONLY` crashes on host-mounted volumes. - Non-root `sencho` system user in `Dockerfile`; process no longer runs as root. -- Graceful shutdown — backend listens for `SIGTERM`/`SIGINT`, drains HTTP connections, stops `MonitorService` and `ImageUpdateService`, and closes the SQLite connection before exiting. -- Vitest backend test suite — 38 tests covering validation utilities, health endpoint, authentication flows, auth middleware enforcement, console-token security, and SSRF validation. Run with `cd backend && npm test`. -- Playwright E2E test scaffolding (`e2e/`) — auth, stack management, and node management specs with shared login helper. Run with `npm run test:e2e`. +- Graceful shutdown - backend listens for `SIGTERM`/`SIGINT`, drains HTTP connections, stops `MonitorService` and `ImageUpdateService`, and closes the SQLite connection before exiting. +- Vitest backend test suite - 38 tests covering validation utilities, health endpoint, authentication flows, auth middleware enforcement, console-token security, and SSRF validation. Run with `cd backend && npm test`. +- Playwright E2E test scaffolding (`e2e/`) - auth, stack management, and node management specs with shared login helper. Run with `npm run test:e2e`. - CI workflow runs Vitest unit tests and ESLint on every PR. - `isValidStackName`, `isValidRemoteUrl`, `isPathWithinBase` extracted to `backend/src/utils/validation.ts` for reuse and testability. #### Multi-Node & Distributed API -- Distributed API proxying using `http-proxy-middleware` for HTTP and WebSockets — replaces the SSH/SFTP architecture entirely (~500 lines removed). +- Distributed API proxying using `http-proxy-middleware` for HTTP and WebSockets - replaces the SSH/SFTP architecture entirely (~500 lines removed). - Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`). - `nodeContextMiddleware` in Express to dynamically extract `x-node-id` headers and `?nodeId=` query parameters for WebSocket upgrades. - `NodeRegistry` service managing multiple Docker daemon connections. - Node management API endpoints: list, get, create, update, delete, and test connection. -- Two-tier scoped navigation UX — context pill in the top header always shows the active node name (pulsing blue for remote, green for local). +- Two-tier scoped navigation UX - context pill in the top header always shows the active node name (pulsing blue for remote, green for local). - Remote-aware headers in `HostConsole`, `ResourcesView`, `GlobalObservabilityView`, and `AppStoreView`. -- `SettingsModal` scopes its sidebar to the active node type — global-only tabs hidden when a remote node is active. -- Cross-node notification aggregation — notification bell surfaces alerts from all connected remote nodes with dedicated real-time WebSocket connections per remote node. -- Remote node host console and container exec WebSocket proxy — gateway exchanges `node_proxy` token for a short-lived `console_session` JWT (60 s TTL) before forwarding. -- `localOnly` option on `apiFetch` — omits `x-node-id` so requests always route to the local node. +- `SettingsModal` scopes its sidebar to the active node type - global-only tabs hidden when a remote node is active. +- Cross-node notification aggregation - notification bell surfaces alerts from all connected remote nodes with dedicated real-time WebSocket connections per remote node. +- Remote node host console and container exec WebSocket proxy - gateway exchanges `node_proxy` token for a short-lived `console_session` JWT (60 s TTL) before forwarding. +- `localOnly` option on `apiFetch` - omits `x-node-id` so requests always route to the local node. #### Application Features -- **App Store** — LinuxServer.io API integration as default template registry with rich metadata (architectures, docs links, GitHub links), category filter, one-click deployment, atomic rollback on failure, custom Portainer v2 registry URL support, editable ports/volumes/environment variables, post-deploy health probe. -- **Resources Hub** — Images, Volumes, and Networks tabs with Managed/External/Unused classification, Docker Disk Footprint stacked-bar widget, scoped prune operations (Sencho-only vs All Docker), managed/external filter toggles, and classification badges. -- **Global Observability** — centralized dashboard tracking 24-hour historical metrics and aggregating global tail logs across all containers. Dozzle-style Action Bar with multi-select stack filtering, search, STDOUT/STDERR toggles, and Developer Mode SSE real-time streaming. -- **Background image update checker** — polls OCI-compliant registries every 6 hours using manifest digest comparison; results cached in `stack_update_status` table; pulsing blue dot badge on stacks with available updates. -- **Real-time WebSocket notifications** — replaces 5-second polling; `NotificationService.setBroadcaster()` pushes each new alert to all authenticated subscribers the moment it fires. +- **App Store** - LinuxServer.io API integration as default template registry with rich metadata (architectures, docs links, GitHub links), category filter, one-click deployment, atomic rollback on failure, custom Portainer v2 registry URL support, editable ports/volumes/environment variables, post-deploy health probe. +- **Resources Hub** - Images, Volumes, and Networks tabs with Managed/External/Unused classification, Docker Disk Footprint stacked-bar widget, scoped prune operations (Sencho-only vs All Docker), managed/external filter toggles, and classification badges. +- **Global Observability** - centralized dashboard tracking 24-hour historical metrics and aggregating global tail logs across all containers. Dozzle-style Action Bar with multi-select stack filtering, search, STDOUT/STDERR toggles, and Developer Mode SSE real-time streaming. +- **Background image update checker** - polls OCI-compliant registries every 6 hours using manifest digest comparison; results cached in `stack_update_status` table; pulsing blue dot badge on stacks with available updates. +- **Real-time WebSocket notifications** - replaces 5-second polling; `NotificationService.setBroadcaster()` pushes each new alert to all authenticated subscribers the moment it fires. - **Live Container Logs** viewer using SSE for real-time terminal output. -- **Animated design system** — `motion` package and `animate-ui` library; new brand cyan token; spring-based dialog/tooltip/tab animations; `prefers-reduced-motion` respected globally; Geist font via Google Fonts CDN. -- Theme-aware sidebar logo — dark and light variants auto-switch based on active theme. +- **Animated design system** - `motion` package and `animate-ui` library; new brand cyan token; spring-based dialog/tooltip/tab animations; `prefers-reduced-motion` respected globally; Geist font via Google Fonts CDN. +- Theme-aware sidebar logo - dark and light variants auto-switch based on active theme. - Auto theme option (light/dark/auto) with `window.matchMedia` listener. -- `PATCH /api/settings` bulk-update endpoint — validates all values via Zod schema, persists atomically in a single SQLite transaction. -- `system_state` SQLite table — separates runtime operational state from user-defined config in `global_settings`. -- Configurable `metrics_retention_hours` (default: 24 h) and `log_retention_days` (default: 30 d) — `MonitorService` reads these dynamically each cycle. -- Managed/unmanaged container count split in `GET /api/stats` — Home Dashboard "Active Containers" card shows "N managed · N external". -- Two-Stage Teardown for stack deletion — `docker compose down` sweeps ghost networks before deployment files are deleted. +- `PATCH /api/settings` bulk-update endpoint - validates all values via Zod schema, persists atomically in a single SQLite transaction. +- `system_state` SQLite table - separates runtime operational state from user-defined config in `global_settings`. +- Configurable `metrics_retention_hours` (default: 24 h) and `log_retention_days` (default: 30 d) - `MonitorService` reads these dynamically each cycle. +- Managed/unmanaged container count split in `GET /api/stats` - Home Dashboard "Active Containers" card shows "N managed · N external". +- Two-Stage Teardown for stack deletion - `docker compose down` sweeps ghost networks before deployment files are deleted. - Custom Environment Variable injection tool in deployment UI. - `ErrorBoundary` component now wraps root `` in `main.tsx`. - Git Flow branching strategy and branch protection. @@ -250,87 +264,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed #### Authentication & Proxy -- Login loop caused by remote node auth failure — `apiFetch` now only fires `sencho-unauthorized` when the `x-sencho-proxy: 1` header is absent (i.e., a genuine local session failure, not a remote node auth error). -- `authMiddleware` and WS upgrade handler now evaluate `bearerToken || cookieToken` (Bearer first) — cookie no longer shadows a valid Bearer token on node-to-node proxy calls. -- Remote node proxy stripping the `/api` path prefix — added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to remote instances. -- Remote node HTTP proxy body forwarding — replaced `proxyReq.write(JSON.stringify(req.body))` (raced against `http-proxy`'s `process.nextTick(proxyReq.end)`) with a conditional JSON body parser that skips `express.json()` for remote-targeted requests; the raw `IncomingMessage` stream is left unconsumed so `http-proxy`'s `req.pipe(proxyReq)` forwards it intact. -- Remote node proxy forwarding the browser's `sencho_token` cookie to the remote instance — stripped in `proxyReq` so only the Bearer token is used. -- Remote WebSocket upgrades forwarding the browser `cookie` header — stripped before `wsProxyServer.ws()` so the remote's `authMiddleware` uses the Bearer token exclusively. -- `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted node — exempted alongside `/api/auth/` so the frontend can re-sync a stale node ID. -- Backend memory leak from `createProxyMiddleware` called inside the request handler on every API call — refactored to a single globally-instantiated proxy using the `router` option. -- `remoteNodeProxy` error handler unsafely cast `proxyRes` to `Response` on WebSocket/TCP-level errors — type-narrowed before sending 502. +- Login loop caused by remote node auth failure - `apiFetch` now only fires `sencho-unauthorized` when the `x-sencho-proxy: 1` header is absent (i.e., a genuine local session failure, not a remote node auth error). +- `authMiddleware` and WS upgrade handler now evaluate `bearerToken || cookieToken` (Bearer first) - cookie no longer shadows a valid Bearer token on node-to-node proxy calls. +- Remote node proxy stripping the `/api` path prefix - added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to remote instances. +- Remote node HTTP proxy body forwarding - replaced `proxyReq.write(JSON.stringify(req.body))` (raced against `http-proxy`'s `process.nextTick(proxyReq.end)`) with a conditional JSON body parser that skips `express.json()` for remote-targeted requests; the raw `IncomingMessage` stream is left unconsumed so `http-proxy`'s `req.pipe(proxyReq)` forwards it intact. +- Remote node proxy forwarding the browser's `sencho_token` cookie to the remote instance - stripped in `proxyReq` so only the Bearer token is used. +- Remote WebSocket upgrades forwarding the browser `cookie` header - stripped before `wsProxyServer.ws()` so the remote's `authMiddleware` uses the Bearer token exclusively. +- `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted node - exempted alongside `/api/auth/` so the frontend can re-sync a stale node ID. +- Backend memory leak from `createProxyMiddleware` called inside the request handler on every API call - refactored to a single globally-instantiated proxy using the `router` option. +- `remoteNodeProxy` error handler unsafely cast `proxyRes` to `Response` on WebSocket/TCP-level errors - type-narrowed before sending 502. #### WebSocket & Streaming -- Container stats WebSocket flooding React with up to 20+ `setState` calls per second — replaced with a ref-buffer + 1.5 s flush interval pattern. -- `streamStats` Docker stats stream leaking after WebSocket client disconnect — `ws.on('close')` handler calls `stats.destroy()`; all `ws.send()` calls guarded with `readyState === OPEN`. -- `streamStats` and `execContainer` called unawaited — unhandled promise rejections now chain `.catch()`, log the error, and close the WebSocket cleanly. -- Per-connection `WebSocket.Server` instances for stack logs and host console never closed after upgrade — `wss.close()` called immediately after `handleUpgrade`. +- Container stats WebSocket flooding React with up to 20+ `setState` calls per second - replaced with a ref-buffer + 1.5 s flush interval pattern. +- `streamStats` Docker stats stream leaking after WebSocket client disconnect - `ws.on('close')` handler calls `stats.destroy()`; all `ws.send()` calls guarded with `readyState === OPEN`. +- `streamStats` and `execContainer` called unawaited - unhandled promise rejections now chain `.catch()`, log the error, and close the WebSocket cleanly. +- Per-connection `WebSocket.Server` instances for stack logs and host console never closed after upgrade - `wss.close()` called immediately after `handleUpgrade`. - WebSocket notification reconnect upgraded to exponential backoff (1 s → 30 s max) instead of flat 5-second retry; `ws.onerror` logs the event; cleanup guards against closing an already-closing socket. -- Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — gateway's `cookie` header stripped before forwarding to remote; `nodeId` query param stripped from forwarded URL. -- LogViewer returning 404 on remote nodes — `nodeId` query param stripped from `proxyReq.path` in `onProxyReq`. +- Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes - gateway's `cookie` header stripped before forwarding to remote; `nodeId` query param stripped from forwarded URL. +- LogViewer returning 404 on remote nodes - `nodeId` query param stripped from `proxyReq.path` in `onProxyReq`. #### UI & Frontend -- Blank page on HTTP deployments (root cause — Helmet 8 default CSP `upgrade-insecure-requests` and HSTS) — `upgradeInsecureRequests: null` and `strictTransportSecurity: false` set explicitly. -- COOP header console warning on HTTP deployments — `crossOriginOpenerPolicy: false`. -- Inline script CSP violation from Vite module-preload polyfill — disabled via `build.modulePreload.polyfill: false`. -- CSP `workerSrc` missing (Monaco editor workers) — added `worker-src 'self' blob:`. -- CSP `connectSrc` implicit — added explicit `connect-src 'self' ws: wss:`. -- Docker socket `EACCES` root:root edge case — entrypoint handles GID 0 in addition to the standard root:docker case. -- Managed container count wrong when stacks launched from COMPOSE_DIR root — classification now uses `com.docker.compose.project.working_dir`. -- Browser Out of Memory crash in `GlobalObservabilityView` — capped DOM rendering to last 300 entries, reduced SSE log cap to 2,000 entries, replaced `key={idx}` with monotonic `_id` counter. -- `HomeDashboard` create-stack error handling — reads JSON error body before throwing; uses defensive toast pattern. -- `AlertDialogContent` using `asChild` with `motion.div` wrapper crashing on delete-stack confirmation — replaced with CSS keyframe animations. -- animate-ui `auto-height.tsx` importing `WithAsChild` without `type` keyword — crashed browser module loader. +- Blank page on HTTP deployments (root cause - Helmet 8 default CSP `upgrade-insecure-requests` and HSTS) - `upgradeInsecureRequests: null` and `strictTransportSecurity: false` set explicitly. +- COOP header console warning on HTTP deployments - `crossOriginOpenerPolicy: false`. +- Inline script CSP violation from Vite module-preload polyfill - disabled via `build.modulePreload.polyfill: false`. +- CSP `workerSrc` missing (Monaco editor workers) - added `worker-src 'self' blob:`. +- CSP `connectSrc` implicit - added explicit `connect-src 'self' ws: wss:`. +- Docker socket `EACCES` root:root edge case - entrypoint handles GID 0 in addition to the standard root:docker case. +- Managed container count wrong when stacks launched from COMPOSE_DIR root - classification now uses `com.docker.compose.project.working_dir`. +- Browser Out of Memory crash in `GlobalObservabilityView` - capped DOM rendering to last 300 entries, reduced SSE log cap to 2,000 entries, replaced `key={idx}` with monotonic `_id` counter. +- `HomeDashboard` create-stack error handling - reads JSON error body before throwing; uses defensive toast pattern. +- `AlertDialogContent` using `asChild` with `motion.div` wrapper crashing on delete-stack confirmation - replaced with CSS keyframe animations. +- animate-ui `auto-height.tsx` importing `WithAsChild` without `type` keyword - crashed browser module loader. - animate-ui `switch.tsx` double-spreading Radix props onto `motion.button` DOM element. -- "Always Local" badge tooltip crashing (`getStrictContext`) — replaced animate-ui tooltip with pure Radix primitives. +- "Always Local" badge tooltip crashing (`getStrictContext`) - replaced animate-ui tooltip with pure Radix primitives. - Cancel/Add Node buttons in NodeManager dialogs stuck together. - Resources/App Store/Logs menu buttons not toggling off on second click. -- Monaco container height accumulation on tab switching — reset to 0×0 and force synchronous reflow before re-measuring. -- `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` — all calls now inject `x-node-id`. +- Monaco container height accumulation on tab switching - reset to 0×0 and force synchronous reflow before re-measuring. +- `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` - all calls now inject `x-node-id`. - `HostConsole` WebSocket URL missing `?nodeId=` query parameter. -- "Open App" button opening `http://localhost:{port}` for remote node containers — resolves hostname from remote node's `api_url`. -- Dashboard cards showing stale local-node data after switching to a remote node — polling effects now depend on `activeNode?.id` and clear state immediately on node change. -- `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response — checks `res.ok` before calling `res.json()`. -- Four empty `catch {}` blocks in `EditorLayout` — now surface errors via `toast.error()`. +- "Open App" button opening `http://localhost:{port}` for remote node containers - resolves hostname from remote node's `api_url`. +- Dashboard cards showing stale local-node data after switching to a remote node - polling effects now depend on `activeNode?.id` and clear state immediately on node change. +- `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response - checks `res.ok` before calling `res.json()`. +- Four empty `catch {}` blocks in `EditorLayout` - now surface errors via `toast.error()`. - `StackAlertSheet` not fetching notification agent status from the active node on open. -- `SettingsModal` Notifications tab hidden when a remote node is active — now visible and configurable on remote nodes. -- `POST /api/alerts` now validates the request body with a Zod schema — rejects unknown metric/operator values, negative thresholds, and missing fields with a structured 400. +- `SettingsModal` Notifications tab hidden when a remote node is active - now visible and configurable on remote nodes. +- `POST /api/alerts` now validates the request body with a Zod schema - rejects unknown metric/operator values, negative thresholds, and missing fields with a structured 400. - `WebSocket.Server` replaced with named import `WebSocketServer` from `ws` to fix ESM/CJS interop. -- `NodeProvider` mounted outside the auth gate — moved inside the authenticated branch so `refreshNodes` no longer fires before authentication. -- Infinite re-fetch loop in `NodeContext` — `refreshNodes` useCallback no longer depends on `activeNode` state; replaced with `useRef`. -- Infinite page reload loop — `apiFetch` replaced `window.location.href = '/'` with a `sencho-unauthorized` custom event. -- API Token copy button failing silently on HTTP/non-localhost — added `execCommand('copy')` fallback. +- `NodeProvider` mounted outside the auth gate - moved inside the authenticated branch so `refreshNodes` no longer fires before authentication. +- Infinite re-fetch loop in `NodeContext` - `refreshNodes` useCallback no longer depends on `activeNode` state; replaced with `useRef`. +- Infinite page reload loop - `apiFetch` replaced `window.location.href = '/'` with a `sencho-unauthorized` custom event. +- API Token copy button failing silently on HTTP/non-localhost - added `execCommand('copy')` fallback. - E2E nodes tests permanently timing out because the Add Node submit button requires `api_token` to be non-empty. -- ESLint CI step — replaced all `any` annotations with proper types, fixed unused catch variables. -- `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` — suppressed at call site. -- Global Logs false-positive error misclassifications — replaced naive regex with a robust 3-tier classification engine. -- Memory leak in `GlobalObservabilityView` SSE mode — log array capped at 2,000 entries. -- Historical metrics memory leak — polling throttled to 60 s; SQLite payload downsampled by 12×. -- Active node UI dropdown desyncing from API requests on initial page load — state hydrated from localStorage. +- ESLint CI step - replaced all `any` annotations with proper types, fixed unused catch variables. +- `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` - suppressed at call site. +- Global Logs false-positive error misclassifications - replaced naive regex with a robust 3-tier classification engine. +- Memory leak in `GlobalObservabilityView` SSE mode - log array capped at 2,000 entries. +- Historical metrics memory leak - polling throttled to 60 s; SQLite payload downsampled by 12×. +- Active node UI dropdown desyncing from API requests on initial page load - state hydrated from localStorage. - `MonitorService` crash (`Cannot read properties of undefined (reading 'cpu_usage')`) during Docker container transition states. -- Deleted node ghost API calls — 404 errors intercepted globally, forcing UI to resync to default node. +- Deleted node ghost API calls - 404 errors intercepted globally, forcing UI to resync to default node. - Horizontal UI overflow in Node Manager settings on smaller resolutions. - Docker API parsing bug where HTML string responses from misconfigured ports were counted as containers. ### Changed -- **Architecture:** Replaced SSH/SFTP remote node model with Distributed API proxy (HTTP/WebSocket) — remote nodes now only require an API URL and Bearer token. Node Manager UI vastly simplified. +- **Architecture:** Replaced SSH/SFTP remote node model with Distributed API proxy (HTTP/WebSocket) - remote nodes now only require an API URL and Bearer token. Node Manager UI vastly simplified. - **Docs:** Migrated Mintlify config from deprecated `mint.json` to `docs.json` v2 format; bootstrapped full user-facing documentation (configuration, stack management, editor, multi-node, alerts, dashboard, resources, app store, observability, settings reference, troubleshooting, backup & restore). -- **Design system:** Animated UI overhaul — new brand cyan token, spring-based animations on dialogs/tooltips/switches/tabs, dark mode shadow strengthening, Geist font now actually loaded. -- Notification delivery replaced polling with WebSocket push — no more `setInterval` in `EditorLayout`. +- **Design system:** Animated UI overhaul - new brand cyan token, spring-based animations on dialogs/tooltips/switches/tabs, dark mode shadow strengthening, Geist font now actually loaded. +- Notification delivery replaced polling with WebSocket push - no more `setInterval` in `EditorLayout`. - `DatabaseService.addNotificationHistory` returns the full inserted record for real-time broadcasting. -- `SettingsModal` overhauled — per-operation loading states, skeleton loader, unsaved-changes indicator, all saves use `PATCH /api/settings`. +- `SettingsModal` overhauled - per-operation loading states, skeleton loader, unsaved-changes indicator, all saves use `PATCH /api/settings`. - `MonitorService` evaluates limits and detects container crashes across all registered nodes concurrently. - `MonitorService` reads retention settings dynamically each cycle. -- Developer settings scoped to the local node — reads/writes always target local via `localOnly` regardless of active node. -- Dark mode scrollbar styling — no more white native scrollbars. +- Developer settings scoped to the local node - reads/writes always target local via `localOnly` regardless of active node. +- Dark mode scrollbar styling - no more white native scrollbars. - Rebranded "Templates" → "App Store", "Ghost Containers" → "Unmanaged Containers", "Observability" → "Logs". - Global logs display chronologically (newest at bottom) with smooth auto-scrolling; UTC → local browser timezone. - Historical CPU/RAM charts relocated to the Home Dashboard; data normalized (CPU relative to host cores, RAM to GB). -- `EditorLayout` main workspace container keyed to `activeView` — every view switch triggers a fade-up entrance animation. +- `EditorLayout` main workspace container keyed to `activeView` - every view switch triggers a fade-up entrance animation. ### Removed -- SSH/SFTP remote node adapters (`IFileAdapter`, `LocalFileAdapter`, `SSHFileAdapter`, `SSHFileAdapter`, `ComposeService.executeRemote`, `ComposeService.streamLogs` SSH path) — ~500 lines. +- SSH/SFTP remote node adapters (`IFileAdapter`, `LocalFileAdapter`, `SSHFileAdapter`, `SSHFileAdapter`, `ComposeService.executeRemote`, `ComposeService.streamLogs` SSH path) - ~500 lines. [0.1.0]: https://github.com/AnsoCode/Sencho/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2aa2962..42e5aaa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Thank you for your interest in contributing to Sencho! - Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages - Update documentation if your change affects user-facing behavior - Add tests for new functionality -- Keep PRs focused — one feature or fix per PR +- Keep PRs focused - one feature or fix per PR - Update `CHANGELOG.md` under `## [Unreleased]` for user-facing changes ## Reporting Bugs @@ -41,7 +41,7 @@ Use the [bug report template](https://github.com/AnsoCode/Sencho/issues/new?temp ## Code Style -- TypeScript with `strict: true` — no `any` casts or `@ts-ignore` +- TypeScript with `strict: true` - no `any` casts or `@ts-ignore` - ESLint 9 flat config for both backend and frontend - Tailwind CSS + shadcn/ui for frontend styling - Follow existing patterns in the codebase diff --git a/Dockerfile b/Dockerfile index 8e650ab3..1ffd8f8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -# Cross-compilation helper — provides xx-clang, xx-apk, etc. +# Cross-compilation helper - provides xx-clang, xx-apk, etc. # Runs on the BUILD platform; its binaries are copied into build stages below. FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx # Stage 1: Build Frontend -# Runs on the BUILD platform (amd64) — frontend has no native modules so the +# Runs on the BUILD platform (amd64) - frontend has no native modules so the # compiled output (JS/CSS/HTML) is entirely platform-agnostic. FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder @@ -20,7 +20,7 @@ COPY package.json /app/package.json RUN npm run build # Stage 2: Compile TypeScript -# Runs on the BUILD platform (amd64) — tsc output is platform-agnostic JS. +# Runs on the BUILD platform (amd64) - tsc output is platform-agnostic JS. FROM --platform=$BUILDPLATFORM node:20-alpine AS backend-builder WORKDIR /app/backend @@ -35,7 +35,7 @@ RUN npm config set fetch-retry-maxtimeout 120000 && \ COPY backend/ ./ RUN npm run build -# Stage 3: Production dependencies (cross-compiled — NO QEMU execution) +# Stage 3: Production dependencies (cross-compiled - NO QEMU execution) # Runs on the BUILD platform (amd64) but compiles native modules # (bcrypt, better-sqlite3, node-pty) for the TARGET platform using # tonistiigi/xx + clang as the cross-compiler. @@ -61,9 +61,9 @@ WORKDIR /app # Cross (TARGETARCH != BUILDARCH, e.g. amd64 → arm64): # xx-clang targets the foreign architecture without QEMU. The target sysroot # is populated via xx-apk: -# g++ — libstdc++ headers/libs (all three native modules use C++) -# musl-dev — musl libc headers for the target arch -# linux-headers — / required by node-pty +# g++ - libstdc++ headers/libs (all three native modules use C++) +# musl-dev - musl libc headers for the target arch +# linux-headers - / required by node-pty RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ apk add --no-cache python3 make g++; \ else \ @@ -73,7 +73,7 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ COPY backend/package*.json backend/.npmrc ./ -# Native: plain npm ci — g++ compiles native modules for the host arch. +# Native: plain npm ci - g++ compiles native modules for the host arch. # Cross: npm_config_arch tells prebuild-install/node-pre-gyp which pre-built # binary to attempt; CC/CXX/AR route compilation through xx-clang so # the output targets the foreign arch without any QEMU emulation. @@ -88,7 +88,7 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \ fi # Stage 4: Production runtime -# Runs on the TARGET platform — no compilation happens here. +# Runs on the TARGET platform - no compilation happens here. FROM node:20-alpine # Install Docker CLI, Docker Compose CLI, and Bash for Host Console @@ -123,7 +123,7 @@ RUN addgroup -S sencho && adduser -S -G sencho sencho \ # # NOTE: USER directive is intentionally absent here. The entrypoint starts as # root so it can chown the mounted data volume, then exec's as sencho. Static -# security scanners (Trivy, Clair) may flag "running as root" — this is a known +# security scanners (Trivy, Clair) may flag "running as root" - this is a known # and accepted trade-off for self-hosted apps with user-supplied volume mounts. COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # Strip Windows CRLF line endings that can sneak in on Windows dev machines @@ -135,7 +135,7 @@ RUN sed -i 's/\r//' /usr/local/bin/docker-entrypoint.sh \ # Expose port EXPOSE 3000 -# Health check — polls the public /api/health endpoint every 30s +# Health check - polls the public /api/health endpoint every 30s HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD node -e "const h=require('http');h.get('http://localhost:3000/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))" diff --git a/MANUAL_STEPS.md b/MANUAL_STEPS.md index fcef2400..e93c7cce 100644 --- a/MANUAL_STEPS.md +++ b/MANUAL_STEPS.md @@ -7,14 +7,14 @@ These actions could not be performed automatically and need to be done manually Go to **Settings > Branches > Add branch ruleset** for `main`: - [x] Require a pull request before merging - - Required approvals: 0 (solo dev — you merge your own PRs after CI passes) + - Required approvals: 0 (solo dev - you merge your own PRs after CI passes) - Dismiss stale reviews when new commits are pushed: ON - [x] Require status checks to pass before merging - Add these job names: `Backend (Build, Test, Lint)`, `Frontend (Build, Lint)`, `Docker Build & Scan`, `E2E Tests (Playwright)` - [x] Require conversation resolution before merging - [x] Do not allow bypassing the above settings (even you must go through PRs) - [x] Do not allow deletions -- [ ] Require signed commits (optional — future improvement) +- [ ] Require signed commits (optional - future improvement) ## 2. Repository Settings diff --git a/README.md b/README.md index 522600b9..47c04ef6 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ A self-hosted Docker Compose management dashboard. Manage your stacks, container ## Features -- **Stack Management** — Create, edit, start, stop, and remove Docker Compose stacks with a built-in Monaco code editor -- **Multi-Node Support** — Manage remote Sencho instances through a transparent HTTP/WebSocket proxy (Distributed API model) -- **App Store** — One-click deployment from LinuxServer.io templates with editable ports, volumes, and environment variables -- **Resource Hub** — Browse and manage images, volumes, and networks with managed/external/unused classification -- **Live Logs** — Aggregated real-time log streaming across all containers with search and filtering -- **Dashboard** — Container stats, CPU/RAM metrics, health checks, and image update notifications -- **Alerts** — Configurable threshold alerts for CPU, RAM, and disk usage -- **Terminal** — In-browser host console and container exec via WebSocket +- **Stack Management** - Create, edit, start, stop, and remove Docker Compose stacks with a built-in Monaco code editor +- **Multi-Node Support** - Manage remote Sencho instances through a transparent HTTP/WebSocket proxy (Distributed API model) +- **App Store** - One-click deployment from LinuxServer.io templates with editable ports, volumes, and environment variables +- **Resource Hub** - Browse and manage images, volumes, and networks with managed/external/unused classification +- **Live Logs** - Aggregated real-time log streaming across all containers with search and filtering +- **Dashboard** - Container stats, CPU/RAM metrics, health checks, and image update notifications +- **Alerts** - Configurable threshold alerts for CPU, RAM, and disk usage +- **Terminal** - In-browser host console and container exec via WebSocket ## Quick Start diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index 5c5d6e04..f6ad476f 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -15,14 +15,14 @@ export default tseslint.config( varsIgnorePattern: '^_', argsIgnorePattern: '^_', }], - // Pre-existing patterns — warn rather than error until addressed + // Pre-existing patterns - warn rather than error until addressed '@typescript-eslint/ban-ts-comment': 'warn', '@typescript-eslint/no-namespace': 'warn', 'no-empty': 'warn', // Terminal output processing intentionally uses control characters in regexes 'no-control-regex': 'off', 'no-console': 'off', - // New in ESLint 10 recommended — existing patterns, suppress until addressed + // New in ESLint 10 recommended - existing patterns, suppress until addressed 'no-useless-assignment': 'warn', 'preserve-caught-error': 'warn', }, diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 22535376..9c4263c1 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -74,7 +74,7 @@ describe('authMiddleware', () => { const res = await request(app) .get('/api/stacks') .set('Authorization', `Bearer ${token}`); - // Will succeed (200) or fail with a docker/fs error (500) — but NOT 401 + // Will succeed (200) or fail with a docker/fs error (500) - but NOT 401 expect(res.status).not.toBe(401); }); @@ -96,7 +96,7 @@ describe('authMiddleware', () => { // ─── Protected endpoint: console-token ─────────────────────────────────────── describe('POST /api/system/console-token', () => { - it('returns 401 without authentication (was a security bug — C1 fix)', async () => { + it('returns 401 without authentication (was a security bug - C1 fix)', async () => { const res = await request(app).post('/api/system/console-token'); expect(res.status).toBe(401); }); diff --git a/backend/src/__tests__/health.test.ts b/backend/src/__tests__/health.test.ts index 571ca1a1..e19a9a43 100644 --- a/backend/src/__tests__/health.test.ts +++ b/backend/src/__tests__/health.test.ts @@ -33,7 +33,7 @@ describe('GET /api/health', () => { }); it('does not require an auth token', async () => { - // No cookie, no Authorization header — must still return 200 + // No cookie, no Authorization header - must still return 200 const res = await request(app).get('/api/health'); expect(res.status).not.toBe(401); expect(res.status).not.toBe(403); diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index c5d659b6..8eedf0e4 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -1,5 +1,5 @@ /** - * Test DB helper — creates a temporary SQLite database, seeds it with a known + * Test DB helper - creates a temporary SQLite database, seeds it with a known * admin credential, and sets process.env so DatabaseService uses it. * * Call this at the top of every test file *before* importing the app, diff --git a/backend/src/__tests__/nodes.test.ts b/backend/src/__tests__/nodes.test.ts index 9d1cc41f..09d2f1cf 100644 --- a/backend/src/__tests__/nodes.test.ts +++ b/backend/src/__tests__/nodes.test.ts @@ -1,5 +1,5 @@ /** - * Tests for node management API — focusing on api_url validation (SSRF fix C2). + * Tests for node management API - focusing on api_url validation (SSRF fix C2). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; @@ -21,7 +21,7 @@ afterAll(() => { cleanupTestDb(tmpDir); }); -describe('POST /api/nodes — api_url SSRF validation (C2 fix)', () => { +describe('POST /api/nodes - api_url SSRF validation (C2 fix)', () => { it('rejects localhost api_url', async () => { const res = await request(app) .post('/api/nodes') @@ -66,7 +66,7 @@ describe('POST /api/nodes — api_url SSRF validation (C2 fix)', () => { api_url: 'http://192.168.1.50:3000', api_token: 'sometoken', }); - // Should succeed (201 or 200) — not a validation error + // Should succeed (201 or 200) - not a validation error expect(res.status).not.toBe(400); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index c08ec746..afbc3ac7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -67,8 +67,8 @@ const getCookieOptions = (req: Request) => ({ // Middleware // Security headers (X-Frame-Options, X-Content-Type-Options, etc.) -// crossOriginEmbedderPolicy: disabled — Monaco editor workers lack COEP headers. -// hsts: disabled — HSTS must only be set when the app is served over HTTPS. +// crossOriginEmbedderPolicy: disabled - Monaco editor workers lack COEP headers. +// hsts: disabled - HSTS must only be set when the app is served over HTTPS. // Enabling it over HTTP permanently breaks browser access for 1 year. // contentSecurityPolicy.upgradeInsecureRequests: explicitly set to null. // Helmet 8 merges custom directives with its defaults, which include this @@ -114,7 +114,7 @@ app.use(helmet({ }, })); -// CORS — in production restrict to the configured frontend origin. +// CORS - in production restrict to the configured frontend origin. // In development, mirror the request origin so Vite's dev server works. const corsOrigin = process.env.NODE_ENV === 'production' && process.env.FRONTEND_URL ? process.env.FRONTEND_URL @@ -124,6 +124,7 @@ app.use(cors({ origin: corsOrigin, credentials: true, })); + // Conditionally parse JSON bodies. Remote proxy requests must NOT have their body // consumed here: express.json() drains the IncomingMessage stream into req.body // and http-proxy then pipes an already-ended stream to the remote server. @@ -239,7 +240,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): } }; -// Rate limiter for auth endpoints — prevents brute-force attacks. +// Rate limiter for auth endpoints - prevents brute-force attacks. // Production: 5 attempts per 15-minute window per IP. // Development: 100 attempts (so E2E tests and local tooling are not blocked). const authRateLimiter = rateLimit({ @@ -360,7 +361,7 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response) } }); -// Update password endpoint — any authenticated user can change their own password +// Update password endpoint - any authenticated user can change their own password app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise => { try { const { oldPassword, newPassword } = req.body; @@ -788,14 +789,14 @@ async function captureLocalNodeFiles(node: Node): Promise { const composeContent = await fsService.getStackContent(stackName); files.push({ filename: 'compose.yaml', content: composeContent }); } catch { - // Stack has no compose file — skip + // Stack has no compose file - skip continue; } try { const envContent = await fsService.getEnvContent(stackName); files.push({ filename: '.env', content: envContent }); } catch { - // No .env file — that's fine + // No .env file - that's fine } stacks.push({ stackName, files }); } @@ -844,7 +845,7 @@ async function captureRemoteNodeFiles(node: Node): Promise { files.push({ filename: '.env', content }); } } catch { - // No .env — skip + // No .env - skip } if (files.length > 0) { stacks.push({ stackName, files }); @@ -1029,7 +1030,7 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response) try { await fsService.backupStackFiles(stackName); } catch { - // Stack may not exist yet — that's ok + // Stack may not exist yet - that's ok } for (const file of files) { @@ -1206,7 +1207,7 @@ app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: R } }); -// Webhook trigger — public endpoint, authenticated via HMAC signature +// Webhook trigger - public endpoint, authenticated via HMAC signature app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promise => { try { const id = parseInt(req.params.id as string, 10); @@ -1218,7 +1219,7 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi return; } - // Pro gate — trigger only works with an active Pro license + // Pro gate - trigger only works with an active Pro license if (LicenseService.getInstance().getTier() !== 'pro') { res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); return; @@ -1242,7 +1243,7 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi const action = req.body?.action || webhook.action; const triggerSource = req.headers['user-agent'] || req.ip || null; - // Execute asynchronously — return 202 immediately + // Execute asynchronously - return 202 immediately res.status(202).json({ message: 'Webhook accepted', action }); const atomic = LicenseService.getInstance().getTier() === 'pro'; @@ -1298,6 +1299,18 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom return; } + // Enforce seat limits based on license variant + const seatLimits = LicenseService.getInstance().getSeatLimits(); + if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) { + res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` }); + return; + } + if (role === 'viewer' && seatLimits.maxViewers !== null && db.getViewerCount() >= seatLimits.maxViewers) { + res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` }); + return; + } + + const passwordHash = await bcrypt.hash(password, 10); const id = db.addUser({ username, password_hash: passwordHash, role }); res.status(201).json({ id, username, role }); @@ -1451,7 +1464,7 @@ const remoteNodeProxy = createProxyMiddleware({ // Mark every response forwarded from a remote node with a sentinel header. // The frontend (apiFetch / fetchForNode) checks this before firing the // global 'sencho-unauthorized' event: a 401 from a remote means the stored - // api_token for that node is invalid — not that the user's own session + // api_token for that node is invalid - not that the user's own session // expired. Without this distinction, any node with a bad token causes an // immediate logout loop. proxyRes.headers['x-sencho-proxy'] = '1'; @@ -1854,7 +1867,7 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis await fsService.access(f); existing.push(f); } catch { - // File does not exist — skip + // File does not exist - skip } } return existing; @@ -2208,7 +2221,7 @@ app.get('/api/stats', async (req: Request, res: Response) => { // A container is "managed" if Docker started it from within COMPOSE_DIR. // We use com.docker.compose.project.working_dir rather than project name because // stacks launched from the COMPOSE_DIR root (not a subdirectory) all share the - // project name of the root folder — causing false "external" classification. + // project name of the root folder - causing false "external" classification. const isManagedByComposeDir = (c: any): boolean => { const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; if (!workingDir) return false; @@ -2516,16 +2529,16 @@ const ALLOWED_SETTING_KEYS = new Set([ // Zod schema for bulk PATCH - all keys optional, present keys fully validated import { z } from 'zod'; const SettingsPatchSchema = z.object({ - host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String), - host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String), - host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String), - docker_janitor_gb: z.coerce.number().min(0).transform(String), - global_crash: z.enum(['0', '1']), - global_logs_refresh: z.enum(['1', '3', '5', '10']), - developer_mode: z.enum(['0', '1']), - template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }), + host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String), + docker_janitor_gb: z.coerce.number().min(0).transform(String), + global_crash: z.enum(['0', '1']), + global_logs_refresh: z.enum(['1', '3', '5', '10']), + developer_mode: z.enum(['0', '1']), + template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }), metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String), - log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), + log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), }).partial(); app.get('/api/settings', async (req: Request, res: Response) => { @@ -2953,28 +2966,28 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { // ========================= app.get('/api/image-updates', authMiddleware, (_req: Request, res: Response) => { - try { - const updates = DatabaseService.getInstance().getStackUpdateStatus(); - res.json(updates); - } catch (error) { - console.error('Failed to fetch image update status:', error); - res.status(500).json({ error: 'Failed to fetch image update status' }); - } + try { + const updates = DatabaseService.getInstance().getStackUpdateStatus(); + res.json(updates); + } catch (error) { + console.error('Failed to fetch image update status:', error); + res.status(500).json({ error: 'Failed to fetch image update status' }); + } }); app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Response) => { - if (!requireAdmin(_req, res)) return; - try { - const triggered = ImageUpdateService.getInstance().triggerManualRefresh(); - if (!triggered) { - res.status(429).json({ error: 'Rate limited. Please wait at least 10 minutes between manual refreshes.' }); - return; - } - res.json({ success: true, message: 'Image update check started in background.' }); - } catch (error) { - console.error('Failed to trigger image update refresh:', error); - res.status(500).json({ error: 'Failed to trigger refresh' }); + if (!requireAdmin(_req, res)) return; + try { + const triggered = ImageUpdateService.getInstance().triggerManualRefresh(); + if (!triggered) { + res.status(429).json({ error: 'Rate limited. Please wait at least 10 minutes between manual refreshes.' }); + return; } + res.json({ success: true, message: 'Image update check started in background.' }); + } catch (error) { + console.error('Failed to trigger image update refresh:', error); + res.status(500).json({ error: 'Failed to trigger refresh' }); + } }); // ========================= @@ -3156,11 +3169,11 @@ if (require.main === module) { // Exports used by tests (supertest requires the http.Server instance). export { app, server }; -// Graceful shutdown — allows in-flight requests to finish, then cleanly stops +// Graceful shutdown - allows in-flight requests to finish, then cleanly stops // background services and closes the SQLite connection before the process exits. // Docker sends SIGTERM when the container stops; Ctrl-C sends SIGINT in dev. const gracefulShutdown = (signal: string) => { - console.log(`[Shutdown] ${signal} received — shutting down gracefully…`); + console.log(`[Shutdown] ${signal} received - shutting down gracefully…`); server.close(() => { console.log('[Shutdown] HTTP server closed'); @@ -3168,13 +3181,13 @@ const gracefulShutdown = (signal: string) => { try { MonitorService.getInstance().stop(); } catch { /* already stopped */ } try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ } try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ } - console.log('[Shutdown] Done — exiting'); + console.log('[Shutdown] Done - exiting'); process.exit(0); }); // Force-exit after 10 s if connections refuse to drain setTimeout(() => { - console.error('[Shutdown] Timed out waiting for connections — forcing exit'); + console.error('[Shutdown] Timed out waiting for connections - forcing exit'); process.exit(1); }, 10_000).unref(); }; diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 877fa4ed..ea3223d4 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -134,7 +134,7 @@ export class ComposeService { } catch (deployError) { // Atomic: auto-rollback on failure if (atomic) { - sendOutput('\n=== Deployment failed — rolling back to previous version ===\n'); + sendOutput('\n=== Deployment failed - rolling back to previous version ===\n'); try { const fsSvc = FileSystemService.getInstance(this.nodeId); await fsSvc.restoreStackFiles(stackName); @@ -142,7 +142,7 @@ export class ComposeService { sendOutput('=== Rolled back successfully ===\n'); } catch (rollbackError) { console.error(`Rollback failed for ${stackName}:`, rollbackError); - sendOutput('=== Rollback failed — manual intervention may be required ===\n'); + sendOutput('=== Rollback failed - manual intervention may be required ===\n'); } } throw deployError; @@ -322,7 +322,7 @@ export class ComposeService { } catch (updateError) { // Atomic: auto-rollback on failure if (atomic) { - sendOutput('\n=== Update failed — rolling back to previous version ===\n'); + sendOutput('\n=== Update failed - rolling back to previous version ===\n'); try { const fsSvc = FileSystemService.getInstance(this.nodeId); await fsSvc.restoreStackFiles(stackName); @@ -330,7 +330,7 @@ export class ComposeService { sendOutput('=== Rolled back successfully ===\n'); } catch (rollbackError) { console.error(`Rollback failed for ${stackName}:`, rollbackError); - sendOutput('=== Rollback failed — manual intervention may be required ===\n'); + sendOutput('=== Rollback failed - manual intervention may be required ===\n'); } } throw updateError; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 8d899216..208d8183 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -722,6 +722,11 @@ export class DatabaseService { return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number })?.count || 0; } + public getViewerCount(): number { + return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'viewer'").get() as { count: number })?.count || 0; + } + + // --- Fleet Snapshots --- public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string): number { diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index adac4f0f..6e4b60c1 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -5,17 +5,32 @@ import { DatabaseService } from './DatabaseService'; export type LicenseTier = 'community' | 'pro'; export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled'; +export type LicenseVariant = 'personal' | 'team' | null; + export interface LicenseInfo { tier: LicenseTier; status: LicenseStatus; + variant: LicenseVariant; customerName: string | null; productName: string | null; maskedKey: string | null; validUntil: string | null; trialDaysRemaining: number | null; instanceId: string; + portalUrl: string | null; } +/** Seat limits per variant. null = unlimited. */ +export interface SeatLimits { + maxAdmins: number | null; + maxViewers: number | null; +} + +const SEAT_LIMITS: Record = { + personal: { maxAdmins: 1, maxViewers: 3 }, + team: { maxAdmins: null, maxViewers: null }, +}; + interface LemonSqueezyActivationResponse { activated: boolean; error?: string; @@ -82,7 +97,7 @@ export class LicenseService { private static instance: LicenseService; private validationTimer: ReturnType | null = null; - private constructor() {} + private constructor() { } public static getInstance(): LicenseService { if (!LicenseService.instance) { @@ -118,7 +133,7 @@ export class LicenseService { } /** - * Returns the current license tier. Synchronous — reads from cached DB state only. + * Returns the current license tier. Synchronous - reads from cached DB state only. */ public getTier(): LicenseTier { const db = DatabaseService.getInstance(); @@ -132,7 +147,7 @@ export class LicenseService { if (validUntil && new Date(validUntil) > new Date()) { return 'pro'; } - // Trial expired — update status + // Trial expired - update status db.setSystemState('license_status', 'community'); return 'community'; } @@ -162,6 +177,31 @@ export class LicenseService { return 'community'; } + /** + * Get the license variant (personal or team) from stored metadata. + * Trial licenses default to "team" so users can explore all features. + */ + public getVariant(): LicenseVariant { + const db = DatabaseService.getInstance(); + const status = db.getSystemState('license_status'); + if (status === 'trial') return 'team'; + const variantName = db.getSystemState('license_variant_name'); + if (!variantName) return null; + const lower = variantName.toLowerCase(); + if (lower.includes('team')) return 'team'; + if (lower.includes('personal')) return 'personal'; + return 'personal'; // default activated licenses to personal + } + + /** + * Get seat limits for the current license variant. + */ + public getSeatLimits(): SeatLimits { + const variant = this.getVariant(); + if (!variant) return { maxAdmins: 1, maxViewers: 0 }; // community + return SEAT_LIMITS[variant] || SEAT_LIMITS.personal; + } + /** * Get full license information for the API response. */ @@ -181,12 +221,14 @@ export class LicenseService { return { tier: this.getTier(), status, + variant: this.getVariant(), customerName: db.getSystemState('license_customer_name'), productName: db.getSystemState('license_product_name'), maskedKey: key ? `****-****-****-${key.slice(-4)}` : null, validUntil, trialDaysRemaining, instanceId, + portalUrl: db.getSystemState('customer_portal_url') || null, }; } @@ -221,7 +263,7 @@ export class LicenseService { if (data.license_key?.expires_at) { db.setSystemState('license_valid_until', data.license_key.expires_at); } else { - // Lifetime license — no expiry + // Lifetime license - no expiry db.setSystemState('license_valid_until', ''); } @@ -231,6 +273,9 @@ export class LicenseService { if (data.meta?.product_name) { db.setSystemState('license_product_name', data.meta.product_name); } + if (data.meta?.variant_name) { + db.setSystemState('license_variant_name', data.meta.variant_name); + } console.log('[License] Activated successfully.'); return { success: true }; @@ -278,6 +323,13 @@ export class LicenseService { 'license_last_validated', 'license_customer_name', 'license_product_name', + 'license_variant_name', + 'subscription_id', + 'customer_id', + 'customer_portal_url', + 'update_payment_url', + 'order_id', + 'receipt_url', ]; for (const key of keysToRemove) { db.setSystemState(key, ''); @@ -345,11 +397,14 @@ export class LicenseService { if (data.meta?.product_name) { db.setSystemState('license_product_name', data.meta.product_name); } + if (data.meta?.variant_name) { + db.setSystemState('license_variant_name', data.meta.variant_name); + } console.log('[License] Validation successful.'); return { success: true }; } catch (err) { - // Network failure — don't change status, just log + // Network failure - don't change status, just log console.warn('[License] Validation network error (keeping current status):', (err as Error).message); return { success: false, error: 'Unable to reach license server' }; } diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index c8974ff4..d2d0bd27 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -10,7 +10,7 @@ export const isValidStackName = (name: string): boolean => /** * Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL. * Rejects loopback addresses to prevent SSRF against local services. - * Private/LAN IPs are allowed — users legitimately point Sencho at nodes on their LAN. + * Private/LAN IPs are allowed - users legitimately point Sencho at nodes on their LAN. */ export function isValidRemoteUrl( raw: string, @@ -32,7 +32,7 @@ export function isValidRemoteUrl( if (loopback.test(url.hostname)) { return { valid: false, - reason: 'API URL cannot point to localhost or loopback — use the actual host address', + reason: 'API URL cannot point to localhost or loopback - use the actual host address', }; } return { valid: true, url }; diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index a782440b..a65f7bc9 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - // Only run TypeScript sources — exclude the compiled dist/ output. + // Only run TypeScript sources - exclude the compiled dist/ output. include: ['src/__tests__/**/*.test.ts'], exclude: ['dist/**', 'node_modules/**'], // Each test file gets its own worker so singletons are fresh between files. diff --git a/docs/docs.json b/docs/docs.json index c209dbc4..7671f34a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,7 +40,8 @@ "features/webhooks", "features/rbac", "features/atomic-deployments", - "features/fleet-backups" + "features/fleet-backups", + "features/licensing" ] }, { diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index ebb90546..cd6dd22f 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -3,7 +3,7 @@ title: App Store description: Browse and deploy pre-configured application templates in one click. --- -The **App Store** tab lets you browse a curated catalogue of Docker Compose templates and deploy any of them as a new stack with environment-specific configuration — no YAML required. +The **App Store** tab lets you browse a curated catalogue of Docker Compose templates and deploy any of them as a new stack with environment-specific configuration - no YAML required. App Store showing a grid of application templates with category filters @@ -30,7 +30,7 @@ Click any template card to open the **deployment sheet** on the right side of th ### Stack name -Pre-filled with the template name in lowercase. You can change it — the same [naming rules](/features/stack-management#creating-a-stack) apply (lowercase, hyphens, no spaces). +Pre-filled with the template name in lowercase. You can change it - the same [naming rules](/features/stack-management#creating-a-stack) apply (lowercase, hyphens, no spaces). ### Environment variables @@ -48,7 +48,7 @@ For each container mount point, enter the **host path** where that data should b ### Ports -For each exposed port, edit the **host port** (left side). The container port (right side) is fixed by the template and cannot be changed here — edit the compose file after deployment if needed. +For each exposed port, edit the **host port** (left side). The container port (right side) is fixed by the template and cannot be changed here - edit the compose file after deployment if needed. ### What happens on Deploy diff --git a/docs/features/atomic-deployments.mdx b/docs/features/atomic-deployments.mdx index b0386f25..41995286 100644 --- a/docs/features/atomic-deployments.mdx +++ b/docs/features/atomic-deployments.mdx @@ -11,12 +11,12 @@ Sencho Pro wraps every deployment in a safety net. Before applying changes, it b ## How it works -1. **Backup** — Before a deploy or update, Sencho copies `compose.yaml` and `.env` files to a `.sencho-backup/` directory inside the stack folder -2. **Deploy** — Sencho runs the requested compose operation (up, pull + recreate, etc.) -3. **Health probe** — After deployment, Sencho checks whether any containers exited with a non-zero exit code -4. **Auto-rollback** — If a crash is detected, Sencho restores the backed-up files and re-deploys automatically +1. **Backup** - Before a deploy or update, Sencho copies `compose.yaml` and `.env` files to a `.sencho-backup/` directory inside the stack folder +2. **Deploy** - Sencho runs the requested compose operation (up, pull + recreate, etc.) +3. **Health probe** - After deployment, Sencho checks whether any containers exited with a non-zero exit code +4. **Auto-rollback** - If a crash is detected, Sencho restores the backed-up files and re-deploys automatically -This entire sequence happens transparently. You see a single deploy action — Sencho handles the safety logic behind the scenes. +This entire sequence happens transparently. You see a single deploy action - Sencho handles the safety logic behind the scenes. ## Manual rollback @@ -30,4 +30,4 @@ Deployments triggered via webhooks also use atomic rollback. Whether you deploy ## Community Edition behavior -Community users continue to use the standard deploy flow — no backup is created and no rollback is available. Upgrading to Pro enables atomic deployments immediately with no configuration required. +Community users continue to use the standard deploy flow - no backup is created and no rollback is available. Upgrading to Pro enables atomic deployments immediately with no configuration required. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 2869e351..d26f64ce 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -15,7 +15,7 @@ The top row summarises container state at a glance: | Card | What it shows | |------|---------------| -| **Active Containers** | Running containers — broken down as `N managed · N external` | +| **Active Containers** | Running containers - broken down as `N managed · N external` | | **Exited Containers** | Stopped or crashed containers | | **Docker Network** | Current inbound/outbound network throughput across all containers | @@ -37,8 +37,8 @@ When any value exceeds configured thresholds (set in **Settings → System Limit Two area charts display time-series data sampled at one-minute intervals, retained for up to 24 hours (configurable in **Settings → Developer**): -- **Normalized CPU Usage** — total CPU percentage across all managed containers, normalised over all host cores -- **Normalized RAM Usage** — total memory allocated by managed containers, in GB +- **Normalized CPU Usage** - total CPU percentage across all managed containers, normalised over all host cores +- **Normalized RAM Usage** - total memory allocated by managed containers, in GB The x-axis shows time labels. Hover over a data point to see the exact value at that moment. diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 4ebfbd4e..a7948994 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -3,7 +3,7 @@ title: Editor description: Edit compose files and environment variables, and manage containers directly from the dashboard. --- -Selecting a stack opens the editor view — a split-pane layout with container management on the left and a full Monaco code editor on the right. +Selecting a stack opens the editor view - a split-pane layout with container management on the left and a full Monaco code editor on the right. Editor view showing container panel and Monaco editor @@ -37,9 +37,9 @@ The `.env` editor has the same save/discard controls as the compose editor. Chan The left panel lists all containers that belong to the selected stack. Each container shows: -- **Status badge** — `running`, `exited`, `starting`, or `unhealthy` -- **Live stats** — CPU %, RAM usage, and network I/O updated every 1–2 seconds -- **Port mappings** — host:container port pairs (if any) +- **Status badge** - `running`, `exited`, `starting`, or `unhealthy` +- **Live stats** - CPU %, RAM usage, and network I/O updated every 1–2 seconds +- **Port mappings** - host:container port pairs (if any) ### Container actions @@ -57,7 +57,7 @@ The log viewer streams output from a single container in real-time using Server- ### Container terminal (exec) -The terminal modal gives you an interactive bash shell inside the running container — equivalent to `docker exec -it bash`. It uses a full xterm.js emulator with color support and tab completion. +The terminal modal gives you an interactive bash shell inside the running container - equivalent to `docker exec -it bash`. It uses a full xterm.js emulator with color support and tab completion. The container terminal requires the container to have `bash` (or `sh`) installed. Minimal images (e.g. Alpine-based) may need `sh` instead. diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index 93fe8494..cbbad9ca 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -7,28 +7,28 @@ description: Snapshot compose files across all nodes for disaster recovery and a Fleet-Wide Backups require a Sencho Pro license. The feature is available to Pro admins in the Fleet View. -Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet — local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time. +Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet - local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time. ## Creating a snapshot 1. Navigate to **Fleet View** and select the **Snapshots** tab 2. Click **Create Snapshot** 3. Optionally enter a description (e.g. "Before v2 migration") -4. Click **Create** — Sencho captures files from every reachable node +4. Click **Create** - Sencho captures files from every reachable node During creation, Sencho connects to each node in parallel: -- **Local nodes** — reads files directly from the compose directory -- **Remote nodes** — fetches files via the Distributed API proxy using the node's API token +- **Local nodes** - reads files directly from the compose directory +- **Remote nodes** - fetches files via the Distributed API proxy using the node's API token If a remote node is offline or unreachable, it is **skipped gracefully**. The snapshot is still created with data from all reachable nodes, and skipped nodes are recorded with the reason for the failure. ## Browsing snapshots The snapshot list shows: -- **Date** — when the snapshot was taken -- **Description** — your optional label -- **Scope** — how many nodes and stacks were captured -- **Warnings** — an indicator if any nodes were skipped +- **Date** - when the snapshot was taken +- **Description** - your optional label +- **Scope** - how many nodes and stacks were captured +- **Warnings** - an indicator if any nodes were skipped Click **View** to open the detail view, which presents a collapsible tree: @@ -58,8 +58,8 @@ Admins can restore individual stacks from any snapshot: 5. Confirm the action Sencho writes the snapshot's files back to the target node: -- **Local nodes** — files are written directly, and the current files are backed up first (creating a rollback point via the atomic deployment system) -- **Remote nodes** — files are pushed via the Distributed API proxy +- **Local nodes** - files are written directly, and the current files are backed up first (creating a rollback point via the atomic deployment system) +- **Remote nodes** - files are pushed via the Distributed API proxy Restoring overwrites the current compose and environment files on the target node. If atomic deployments are enabled, the current files are backed up before restoration. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index a716aa62..f1af2130 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -3,7 +3,7 @@ title: Fleet View description: Monitor all your nodes from a single dashboard with real-time health metrics, search, filtering, and container drill-down. --- -The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment — local and remote — on one screen. It is available to all tiers, with advanced features unlocked by Sencho Pro. +The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment - local and remote - on one screen. It is available to all tiers, with advanced features unlocked by Sencho Pro. Fleet View showing health summary cards, toolbar, and node grid @@ -111,7 +111,7 @@ Nodes with CPU or disk usage above 90% automatically receive a red **Critical** ## How fleet data is fetched -Fleet View queries all registered nodes in parallel. Each node responds independently — one slow or offline node does not block the others. Local node data comes from the Docker socket and system stats directly. Remote node data is fetched over the Distributed API proxy using each node's Bearer token. +Fleet View queries all registered nodes in parallel. Each node responds independently - one slow or offline node does not block the others. Local node data comes from the Docker socket and system stats directly. Remote node data is fetched over the Distributed API proxy using each node's Bearer token. Fleet View always runs on your primary (local) Sencho instance. It is never proxied through a remote node. diff --git a/docs/features/global-observability.mdx b/docs/features/global-observability.mdx index 4e396666..2ad6c06f 100644 --- a/docs/features/global-observability.mdx +++ b/docs/features/global-observability.mdx @@ -3,7 +3,7 @@ title: Global Observability description: A unified, searchable log stream from every container across all your stacks. --- -The **Logs** tab aggregates output from all running containers into a single scrollable view. Instead of tailing logs one container at a time, you see everything in one place — with filtering to focus on what matters. +The **Logs** tab aggregates output from all running containers into a single scrollable view. Instead of tailing logs one container at a time, you see everything in one place - with filtering to focus on what matters. Global Observability view showing real-time log lines from multiple containers @@ -13,11 +13,11 @@ The **Logs** tab aggregates output from all running containers into a single scr Each line shows: -- **Timestamp** — when the log line was emitted -- **Stack name** — the Compose stack the container belongs to (colour-coded) -- **Container name** — the specific container -- **Level** — `INFO`, `WARN`, or `ERROR` (where detectable) -- **Message** — the raw log output +- **Timestamp** - when the log line was emitted +- **Stack name** - the Compose stack the container belongs to (colour-coded) +- **Container name** - the specific container +- **Level** - `INFO`, `WARN`, or `ERROR` (where detectable) +- **Message** - the raw log output ## Streaming modes @@ -36,11 +36,11 @@ Use the controls above the log panel to narrow what you see: | Control | What it does | |---------|-------------| -| **Stack filter** | Multi-select dropdown — show only logs from chosen stacks | -| **Stream** | `ALL` / `STDOUT` / `STDERR` — filter by output stream | +| **Stack filter** | Multi-select dropdown - show only logs from chosen stacks | +| **Stream** | `ALL` / `STDOUT` / `STDERR` - filter by output stream | | **Search** | Full-text filter on the message field (case-insensitive) | -Filters combine — you can show only `STDERR` from a specific stack while searching for a keyword. +Filters combine - you can show only `STDERR` from a specific stack while searching for a keyword. ## Controls diff --git a/docs/features/host-console.mdx b/docs/features/host-console.mdx index 6108b2e3..a6e811a2 100644 --- a/docs/features/host-console.mdx +++ b/docs/features/host-console.mdx @@ -1,6 +1,6 @@ --- title: Host Console -description: An interactive terminal on your host OS, directly in the browser — no SSH required. +description: An interactive terminal on your host OS, directly in the browser - no SSH required. --- The **Console** tab opens a full interactive terminal session on the machine running Sencho. It behaves exactly like an SSH session, but without needing an SSH server, client, or key management. @@ -32,7 +32,7 @@ Click **Close Console** to end the session and terminate the shell process on th The shell depends on the host OS: - **Linux/macOS hosts:** `bash` or `sh` -- **Windows hosts (Docker Desktop):** PowerShell (as shown in the screenshot — the console opens inside the Sencho container's Windows environment) +- **Windows hosts (Docker Desktop):** PowerShell (as shown in the screenshot - the console opens inside the Sencho container's Windows environment) ## Security model diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx new file mode 100644 index 00000000..3daabf5e --- /dev/null +++ b/docs/features/licensing.mdx @@ -0,0 +1,54 @@ +--- +title: Licensing & Billing +description: How Sencho Pro licensing works - trials, activation, and subscription management. +--- + +Sencho uses an open-core model. The **Community** tier is free forever with unlimited nodes. **Pro** unlocks advanced features like fleet management, RBAC, webhooks, atomic deployments, and fleet-wide backups. + +## Plans + +| Tier | Price | Accounts | +|------|-------|----------| +| **Community** | Free | 1 admin | +| **Personal Pro** | $7.99/month, $69.99/year, or $249 lifetime | 1 admin + 3 viewers | +| **Team Pro** | $49.99/month, $499.99/year, or $1,499 lifetime | Unlimited | + +Lifetime pricing is an early-adopter offer available for a limited time only. + +## Free trial + +Every new Sencho installation starts with a **14-day Pro trial** - no license key or credit card required. All Pro features are unlocked during the trial so you can evaluate them with your real infrastructure. + +When the trial expires, Sencho automatically reverts to the Community tier. No data is lost. + +## Purchasing a license + +1. Visit [sencho.io](https://sencho.io/#pricing) and choose a plan. +2. Complete the checkout on Lemon Squeezy. +3. You'll receive a license key by email. + +## Activating your license + +1. Go to **Settings > License** in your Sencho dashboard. +2. Paste your key into the **License Key** field. +3. Click **Activate**. + +Sencho validates the key against Lemon Squeezy and activates Pro features immediately. + +## License validation + +Active licenses are re-validated every **72 hours** against the Lemon Squeezy API. If your instance goes offline, there is a **30-day grace period** before it degrades to the Community tier. + +## Managing your subscription + +To manage your subscription (update payment method, view invoices, cancel, or switch plans), visit the [Lemon Squeezy customer portal](https://app.lemonsqueezy.com/my-orders) or use the link in your purchase confirmation email. + +## Deactivating a license + +To transfer your license to a different instance: + +1. Go to **Settings > License**. +2. Click **Deactivate License**. +3. On your new instance, activate the same license key. + +Deactivation reverts the current instance to the Community tier immediately. diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 17d44373..febc6ef1 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -3,7 +3,7 @@ title: Multi-Node Management description: Connect multiple Sencho instances and manage all your servers from a single dashboard. --- -Sencho's multi-node feature lets you manage Docker Compose stacks on multiple servers — all from the same browser tab. Each server runs its own Sencho instance, and your primary instance acts as a transparent proxy to the others. +Sencho's multi-node feature lets you manage Docker Compose stacks on multiple servers - all from the same browser tab. Each server runs its own Sencho instance, and your primary instance acts as a transparent proxy to the others. Node Manager showing a local and a remote node, both Online @@ -21,7 +21,7 @@ Your primary Sencho installation is always listed as **Local**. It is the defaul ### Step 1: Generate a token on the remote machine -On the **remote** Sencho instance (the server you want to add), open **Settings → Nodes** and click **Generate Token**. Copy the generated token — you'll only see it once. +On the **remote** Sencho instance (the server you want to add), open **Settings → Nodes** and click **Generate Token**. Copy the generated token - you'll only see it once. The token is a long-lived JWT scoped to `node_proxy`. Anyone with this token can fully control that Sencho instance, so treat it like a password. @@ -55,7 +55,7 @@ The Compose directory path matters on remote nodes too. When you register a remo ## Switching between nodes -The **node switcher** dropdown in the top-left of the sidebar shows the currently active node. Click it to switch to any registered node. All views — dashboard stats, stack list, editor, resources, logs — immediately reflect the selected node. +The **node switcher** dropdown in the top-left of the sidebar shows the currently active node. Click it to switch to any registered node. All views - dashboard stats, stack list, editor, resources, logs - immediately reflect the selected node. Node switcher dropdown and node list @@ -77,4 +77,4 @@ Click the **pencil icon** on any remote node row to edit its name, URL, or token - Node tokens grant full control over the remote Sencho instance. Rotate them if compromised via Settings → Nodes → Generate Token (the old token is invalidated). - Use HTTPS between instances in production to prevent token interception. -- The host console and container exec terminals are blocked for node-proxy tokens — interactive shell access always requires a real browser session on that instance. +- The host console and container exec terminals are blocked for node-proxy tokens - interactive shell access always requires a real browser session on that instance. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 30fdf82b..b3db2292 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -13,7 +13,7 @@ Full in-browser code editor for your Compose and environment files. Toggle edit ## Multi-node support -Add remote Sencho instances as nodes. All dashboard operations — stack management, logs, stats — work identically whether you're targeting your local machine or a server on the other side of the world. Uses a transparent HTTP proxy model; no SSH or shared Docker sockets required. [Learn more →](/features/multi-node) +Add remote Sencho instances as nodes. All dashboard operations - stack management, logs, stats - work identically whether you're targeting your local machine or a server on the other side of the world. Uses a transparent HTTP proxy model; no SSH or shared Docker sockets required. [Learn more →](/features/multi-node) ## Fleet View @@ -21,7 +21,7 @@ Monitor your entire infrastructure from a single screen. The fleet dashboard sho ## RBAC & user management -Create viewer accounts with read-only access to dashboards, logs, and file contents — while keeping deploy and edit permissions locked to admins. Sencho Pro supports two roles: Admin (full access) and Viewer (read-only). [Learn more →](/features/rbac) +Create viewer accounts with read-only access to dashboards, logs, and file contents - while keeping deploy and edit permissions locked to admins. Sencho Pro supports two roles: Admin (full access) and Viewer (read-only). [Learn more →](/features/rbac) ## Atomic deployments @@ -29,7 +29,7 @@ Pro users get automatic backup and rollback on every deployment. Before applying ## Fleet-wide backups -Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy — even to remote nodes. Offline nodes are gracefully skipped with visible warnings. [Learn more →](/features/fleet-backups) +Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy - even to remote nodes. Offline nodes are gracefully skipped with visible warnings. [Learn more →](/features/fleet-backups) ## Real-time logs & stats @@ -57,11 +57,11 @@ Browse 190+ pre-configured application templates. Filter by category (Media, Aut ## Host console -Open an interactive terminal on the host OS directly in the browser — full xterm.js emulation with color support. No SSH client required. +Open an interactive terminal on the host OS directly in the browser - full xterm.js emulation with color support. No SSH client required. ## Webhooks -Trigger stack actions from external CI/CD pipelines via HTTP webhooks. Create a webhook targeting a specific stack and action, then call it from GitHub Actions, GitLab CI, or any system that can send an HTTP POST. Requests are authenticated with HMAC-SHA256 signatures — no session cookies required. [Learn more →](/features/webhooks) +Trigger stack actions from external CI/CD pipelines via HTTP webhooks. Create a webhook targeting a specific stack and action, then call it from GitHub Actions, GitLab CI, or any system that can send an HTTP POST. Requests are authenticated with HMAC-SHA256 signatures - no session cookies required. [Learn more →](/features/webhooks) ## Alerts & notifications diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index fd615cc9..e605e09e 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -1,6 +1,6 @@ --- title: RBAC & User Management -description: Role-based access control for Sencho Pro — create admin and viewer accounts to control who can modify your stacks. +description: Role-based access control for Sencho Pro - create admin and viewer accounts to control who can modify your stacks. --- @@ -13,7 +13,7 @@ Sencho Pro introduces role-based access control with two distinct roles: **Admin | Role | Description | |------|-------------| -| **Admin** | Full access to all features — deploy, edit, manage users, configure nodes, and more | +| **Admin** | Full access to all features - deploy, edit, manage users, configure nodes, and more | | **Viewer** | Read-only access to dashboards, logs, stats, and file contents | ### What viewers can see @@ -23,7 +23,7 @@ Sencho Pro introduces role-based access control with two distinct roles: **Admin - Compose and `.env` file contents (read-only) - Per-container stats and logs - Fleet view -- Resources hub (images, volumes, networks — read-only) +- Resources hub (images, volumes, networks - read-only) - Global logs - Notifications @@ -47,12 +47,12 @@ Admins can manage accounts in **Settings → Users**. From there you can: ## Migration from single-admin setup -When you upgrade to Sencho Pro, your existing single-admin credentials are automatically migrated to the new users table. No manual action is required — your login continues to work as before, and your account is assigned the Admin role. +When you upgrade to Sencho Pro, your existing single-admin credentials are automatically migrated to the new users table. No manual action is required - your login continues to work as before, and your account is assigned the Admin role. ## License tiers | Tier | Admin accounts | Viewer accounts | |------|---------------|-----------------| | **Community** | 1 | 0 | -| **Personal Pro** | 1 | 1 | +| **Personal Pro** | 1 | 3 | | **Team Pro** | Unlimited | Unlimited | diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 96c305c0..9ab628fa 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -23,7 +23,7 @@ Click any segment to automatically filter the tabs below to that category. ## Quick Clean panel -Four prune buttons let you reclaim disk space immediately. By default they operate on **Sencho-managed resources only** — they will not touch external Docker projects. +Four prune buttons let you reclaim disk space immediately. By default they operate on **Sencho-managed resources only** - they will not touch external Docker projects. | Button | What it removes | |--------|----------------| @@ -32,7 +32,7 @@ Four prune buttons let you reclaim disk space immediately. By default they opera | **Prune Dead Networks** | Networks not connected to any Sencho container | | **Purge Unmanaged Containers** | Containers Sencho doesn't recognise (started outside it) | -Each button has a **⋮ More options** menu that lets you target **all Docker resources** instead of Sencho-only. Use this carefully — it can affect other Compose projects running on the same host. +Each button has a **⋮ More options** menu that lets you target **all Docker resources** instead of Sencho-only. Use this carefully - it can affect other Compose projects running on the same host. A confirmation dialog appears before any destructive operation, showing a summary of what will be removed. @@ -45,8 +45,8 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta **Filter buttons:** `All` · `Managed` · `External` **Status badges:** -- `In Use` + stack name — image is actively used by a running container -- `Unused` — image has no running containers; safe to delete +- `In Use` + stack name - image is actively used by a running container +- `Unused` - image has no running containers; safe to delete Click the trash icon on any row to delete an individual image. Sencho will warn you if the image is in use. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index c91c5661..834c2ab6 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -16,7 +16,7 @@ Click **Create Stack** in the left sidebar. Enter a name and click **Create**. **Naming rules:** - Lowercase letters, numbers, and hyphens only (e.g. `my-app`, `nextcloud`) - No spaces or special characters -- Must be unique — duplicates are rejected +- Must be unique - duplicates are rejected Sencho creates a new directory inside `COMPOSE_DIR` with a blank `compose.yaml` file. You'll land in the editor automatically. @@ -34,7 +34,7 @@ Use the **search box** above the list to filter stacks by name. ## Deploying a stack -Select a stack and click **Deploy** in the stack header. This runs `docker compose up -d` — pulling images if needed and creating or recreating containers. +Select a stack and click **Deploy** in the stack header. This runs `docker compose up -d` - pulling images if needed and creating or recreating containers. Stack editor with control buttons @@ -52,7 +52,7 @@ The stack header exposes four actions: | **Delete** | `down` + removes files | Stops and removes containers, then deletes the stack directory. | - **Delete** is irreversible. It removes the stack directory — including `compose.yaml`, `.env`, and any bind-mounted files stored there. Back up important files before deleting. + **Delete** is irreversible. It removes the stack directory - including `compose.yaml`, `.env`, and any bind-mounted files stored there. Back up important files before deleting. ## Stack context menu @@ -63,8 +63,8 @@ Right-click or use the **⋮** button on any stack in the sidebar to access: Stack context menu showing Alerts option -- **Alerts** — configure metric-based alerting rules for this stack -- **Check for updates** — manually trigger an image update check +- **Alerts** - configure metric-based alerting rules for this stack +- **Check for updates** - manually trigger an image update check ## Converting a `docker run` command diff --git a/docs/features/webhooks.mdx b/docs/features/webhooks.mdx index f3b3c9bb..58eab40a 100644 --- a/docs/features/webhooks.mdx +++ b/docs/features/webhooks.mdx @@ -7,7 +7,7 @@ description: Trigger stack actions from CI/CD pipelines via HTTP webhooks with H Webhooks require a Sencho Pro license. -Sencho webhooks let external systems trigger stack actions over HTTP. The typical use case: your CI pipeline builds a new image, then calls a Sencho webhook to deploy the updated stack — no manual intervention required. +Sencho webhooks let external systems trigger stack actions over HTTP. The typical use case: your CI pipeline builds a new image, then calls a Sencho webhook to deploy the updated stack - no manual intervention required. ## How it works @@ -26,7 +26,7 @@ Open **Settings → Webhooks** and click **Create Webhook**. Fill in: | **Stack** | The target stack to act on | | **Action** | One of: `deploy`, `restart`, `stop`, `start`, `pull` | -After creation, Sencho shows the webhook secret **once**. Copy it immediately — it cannot be retrieved later. +After creation, Sencho shows the webhook secret **once**. Copy it immediately - it cannot be retrieved later. ### Actions explained @@ -122,6 +122,6 @@ Each webhook tracks its last 100 executions. Click **Recent executions** on any - Webhook secrets are 64-character hex strings generated with `crypto.randomBytes(32)` - Signature validation uses `crypto.timingSafeEqual` to prevent timing attacks -- Secrets are shown only once at creation — API responses return masked values +- Secrets are shown only once at creation - API responses return masked values - Webhook trigger endpoints are public (no session cookie required) but protected by HMAC signature validation -- Each webhook targets a single stack — there is no way to execute arbitrary commands +- Each webhook targets a single stack - there is no way to execute arbitrary commands diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index 751e4ffc..b5fc4816 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -41,21 +41,21 @@ volumes: ``` - Without a persistent data mount, Sencho will lose all configuration — including registered nodes, alerts, and settings — every time the container restarts. + Without a persistent data mount, Sencho will lose all configuration - including registered nodes, alerts, and settings - every time the container restarts. -### Compose directory — the 1:1 path rule +### Compose directory - the 1:1 path rule This is the most common source of deployment problems. Read carefully. -When Sencho runs `docker compose up`, it does so on your **host machine**. Docker resolves relative volume paths in your Compose files relative to the **host** path of the stack directory — not the path inside the Sencho container. +When Sencho runs `docker compose up`, it does so on your **host machine**. Docker resolves relative volume paths in your Compose files relative to the **host** path of the stack directory - not the path inside the Sencho container. **The rule:** Mount your Compose directory at the **exact same path** inside the container as it exists on your host. ```yaml -# ✅ Correct — host path matches container path +# ✅ Correct - host path matches container path volumes: - /home/boris/docker:/home/boris/docker environment: @@ -63,7 +63,7 @@ environment: ``` ```yaml -# ❌ Wrong — paths differ, relative volumes will break +# ❌ Wrong - paths differ, relative volumes will break volumes: - /home/boris/docker:/app/compose environment: @@ -158,4 +158,4 @@ labels: ## First boot -After starting Sencho, open it in your browser. If no admin account exists yet, you'll be taken to a setup screen to create one. This only appears once — subsequent visits go directly to the login page. +After starting Sencho, open it in your browser. If no admin account exists yet, you'll be taken to a setup screen to create one. This only appears once - subsequent visits go directly to the login page. diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 8e719c6d..35c54ded 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -3,7 +3,7 @@ title: Introduction description: What Sencho is and why you might want it. --- -Sencho is a self-hosted Docker Compose management dashboard. It gives you a clean web UI to deploy, manage, and monitor your Docker Compose stacks — locally or across multiple remote servers — without touching a terminal. +Sencho is a self-hosted Docker Compose management dashboard. It gives you a clean web UI to deploy, manage, and monitor your Docker Compose stacks - locally or across multiple remote servers - without touching a terminal. Sencho dashboard showing system stats and container metrics @@ -11,9 +11,9 @@ Sencho is a self-hosted Docker Compose management dashboard. It gives you a clea ## Key concepts -- **Stacks** — a Docker Compose project living in your `COMPOSE_DIR`. Sencho treats each subdirectory as a stack. -- **Nodes** — a Sencho instance. Your local machine is always the default node. Add remote nodes by pointing Sencho at another Sencho instance's API URL. -- **Resources** — images, volumes, and networks that belong to your stacks (managed) or exist outside them (external/unused). +- **Stacks** - a Docker Compose project living in your `COMPOSE_DIR`. Sencho treats each subdirectory as a stack. +- **Nodes** - a Sencho instance. Your local machine is always the default node. Add remote nodes by pointing Sencho at another Sencho instance's API URL. +- **Resources** - images, volumes, and networks that belong to your stacks (managed) or exist outside them (external/unused). Sencho never accesses remote servers directly via SSH or Docker TCP. Remote management works by proxying API requests to another running Sencho instance. @@ -21,12 +21,12 @@ Sencho is a self-hosted Docker Compose management dashboard. It gives you a clea ## What you can do -- **Deploy and control stacks** — create, start, stop, restart, and delete Compose stacks with one click -- **Edit files in-browser** — full Monaco editor for `compose.yaml` and `.env` files -- **Monitor in real-time** — live CPU, RAM, disk, and network stats with historical charts -- **Stream logs** — tail container logs individually or aggregate all stacks in one view -- **Manage resources** — browse, filter, and prune Docker images, volumes, and networks -- **Deploy from the App Store** — one-click deployment from a curated template registry -- **Run a host console** — interactive terminal on the host OS directly in the browser -- **Set alerts** — threshold-based notifications via Discord, Slack, or any webhook -- **Manage multiple servers** — add remote Sencho instances as nodes and switch between them seamlessly +- **Deploy and control stacks** - create, start, stop, restart, and delete Compose stacks with one click +- **Edit files in-browser** - full Monaco editor for `compose.yaml` and `.env` files +- **Monitor in real-time** - live CPU, RAM, disk, and network stats with historical charts +- **Stream logs** - tail container logs individually or aggregate all stacks in one view +- **Manage resources** - browse, filter, and prune Docker images, volumes, and networks +- **Deploy from the App Store** - one-click deployment from a curated template registry +- **Run a host console** - interactive terminal on the host OS directly in the browser +- **Set alerts** - threshold-based notifications via Discord, Slack, or any webhook +- **Manage multiple servers** - add remote Sencho instances as nodes and switch between them seamlessly diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index a498cdaf..1d2ca156 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -29,10 +29,10 @@ Open `http://localhost:3000` in your browser. On first boot you'll be prompted t ## Important: the 1:1 path rule -The `-v /opt/compose:/app/compose` mount above uses a simplified path for illustration. In practice, you must mount your compose directory at the **same path** inside and outside the container. See the [Configuration guide](/getting-started/configuration#compose-directory-the-11-path-rule) for details — this is the most common setup mistake. +The `-v /opt/compose:/app/compose` mount above uses a simplified path for illustration. In practice, you must mount your compose directory at the **same path** inside and outside the container. See the [Configuration guide](/getting-started/configuration#compose-directory-the-11-path-rule) for details - this is the most common setup mistake. ## Next steps -- [Configuration](/getting-started/configuration) — full environment variable reference, reverse proxy setup -- [Stack Management](/features/stack-management) — create and deploy your first stack -- [Multi-Node](/features/multi-node) — add a remote server to manage from this dashboard +- [Configuration](/getting-started/configuration) - full environment variable reference, reverse proxy setup +- [Stack Management](/features/stack-management) - create and deploy your first stack +- [Multi-Node](/features/multi-node) - add a remote server to manage from this dashboard diff --git a/docs/operations/backup.mdx b/docs/operations/backup.mdx index a42041cf..a8dfe599 100644 --- a/docs/operations/backup.mdx +++ b/docs/operations/backup.mdx @@ -14,13 +14,13 @@ Sencho stores all its state in two places: the **data directory** (SQLite databa Default path: `/app/data` inside the container, mapped to wherever you mounted it on the host (e.g. `./sencho-data`). Contains: -- `sencho.db` — SQLite database with all settings, nodes, alerts, metrics history, and notification history +- `sencho.db` - SQLite database with all settings, nodes, alerts, metrics history, and notification history This single file is everything Sencho knows about itself. Back it up and you can fully restore any Sencho installation. ### 2. Compose directory (`COMPOSE_DIR`) -The directory containing your stack subdirectories — your `compose.yaml` files, `.env` files, and any bind-mounted config files stored there. +The directory containing your stack subdirectories - your `compose.yaml` files, `.env` files, and any bind-mounted config files stored there. This is your actual application data. It lives entirely outside Sencho and you almost certainly already have it on a schedule, but include it in any Sencho backup plan. @@ -131,7 +131,7 @@ If other Sencho instances were pointing to your old host as a remote node, updat | Item | Location | Notes | |------|----------|-------| | Container data volumes | Wherever each stack's volumes are mounted on the host | Back these up separately per-application | -| Actual container images | Docker image cache | These are re-pulled on next deploy — no backup needed | +| Actual container images | Docker image cache | These are re-pulled on next deploy - no backup needed | | Sencho logs (docker logs) | Container stdout | Not persisted beyond container lifetime | diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 8a1feb1d..55daf2f0 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -15,13 +15,13 @@ docker compose -f /path/to/your/stack/compose.yaml logs The most common causes: -- **Missing environment variable** — a required variable in your `.env` file is empty or has the wrong name. -- **Port already in use** — another container or host process is bound to the same port. Change the host port in the compose file. -- **Volume path does not exist** — a bind-mount path on the host doesn't exist yet. Create the directory manually. +- **Missing environment variable** - a required variable in your `.env` file is empty or has the wrong name. +- **Port already in use** - another container or host process is bound to the same port. Change the host port in the compose file. +- **Volume path does not exist** - a bind-mount path on the host doesn't exist yet. Create the directory manually. --- -## The 1:1 path rule — volumes resolve to wrong paths +## The 1:1 path rule - volumes resolve to wrong paths **Symptom:** Stacks deploy but relative volume paths (e.g. `./config:/config`) point to the wrong location inside the container, or `docker compose` exits with a path error. @@ -37,7 +37,7 @@ environment: - COMPOSE_DIR=/opt/docker ``` -See [Configuration — the 1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule) for a full explanation. +See [Configuration - the 1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule) for a full explanation. --- @@ -61,7 +61,7 @@ ls -la /var/run/docker.sock # Expected: srw-rw---- 1 root docker ... ``` -If the group is not `docker`, the auto-detection still works — Sencho reads the GID from the socket file at startup. +If the group is not `docker`, the auto-detection still works - Sencho reads the GID from the socket file at startup. --- @@ -95,7 +95,7 @@ proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; ``` -Traefik handles WebSocket upgrades automatically for HTTP/1.1 backends — no extra config needed. +Traefik handles WebSocket upgrades automatically for HTTP/1.1 backends - no extra config needed. --- @@ -106,7 +106,7 @@ Traefik handles WebSocket upgrades automatically for HTTP/1.1 backends — no ex **Checks in order:** 1. **Is the remote Sencho instance running?** SSH to that machine and verify. -2. **Is the API URL correct?** It must include the protocol and port (e.g. `http://192.168.1.20:3001`). Open it in a browser — you should see a JSON response from `/api/health`. +2. **Is the API URL correct?** It must include the protocol and port (e.g. `http://192.168.1.20:3001`). Open it in a browser - you should see a JSON response from `/api/health`. 3. **Is the token correct?** Tokens are long JWT strings. Even one missing character will cause auth to fail. Regenerate the token on the remote instance and update the node config. 4. **Is there a firewall blocking the port?** The primary Sencho host must be able to reach the remote host's Sencho port. @@ -131,7 +131,7 @@ sqlite3 /path/to/data/sencho.db DELETE FROM global_settings WHERE key IN ('auth_username', 'auth_password_hash', 'auth_jwt_secret'); ``` -4. Restart the container — the setup screen will appear on next visit. +4. Restart the container - the setup screen will appear on next visit. This resets authentication entirely. All active sessions become invalid. Your stacks, nodes, and alert rules are not affected. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 9ca2c9b7..bf53ba3b 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -31,7 +31,7 @@ Click **Update Password** to apply. The change takes effect immediately; existin **Scope:** Per-node (applies to the currently selected node) -Configure resource thresholds that trigger visual warnings on the dashboard stat cards. These are display thresholds only — Sencho does not throttle or kill containers when limits are reached. +Configure resource thresholds that trigger visual warnings on the dashboard stat cards. These are display thresholds only - Sencho does not throttle or kill containers when limits are reached. | Setting | Range | Description | |---------|-------|-------------| @@ -99,7 +99,7 @@ Click **Save** to apply. **Scope:** Global -Manage connections to local and remote Sencho instances. This is the same interface as the [Multi-Node](/features/multi-node) feature — see that page for the full walkthrough. +Manage connections to local and remote Sencho instances. This is the same interface as the [Multi-Node](/features/multi-node) feature - see that page for the full walkthrough. Quick reference: diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 68f0eed6..8987cc75 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -49,7 +49,7 @@ test.describe('Authentication', () => { test('logout returns to the login screen', async ({ page }) => { await loginAs(page); - // Log Out is inside the User Profile Dropdown — open it first + // Log Out is inside the User Profile Dropdown - open it first await page.getByRole('button', { name: /profile/i }).click(); await page.getByRole('button', { name: /log out/i }).click(); await page.waitForTimeout(1_000); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index a2fcfb4c..0e6f95e0 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -12,7 +12,7 @@ import { Page, expect } from '@playwright/test'; export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin'; export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123'; -/** Selector for the dashboard — only present in EditorLayout, not on login/setup pages */ +/** Selector for the dashboard - only present in EditorLayout, not on login/setup pages */ const DASHBOARD_INDICATOR = 'img[alt="Sencho Logo"]'; /** Returns true if the current page is the first-run setup screen */ @@ -67,7 +67,7 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T } throw new Error( - 'loginAs: could not determine page state — expected setup, login, or dashboard. ' + + 'loginAs: could not determine page state - expected setup, login, or dashboard. ' + 'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.', ); } diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index b6884d82..084e66b8 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -8,7 +8,7 @@ import { loginAs } from './helpers'; test.describe('Node management', () => { test.beforeEach(async ({ page }) => { await loginAs(page); - // Settings is inside the User Profile Dropdown — open it first + // Settings is inside the User Profile Dropdown - open it first await page.getByRole('button', { name: /profile/i }).click(); await page.getByRole('button', { name: /settings/i }).click(); await page.getByRole('button', { name: /^nodes$/i }).click(); @@ -28,7 +28,7 @@ test.describe('Node management', () => { // Wait for the dialog form to be ready await expect(page.locator('#node-name')).toBeVisible({ timeout: 5_000 }); // The API URL field only renders when type === 'remote'. - // #node-type is a Radix UI combobox — click to open, then pick the option. + // #node-type is a Radix UI combobox - click to open, then pick the option. await page.locator('#node-type').click(); await page.getByRole('option', { name: /remote/i }).click(); // Confirm the API URL field is now visible before proceeding diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index de54dcaf..2ac2b997 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -16,7 +16,7 @@ const DOCS_IMAGES = path.resolve(__dirname, '../docs/images'); test.use({ viewport: { width: 1280, height: 800 }, - // Always capture — this spec exists solely to produce screenshots + // Always capture - this spec exists solely to produce screenshots screenshot: 'on', }); diff --git a/e2e/stacks.spec.ts b/e2e/stacks.spec.ts index f37a6eb5..643adea7 100644 --- a/e2e/stacks.spec.ts +++ b/e2e/stacks.spec.ts @@ -1,5 +1,5 @@ /** - * Stack management E2E tests — happy path CRUD. + * Stack management E2E tests - happy path CRUD. */ import { test, expect } from '@playwright/test'; import { loginAs } from './helpers'; @@ -17,7 +17,7 @@ async function waitForStacksLoaded(page: import('@playwright/test').Page) { /** Delete the test stack via the browser's authenticated fetch (so cookies are included). */ async function deleteTestStackViaApi(page: import('@playwright/test').Page) { await page.evaluate(async (name) => { - await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => { }); }, TEST_STACK); } @@ -47,9 +47,9 @@ test.describe('Stack management', () => { await Promise.race([ page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 8_000 }), page.getByText(/already exists/i).waitFor({ state: 'visible', timeout: 8_000 }), - ]).catch(() => {}); + ]).catch(() => { }); - // The stack should now exist — refresh and verify via the sidebar + // The stack should now exist - refresh and verify via the sidebar await page.reload(); await loginAs(page); await waitForStacksLoaded(page); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index abaf05ca..d2d33015 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -272,7 +272,7 @@ export default function EditorLayout() { isMounted = false; if (reconnectTimer) clearTimeout(reconnectTimer); // Only close an already-open connection. If still CONNECTING, let onopen - // detect isMounted=false and close then — avoids the browser warning + // detect isMounted=false and close then - avoids the browser warning // "WebSocket is closed before the connection is established". if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); @@ -768,7 +768,7 @@ export default function EditorLayout() { } catch (error) { console.error('Failed to deploy:', error); const msg = (error as Error).message || 'Failed to deploy stack'; - toast.error(isPro ? `${msg} — automatically rolled back to previous version.` : msg); + toast.error(isPro ? `${msg} - automatically rolled back to previous version.` : msg); } finally { setLoadingAction(null); } @@ -1060,56 +1060,56 @@ export default function EditorLayout() {

STACKS

- - {isLoading ? ( -
- - - -
- ) : ( - (filteredFiles || []).map(file => ( - loadFile(file)} - className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`} - > -
-
- {getDisplayName(file)} - - {stackUpdates[file] && ( - + {isLoading ? ( +
+ + + +
+ ) : ( + (filteredFiles || []).map(file => ( + loadFile(file)} + className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`} + > +
+
- )} + {getDisplayName(file)} -
e.stopPropagation()}> - - - - - - openAlertSheet(file)}> - - Alerts - - - + {stackUpdates[file] && ( + + )} + +
e.stopPropagation()}> + + + + + + openAlertSheet(file)}> + + Alerts + + + +
-
- - )) - )} - + + )) + )} +
@@ -1170,15 +1170,15 @@ export default function EditorLayout() { {/* Console Toggle */} {isAdmin && ( - + )} {/* Resources Toggle */} + + + ) : ( + - - - ) : ( - - )} - - {isPro && backupInfo.exists && ( - - - - - - - {backupInfo.timestamp - ? `Roll back to backup from ${new Date(backupInfo.timestamp).toLocaleString()}` - : 'Roll back to previous deployment'} - - - - )} - -
+ {isPro && backupInfo.exists && ( + + + + + + + {backupInfo.timestamp + ? `Roll back to backup from ${new Date(backupInfo.timestamp).toLocaleString()}` + : 'Roll back to previous deployment'} + + + + )} + +
)}
@@ -1463,22 +1463,22 @@ export default function EditorLayout() { {isAdmin && ( - - - - - - Open Bash Terminal - - + + + + + + Open Bash Terminal + + )} @@ -1538,29 +1538,29 @@ export default function EditorLayout() { )} {isAdmin && ( -
- {!isEditing ? ( - - ) : ( - <> - - - - - )} -
+ ) : ( + <> + + + + + )} + )}
diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index 231bcde1..d0f2cf97 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -301,7 +301,7 @@ export default function FleetSnapshots() { {skipped.map(node => (
  • {node.nodeName} - {' — '} + {' - '} {node.reason}
  • ))} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index a0a90a7d..417e5e83 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -335,7 +335,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
    Stopped
    -
    {node.stacks?.length ?? '—'}
    +
    {node.stacks?.length ?? '-'}
    Stacks
    @@ -499,7 +499,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const processedNodes = useMemo(() => { let filtered = [...nodes]; - // Search (Pro only, but harmless if applied — free users won't see the search bar) + // Search (Pro only, but harmless if applied - free users won't see the search bar) if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); filtered = filtered.filter(n => diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 007f7b94..efc68b81 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -156,7 +156,7 @@ function FootprintWidget({ usage, onFilter }: FootprintWidgetProps) { ); } -// ── Filter Toggle — Segmented Control ───────────────────────────────────────── +// ── Filter Toggle - Segmented Control ───────────────────────────────────────── interface FilterToggleProps { value: ResourceFilter; @@ -429,15 +429,15 @@ export default function ResourcesView() { // Derived filtered lists const filteredImages = images.filter(img => imageFilter === 'managed' ? img.managedStatus === 'managed' : - imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true + imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true ); const filteredVolumes = volumes.filter(vol => volumeFilter === 'managed' ? vol.managedStatus === 'managed' : - volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true + volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true ); const filteredNetworks = networks.filter(net => networkFilter === 'managed' ? net.managedStatus === 'managed' : - networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true + networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true ); const handleFootprintFilter = (filter: ResourceFilter) => { @@ -453,7 +453,7 @@ export default function ResourcesView() {

    Resources Hub

    {activeNode?.type === 'remote' && ( - — {activeNode.name} + - {activeNode.name} )} @@ -823,7 +823,7 @@ export default function ResourcesView() { Prune All Docker {confirmPrune?.target} - This will prune all unused {confirmPrune?.target} from the Docker daemon — + This will prune all unused {confirmPrune?.target} from the Docker daemon - including those from external projects not managed by Sencho. This cannot be undone. diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 47288d46..0011f520 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -18,7 +18,7 @@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil } from 'lucide-react'; +import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard } from 'lucide-react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; @@ -257,7 +257,7 @@ function WebhooksSection({ isPro }: { isPro: boolean }) { {newSecret && (
    - Webhook created — copy your secret now + Webhook created - copy your secret now

    This secret will not be shown again. Store it securely.

    @@ -647,7 +647,7 @@ function UsersSection() { export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); - const { license, isPro, activate, deactivate } = useLicense(); + const { license, isPro, activate, deactivate, checkout } = useLicense(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('account'); const [licenseKeyInput, setLicenseKeyInput] = useState(''); @@ -1141,72 +1141,99 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { )}
    - {/* Activation / Deactivation */} - {license?.status === 'active' ? ( -
    -

    - Deactivating will revert to Community features. -

    - -
    - ) : ( + {/* Manage Subscription (active Pro) */} + {license?.status === 'active' && (
    -
    - -

    - Enter your Sencho Pro license key to unlock all features. -

    -
    -
    - setLicenseKeyInput(e.target.value)} - className="font-mono" - /> + {license.portalUrl && ( + )} +
    +

    + Deactivating will revert to Community features. +

    +
    -

    - Don't have a license? Get Sencho Pro -

    +
    + )} + + {/* Subscribe / Activate (not active) */} + {license?.status !== 'active' && ( +
    +
    + +

    + Purchase a license key from our website, then activate it below. +

    +
    + + + {/* License key activation */} +
    + +
    + setLicenseKeyInput(e.target.value)} + className="font-mono" + /> + +
    +
    )}
    diff --git a/frontend/src/context/LicenseContext.tsx b/frontend/src/context/LicenseContext.tsx index e0aafd5f..4a9d425d 100644 --- a/frontend/src/context/LicenseContext.tsx +++ b/frontend/src/context/LicenseContext.tsx @@ -4,15 +4,19 @@ import { apiFetch } from '@/lib/api'; export type LicenseTier = 'community' | 'pro'; export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled'; +export type LicenseVariant = 'personal' | 'team' | null; + export interface LicenseInfo { tier: LicenseTier; status: LicenseStatus; + variant: LicenseVariant; customerName: string | null; productName: string | null; maskedKey: string | null; validUntil: string | null; trialDaysRemaining: number | null; instanceId: string; + portalUrl: string | null; } interface LicenseContextType { @@ -22,6 +26,7 @@ interface LicenseContextType { refresh: () => Promise; activate: (licenseKey: string) => Promise<{ success: boolean; error?: string }>; deactivate: () => Promise<{ success: boolean; error?: string }>; + checkout: () => void; } const LicenseContext = createContext(undefined); @@ -38,7 +43,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) { setLicense(data); } } catch { - // Silently fail — license info is non-critical + // Silently fail - license info is non-critical } finally { setLoading(false); } @@ -83,10 +88,14 @@ export function LicenseProvider({ children }: { children: ReactNode }) { } }, []); + const checkout = useCallback(() => { + window.open('https://sencho.io/#pricing', '_blank'); + }, []); + const isPro = license?.tier === 'pro'; return ( - + {children} ); diff --git a/frontend/src/index.css b/frontend/src/index.css index f1058178..ebbd7e4a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -28,7 +28,7 @@ --input: oklch(0.9400 0 0); --ring: oklch(0.50 0.14 200); - /* Brand — technical cyan, the signature accent */ + /* Brand - technical cyan, the signature accent */ --brand: oklch(0.50 0.14 200); --brand-foreground: oklch(1 0 0); --brand-muted: oklch(0.50 0.14 200 / 0.12); @@ -110,7 +110,7 @@ --input: oklch(0.3200 0 0); --ring: oklch(0.72 0.14 200); - /* Brand — bright technical cyan on dark backgrounds */ + /* Brand - bright technical cyan on dark backgrounds */ --brand: oklch(0.72 0.14 200); --brand-foreground: oklch(0.05 0 0); --brand-muted: oklch(0.72 0.14 200 / 0.12); @@ -140,7 +140,7 @@ /* Shape */ --radius: 0.5rem; - /* Shadows — deeper on dark backgrounds */ + /* Shadows - deeper on dark backgrounds */ --shadow-x: 0px; --shadow-y: 1px; --shadow-blur: 2px; @@ -331,7 +331,7 @@ body { .animate-delay-300 { animation-delay: 300ms; } /* ───────────────────────────────────────────────────────────── - PREFERS REDUCED MOTION — zero-cost accessibility + PREFERS REDUCED MOTION - zero-cost accessibility ───────────────────────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { *, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c28cea5e..d7c138b5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -28,7 +28,7 @@ export async function apiFetch( if (response.status === 401) { // Only fire the global logout event for local auth failures. // When the response carries x-sencho-proxy, the 401 came from a remote - // Sencho node (expired/invalid api_token) — not from the user's own session. + // Sencho node (expired/invalid api_token) - not from the user's own session. // Logging out in that case creates an unrecoverable loop. if (!response.headers.get('x-sencho-proxy')) { window.dispatchEvent(new Event('sencho-unauthorized')); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3147e0d6..52d7f5fb 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,7 +10,7 @@ import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' // Use the locally bundled Monaco instead of fetching from cdn.jsdelivr.net. // The CSP (scriptSrc: 'self') blocks external CDN scripts; bundling avoids // that entirely. Sencho only needs YAML/plaintext so the base editorWorker -// covers all language modes — no additional language workers required. +// covers all language modes - no additional language workers required. window.MonacoEnvironment = { getWorker(): Worker { return new editorWorker() diff --git a/playwright.config.ts b/playwright.config.ts index e59903a6..429ce5d2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,13 +11,13 @@ import { defineConfig, devices } from '@playwright/test'; */ export default defineConfig({ testDir: './e2e', - // Don't stop on first failure — show all results + // Don't stop on first failure - show all results maxFailures: 0, // How long to wait for a single test timeout: 30_000, // How long to wait for an expect() assertion expect: { timeout: 5_000 }, - // Run tests serially — Sencho is a single-user app and tests share DB state + // Run tests serially - Sencho is a single-user app and tests share DB state workers: 1, reporter: [['list'], ['html', { outputFolder: 'e2e/report', open: 'never' }]],