release: v0.1.0
release: v0.1.0
@@ -0,0 +1,3 @@
|
||||
# Force LF line endings for shell scripts regardless of the developer's OS.
|
||||
# Shell scripts with CRLF endings will fail with "exec format error" in Linux containers.
|
||||
*.sh text eol=lf
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
branches: [ develop ]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -18,22 +18,265 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
backend/package-lock.json
|
||||
frontend/package-lock.json
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install Backend Dependencies
|
||||
- name: Install Dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Build Backend (TypeScript)
|
||||
- name: Build (TypeScript)
|
||||
working-directory: ./backend
|
||||
run: npm run build
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
- name: Unit Tests (Vitest)
|
||||
working-directory: ./backend
|
||||
run: npm test
|
||||
|
||||
- name: Lint (ESLint)
|
||||
working-directory: ./backend
|
||||
run: npm run lint
|
||||
|
||||
- name: Audit Dependencies
|
||||
working-directory: ./backend
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Build Frontend (Vite/React)
|
||||
- name: Build (Vite/React)
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Lint (ESLint)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Audit Dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
docker-validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image (validation only)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: sencho:pr-test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan image for vulnerabilities (Trivy)
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: sencho:pr-test
|
||||
exit-code: '0'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
format: 'table'
|
||||
continue-on-error: true
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ backend, frontend ]
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install root dependencies (Playwright)
|
||||
run: npm ci
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Build backend
|
||||
working-directory: ./backend
|
||||
run: npm run build
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Create compose directory
|
||||
run: mkdir -p /tmp/compose
|
||||
|
||||
- name: Start backend
|
||||
working-directory: ./backend
|
||||
run: node dist/index.js &
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-key-not-for-production
|
||||
COMPOSE_DIR: /tmp/compose
|
||||
PORT: 3000
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Start frontend dev server
|
||||
working-directory: ./frontend
|
||||
run: npm run dev &
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: npx wait-on http://localhost:3000/api/health http://localhost:5173 --timeout 30000
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npx playwright test
|
||||
env:
|
||||
E2E_USERNAME: admin
|
||||
E2E_PASSWORD: password123
|
||||
|
||||
- name: Upload E2E report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/report/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
|
||||
update-screenshots:
|
||||
runs-on: ubuntu-latest
|
||||
# Only on develop pushes; skip bot commits to avoid an infinite loop
|
||||
if: "github.event_name == 'push' && github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'docs: refresh screenshots')"
|
||||
needs: [ backend, frontend ]
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# PAT required — GITHUB_TOKEN cannot create PRs against a protected
|
||||
# branch. DOCS_REPO_TOKEN is a classic PAT with repo scope.
|
||||
token: ${{ secrets.DOCS_REPO_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install root dependencies (Playwright)
|
||||
run: npm ci
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Build backend
|
||||
working-directory: ./backend
|
||||
run: npm run build
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Create compose directory
|
||||
run: mkdir -p /tmp/compose
|
||||
|
||||
- name: Start backend
|
||||
working-directory: ./backend
|
||||
run: node dist/index.js &
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-key-not-for-production
|
||||
COMPOSE_DIR: /tmp/compose
|
||||
PORT: 3000
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Start frontend dev server
|
||||
working-directory: ./frontend
|
||||
run: npm run dev &
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: npx wait-on http://localhost:3000/api/health http://localhost:5173 --timeout 30000
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Capture screenshots
|
||||
run: npx playwright test e2e/screenshots.spec.ts --project=chromium
|
||||
env:
|
||||
E2E_USERNAME: admin
|
||||
E2E_PASSWORD: password123
|
||||
|
||||
- name: Open / update screenshots PR
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.DOCS_REPO_TOKEN }}
|
||||
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 every push to `develop`."
|
||||
add-paths: docs/images/
|
||||
delete-branch: true
|
||||
|
||||
sync-docs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
|
||||
steps:
|
||||
- name: Checkout Sencho repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: sencho
|
||||
|
||||
- name: Clone or init sencho-docs
|
||||
env:
|
||||
DOCS_TOKEN: ${{ secrets.DOCS_REPO_TOKEN }}
|
||||
run: |
|
||||
# Always start clean so a previous partial/broken checkout can't cause
|
||||
# "not in a git directory" on subsequent steps.
|
||||
rm -rf sencho-docs
|
||||
|
||||
REMOTE="https://x-access-token:${DOCS_TOKEN}@github.com/AnsoCode/sencho-docs.git"
|
||||
|
||||
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."
|
||||
mkdir -p sencho-docs
|
||||
cd sencho-docs
|
||||
git init
|
||||
git checkout -b main
|
||||
git remote add origin "${REMOTE}"
|
||||
fi
|
||||
|
||||
- name: Copy /docs into sencho-docs root
|
||||
# --exclude='.git' prevents rsync --delete from wiping the .git
|
||||
# directory that was just cloned/initialized in the previous step.
|
||||
run: rsync -av --delete --exclude='.git' sencho/docs/ sencho-docs/
|
||||
|
||||
- name: Commit and push to sencho-docs
|
||||
working-directory: sencho-docs
|
||||
run: |
|
||||
git config user.email "docs-bot@sencho.io"
|
||||
git config user.name "Sencho Docs Bot"
|
||||
git add -A
|
||||
git commit -m "docs: sync from develop@${{ github.sha }}" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
|
||||
@@ -16,6 +16,11 @@ jobs:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -42,6 +47,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=saelix/sencho:buildcache
|
||||
|
||||
@@ -5,116 +5,159 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes — the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal.
|
||||
- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node.
|
||||
- **Fixed:** Container stats (CPU/RAM/NET), bash exec terminal, and "Open App" button all broken for remote nodes — stats and exec WebSockets connected to the bare root (`ws://host`) with no `?nodeId=` query param, so the upgrade handler couldn't detect the remote node and skipped the WS proxy. Moved all generic WebSockets to `/ws?nodeId=` path; upgrade handler now correctly proxies them to the remote Sencho instance.
|
||||
- **Fixed:** Bash exec and stats WebSockets not reaching the backend in `npm run dev` — Vite proxy config now includes `ws: true` on `/api` and a new `/ws` proxy entry so all WebSocket upgrades are forwarded to `localhost:3000`.
|
||||
- **Fixed:** Backend WS message handler crashing when a proxied WebSocket arrives from a gateway and the forwarded `nodeId` doesn't exist in the remote instance's DB — now falls back to the default local node instead of throwing.
|
||||
- **Fixed:** "Open App" button opening `http://localhost:{port}` for remote node containers — now resolves the hostname from the remote node's `api_url` so the correct remote host is used.
|
||||
- **Fixed:** Remote node system stats, container stats, logs, and exec returning errors — `remoteNodeProxy` middleware was already positioned before API route definitions, but `/api/system/stats` contained a dead remote branch that called `NodeRegistry.getDocker()` for remote nodes, which throws since remote nodes have no direct Docker socket access. Removed the broken branch; remote requests are correctly intercepted by the proxy middleware before reaching any route handler.
|
||||
- **Added:** Background image update checker — `ImageUpdateService` polls OCI-compliant registries (Docker Hub, GHCR, LSCR, etc.) every 6 hours using manifest digest comparison against local `RepoDigests`. Results cached in a new `stack_update_status` SQLite table. A pulsing blue dot badge appears in the stack list sidebar for stacks with available updates. Manual refresh available via `POST /api/image-updates/refresh` (rate-limited to once per 10 minutes).
|
||||
- **Fixed:** `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` — all calls now inject the `x-node-id` header so templates, deploys, stacks, and logs are correctly proxied to the active remote node.
|
||||
- **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter — the upgrade handler now receives the active node ID and routes the PTY session to the correct node.
|
||||
- **Added:** Two-tier Option A scoped navigation UX — a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local).
|
||||
- **Added:** Remote-aware headers in `HostConsole` ("Host Console — [Node Name]"), `ResourcesView` ("Resources Hub — [Node Name]"), `GlobalObservabilityView` (floating node badge), and `AppStoreView` deploy sheet ("Deploying to: [Node Name]").
|
||||
- **Added:** `SettingsModal` now scopes its sidebar to the active node type — when a remote node is selected, global-only tabs (Account, Appearance, Notifications, Nodes) are hidden, and the header subtitle shows the remote node name.
|
||||
- **Fixed:** A massive memory leak (browser Out of Memory crash) by throttling historical metrics polling down to 60s and downsampling SQLite metrics payload sizes by 12x.
|
||||
- **Fixed:** A bug where the active node UI dropdown would desync from the actual API requests on initial page load by properly hydrating state from localStorage.
|
||||
- **Fixed:** Remote node proxy forwarding the browser's `sencho_token` cookie to the remote Sencho instance — the remote's `authMiddleware` evaluates `cookieToken || bearerToken` and the cookie (signed with the local JWT secret) was validated before the valid Bearer token, causing 401 on all proxied API calls. Fixed by stripping the `cookie` header in `proxyReq` so only the Bearer token is used for remote authentication.
|
||||
- **Fixed:** `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted/non-existent node — the nodes list endpoint must always succeed so the frontend can re-sync a stale node ID in localStorage; exempted alongside `/api/auth/`.
|
||||
- **Fixed:** Remote node proxy stripping the `/api` path prefix — `remoteNodeProxy` is mounted at `app.use('/api/', ...)` so Express strips that prefix from `req.url` before `http-proxy-middleware` sees it; added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to the remote Sencho instance (e.g. `/stats` → `/api/stats`). This was the root cause of all remote API calls returning the remote's SPA HTML instead of JSON.
|
||||
- **Fixed:** Dashboard cards (Active Containers, Host CPU, Host RAM, Docker Network) showing stale local-node data after switching to a remote node — `HomeDashboard` polling effects now depend on `activeNode?.id` and clear state immediately on node change.
|
||||
- **Fixed:** `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response (e.g., connection refused to unreachable remote node) — now checks `res.ok` before calling `res.json()` and iterates a typed `fileList` instead of the raw parsed value.
|
||||
- **Fixed:** Restored Local/Remote type selector and fixed state resets in the Add Node modal — form now resets to defaults every time the dialog opens, and the title reflects the chosen type dynamically.
|
||||
- **Fixed:** Remote node connection details failing to display Containers, Images, and CPU metrics — `testRemoteConnection` now fires parallel requests to `/api/stats`, `/api/system/stats`, and `/api/system/images` after auth succeeds, mapping real values into the info panel.
|
||||
- **Fixed:** Suppressed `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` — override is applied to `process.emitWarning` before the proxy instances are created, cleanly intercepting the warning at its call site without suppressing other warnings.
|
||||
- **Fixed:** Backend memory leak caused by improper proxy middleware instantiation - `createProxyMiddleware` was called inside the request handler on every API call, spawning a new `http-proxy` instance (and registering new server listeners) per request. Refactored to a single globally-instantiated proxy using the `router` option for dynamic per-request target resolution.
|
||||
- **Fixed:** `[DEP0060] DeprecationWarning: util._extend` deprecation eliminated as a side-effect of the above fix (deprecation was triggered on every new `http-proxy` initialisation).
|
||||
- **Fixed:** Remote node authentication failures - `authMiddleware` and WebSocket upgrade handler both accept `Authorization: Bearer` tokens (Sencho-to-Sencho proxy auth).
|
||||
- **Fixed:** Node connection testing logic updated to perform authenticated HTTP pings to `/api/auth/check` on the remote instance.
|
||||
- **Fixed:** Node switcher dropdown failing to trigger data refreshes - `EditorLayout` now reacts to `activeNode` changes, re-fetching stacks and clearing stale editor/container state when the user switches nodes.
|
||||
- **Fixed:** API Token copy button failing silently on HTTP / non-localhost deployments where `navigator.clipboard` is unavailable - added `try/catch` with `document.execCommand('copy')` fallback.
|
||||
- **Fixed:** Remote node authentication failures by updating middleware to support Bearer tokens in WebSocket upgrade handler (node-to-node WS proxy now authenticates correctly on the receiving instance).
|
||||
- **Fixed:** Node connection testing logic updated to normalize `api_url` trailing slashes before constructing the authenticated HTTP ping URL.
|
||||
- **Fixed:** Memory leak in `GlobalObservabilityView` SSE mode - log array now capped at 10,000 entries (`.slice(-10000)`) to prevent unbounded accumulation across long sessions.
|
||||
- **Fixed:** Infinite re-fetch loop in `NodeContext` - `refreshNodes` useCallback no longer depends on `activeNode` state; replaced with a `useRef` to read current node inside the callback without being a reactive dependency.
|
||||
- **Fixed:** Infinite page reload loop - `apiFetch` was calling `window.location.href = '/'` on every 401, causing a full browser reload before auth could complete. Replaced with a `sencho-unauthorized` custom event that `AuthContext` handles by setting `appStatus` to `notAuthenticated`.
|
||||
- **Fixed:** `NodeProvider` was mounted outside the auth gate in `App.tsx`, causing `refreshNodes` to fire before authentication was established (hitting 401 immediately on boot). Moved `NodeProvider` inside the authenticated branch so it only mounts after login.
|
||||
- **Removed:** SSH/SFTP file adapters and remote Docker TCP connections (net negative ~500 lines of code).
|
||||
- **Added:** Distributed API proxying using http-proxy-middleware for HTTP and WebSockets.
|
||||
- **Added:** Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`).
|
||||
- **Changed:** Node Manager UI vastly simplified - remote nodes now only require an API URL and Token.
|
||||
- **Fixed:** Critical port routing conflict - separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED.
|
||||
- **Fixed:** `FileSystemService` now reads the node's `compose_dir` from the database for remote nodes instead of always using the `COMPOSE_DIR` env var.
|
||||
- **Fixed:** SSH/SFTP connections in `SSHFileAdapter`, `ComposeService.executeRemote()`, and `ComposeService.streamLogs()` now use `ssh_port` (default 22) instead of Docker API `port`.
|
||||
- **Added:** Full SSH credential fields (SSH Port, Username, Password, Private Key) to the Node Manager Add/Edit forms.
|
||||
- **Added:** `ssh_port` column to the `nodes` database table with migration support (default: 22).
|
||||
- **Changed:** Global `FileSystemService` and `ComposeService` singletons refactored into node-aware instances.
|
||||
- **Added:** `IFileAdapter`, `LocalFileAdapter`, and `SSHFileAdapter` to abstract all filesystem interactions for remote node support.
|
||||
- **Changed:** `MonitorService` now evaluates limits, fetches metrics, and detects container crashes across all registered nodes concurrently.
|
||||
- **Added:** Node Context Middleware in Express API to dynamically extract `x-node-id` headers and parse WebSocket query parameters.
|
||||
- **Added:** Remote Nodes Foundation - `nodes` table in SQLite with auto-seeded default local node.
|
||||
- **Added:** `NodeRegistry` service for managing multiple Docker daemon connections (local socket + TCP).
|
||||
- **Added:** Node management API endpoints: list, get, create, update, delete, and test connection.
|
||||
- **Added:** Settings Hub → Nodes tab with full CRUD UI, connection testing, and Docker info display.
|
||||
- **Added:** Node switcher dropdown in sidebar (auto-visible when multiple nodes are configured).
|
||||
- **Added:** `NodeContext` for frontend-wide active node state management.
|
||||
- **Fixed:** Global logs false-positive error misclassifications caused by Docker containers writing INFO logs to STDERR. Replaced naive regex with a robust 3-tier classification engine supporting `level=info`, `[INFO]`, and ` INFO ` format standards.
|
||||
- **Added:** Developer Mode setting to enable true Real-Time (SSE) global log streaming and infinite scroll.
|
||||
- **Added:** Configurable polling rates for standard global logs monitoring.
|
||||
- **Added:** React Throttle Buffer to prevent UI freezing during heavy real-time log ingestion.
|
||||
- **Fixed:** Global Logs aggressive auto-scrolling preventing users from reading log history.
|
||||
- **Fixed:** Quiet stacks missing from the Global Logs filter dropdown by fetching the definitive stack list independently.
|
||||
- **Fixed:** Global logs misclassifying INFO messages as errors due to naive string matching.
|
||||
- **Changed:** Global logs now display chronologically (newest at bottom) with smooth auto-scrolling.
|
||||
- **Changed:** Renamed Observability navigation tab to Logs.
|
||||
- **Fixed:** TTY container log streams failing to parse globally.
|
||||
- **Fixed:** Global logs displaying in UTC instead of local browser timezone.
|
||||
- **Changed:** Global Logs UI revamped to use a floating, hover-based action bar to maximize terminal space.
|
||||
- **Fixed:** Docker raw byte multiplex headers leaking into global logs stream.
|
||||
- **Changed:** Relocated historical CPU/RAM charts to the Home Dashboard and normalized data values (CPU relative to host cores, RAM to GB).
|
||||
- **Added:** Dozzle-style Action Bar to Global Logs with multi-select stack filtering, search, and STDOUT/STDERR toggles.
|
||||
- **Added:** Centralized observability dashboard tracking 24-hour historical metrics and aggregating global tail logs across all running containers.
|
||||
- **Added:** Live Container Logs viewer using Server-Sent Events (SSE) for real-time terminal output.
|
||||
- **Added:** Pre-deploy folder collision check to prevent silent configuration overwrites in the App Store.
|
||||
- **Added:** UI subtitle during deployment to reassure users during long image downloads.
|
||||
- **Changed:** Standardized manual stack deletion to use the Two-Stage Teardown (Compose Down -> File Wipe) to prevent ghost networks.
|
||||
- **Fixed:** Atomic Rollback failure where non-empty directories caused silent file system errors.
|
||||
- **Added:** Two-Stage Teardown mechanism to ensure `docker compose down` sweeps up ghost networks before deployment files are deleted.
|
||||
### Deprecated
|
||||
- **(Planned)** Port 2375 (TCP) fallback support; future releases may require SSH-only for Node config.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-03-24
|
||||
|
||||
### 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:** `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:** 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`.
|
||||
- **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.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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`.
|
||||
- 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).
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
#### 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.
|
||||
- **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.
|
||||
- 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.
|
||||
- Custom Environment Variable injection tool in deployment UI.
|
||||
- `ErrorBoundary` component now wraps root `<App />` in `main.tsx`.
|
||||
- Git Flow branching strategy and branch protection.
|
||||
|
||||
### Fixed
|
||||
- Fixed backend MonitorService crash (`Cannot read properties of undefined (reading 'cpu_usage')`) occurring when Docker containers lacked CPU telemetry during transition states.
|
||||
- Handled UI deleted nodes ghost API calls by intercepting 404 errors globally in API and forcing the UI to resync to the default Node context.
|
||||
- Hardened `nodeContextMiddleware` in Express to intercept queries to invalid or deleted Node IDs gracefully instead of bubbling to Docker API 500 crashes.
|
||||
- Hardened Remote Node connection testing (`docker.info()`) to explicitly validate expected Docker API daemon properties instead of merely checking string length.
|
||||
- Caught Unhandled SFTP Promise Rejections in Node registry gracefully returning empty arrays to prevent frontend loading UI stalls.
|
||||
- Fixed horizontal UI overflowing in Node Manager settings on smaller resolutions.
|
||||
- **Fixed:** Docker API parsing bug where HTML string responses from misconfigured ports were counted as containers.
|
||||
- **Fixed:** Stack list crashing when SFTP connections fail by gracefully catching SSH errors and returning empty arrays.
|
||||
|
||||
#### 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.
|
||||
|
||||
#### 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`.
|
||||
- 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`.
|
||||
|
||||
#### 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- `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()`.
|
||||
- `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.
|
||||
- `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.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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
|
||||
- **Changed:** Expanded Node Manager UI width and added horizontal scrollbars for better data visibility.
|
||||
- **Added:** Smart Error Parser with telemetry-ready rule IDs to translate cryptic Docker output.
|
||||
- **Added:** Post-Deploy Health Probe to catch immediate container crashes that slip past Compose.
|
||||
- **Changed:** Rollback engine respects a `canSilentlyRollback` flag to protect user-authored configurations.
|
||||
- **Changed:** Removed rigid volume sanitization, allowing full user control over bind paths.
|
||||
- **Added:** Editable Host Volumes in the deployment UI.
|
||||
- **Added:** Custom Environment Variable injection tool.
|
||||
- **Fixed:** ScrollArea UI height rendering and dynamic browser timezone detection.
|
||||
- **Changed:** Rebranded "Templates" to "App Store" across the UI.
|
||||
- **Added:** Advanced deployment configuration panel (Editable Ports and Environment Variables) with smart defaults.
|
||||
- **Fixed:** Implemented smart image fallbacks for broken registry logos and added expandable descriptions.
|
||||
- **Added:** Atomic Deployments: Failed App Store deployments now automatically roll back and delete their orphaned folders.
|
||||
- **Fixed:** Global dark mode scrollbar styling to eliminate blinding white native scrollbars.
|
||||
- **Fixed:** Input overlap UI bug in the App Store deployment panel.
|
||||
### Added
|
||||
- **Added:** Official LinuxServer.io API integration as the default Template Registry.
|
||||
- **Added:** Rich metadata display in the App Store (Architectures, Documentation links, GitHub repository links).
|
||||
- **Added:** Dynamic Template Registry URL support via global settings, defaulting to LinuxServer.io templates.
|
||||
- **Fixed:** Smart Volume Sanitizer to automatically rewrite messy Portainer bind mounts into clean, relative paths (Sencho 1:1 path rule).
|
||||
- Git Flow branching strategy and branch protection.
|
||||
- GitHub Actions CI pipeline for automated TypeScript build verification.
|
||||
- **Added:** Automated Docker Hub CI/CD pipeline for the `dev` and `latest` tags.
|
||||
- **Added:** App Templates (App Store) with One-Click deployment, utilizing Portainer v2 JSON registries and auto-generating compose files.
|
||||
|
||||
- **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`.
|
||||
- `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`.
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
### Removed
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,65 +1,143 @@
|
||||
# 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
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
# 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
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm config set fetch-retry-maxtimeout 120000 && \
|
||||
npm config set fetch-retries 5 && \
|
||||
npm install
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM node:20-alpine AS backend-builder
|
||||
# Stage 2: Compile TypeScript
|
||||
# Runs on the BUILD platform (amd64) — tsc output is platform-agnostic JS.
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Install build dependencies for node-pty native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy backend package files
|
||||
COPY backend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm config set fetch-retry-maxtimeout 120000 && \
|
||||
npm config set fetch-retries 5 && \
|
||||
npm install
|
||||
|
||||
# Copy backend source
|
||||
COPY backend/ ./
|
||||
|
||||
# Build backend
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:20-alpine
|
||||
# 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.
|
||||
# This avoids the Node.js v20 SIGILL crash that occurs when npm runs
|
||||
# under QEMU because QEMU lacks ARMv8.1 LSE atomic instruction support.
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS prod-deps
|
||||
|
||||
# Install Docker CLI, Docker Compose CLI, and Bash for Host Console
|
||||
RUN apk add --no-cache docker-cli docker-cli-compose bash
|
||||
# Copy xx cross-compilation tools into this stage
|
||||
COPY --from=xx / /
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG BUILDARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built backend and node_modules from backend-builder
|
||||
COPY --from=backend-builder /app/backend/dist ./dist
|
||||
COPY --from=backend-builder /app/backend/node_modules ./node_modules
|
||||
COPY --from=backend-builder /app/backend/package.json ./
|
||||
# Two paths depending on whether we are cross-compiling:
|
||||
#
|
||||
# Native (TARGETARCH == BUILDARCH, e.g. amd64 → amd64):
|
||||
# Standard g++ is used. xx-clang introduces sysroot flags that conflict with
|
||||
# node-gyp's header resolution on Alpine for same-platform builds, so we
|
||||
# bypass it entirely and let npm ci use the host compiler directly.
|
||||
#
|
||||
# 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 — <pty.h> / <termios.h> required by node-pty
|
||||
RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
||||
apk add --no-cache python3 make g++; \
|
||||
else \
|
||||
apk add --no-cache clang lld python3 make g++ && \
|
||||
xx-apk add --no-cache g++ musl-dev linux-headers; \
|
||||
fi
|
||||
|
||||
# Copy built frontend from frontend-builder to public folder
|
||||
COPY backend/package*.json ./
|
||||
|
||||
# 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.
|
||||
RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
||||
npm ci --omit=dev; \
|
||||
else \
|
||||
npm_config_arch=$TARGETARCH \
|
||||
CC=xx-clang \
|
||||
CXX=xx-clang++ \
|
||||
AR=xx-ar \
|
||||
npm ci --omit=dev; \
|
||||
fi
|
||||
|
||||
# Stage 4: Production runtime
|
||||
# Runs on the TARGET platform — no compilation happens here.
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install Docker CLI, Docker Compose CLI, and Bash for Host Console
|
||||
RUN apk add --no-cache docker-cli docker-cli-compose bash su-exec
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy cross-compiled production node_modules from the prod-deps stage
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=prod-deps /app/package.json ./
|
||||
|
||||
# Copy compiled TypeScript output (platform-agnostic JS)
|
||||
COPY --from=backend-builder /app/backend/dist ./dist
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
|
||||
# Set environment to production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create a non-root user and ensure the data/compose directories are writable.
|
||||
# The actual volume paths are mounted at runtime, so we only pre-create the
|
||||
# default data dir here; the compose dir is user-supplied via COMPOSE_DIR.
|
||||
RUN addgroup -S sencho && adduser -S -G sencho sencho \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R sencho:sencho /app
|
||||
|
||||
# Copy the entrypoint script that fixes data-volume ownership at startup and
|
||||
# then drops privileges to the sencho user via su-exec (the idiomatic Alpine
|
||||
# equivalent of gosu). This mirrors the pattern used by official Docker images
|
||||
# such as PostgreSQL, Redis, and MariaDB.
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# even with .gitattributes eol=lf, then make executable. A shell script with
|
||||
# \r in tokens like "fi\r" will fail with "unexpected end of file" in Alpine.
|
||||
RUN sed -i 's/\r//' /usr/local/bin/docker-entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the server
|
||||
# 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))"
|
||||
|
||||
# Entrypoint fixes volume ownership as root then drops to sencho via su-exec.
|
||||
# CMD provides the default arguments passed through to the entrypoint.
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: { ecmaVersion: 2022 },
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
// Allow unused catch-clause vars (catch (error) { ... }) and _-prefixed intentional ignores
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
caughtErrors: 'none',
|
||||
varsIgnorePattern: '^_',
|
||||
argsIgnorePattern: '^_',
|
||||
}],
|
||||
// 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -7,7 +7,8 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -20,10 +21,16 @@
|
||||
"@types/http-proxy-middleware": "^0.19.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/yaml": "^1.9.6",
|
||||
"nodemon": "^3.1.13",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"typescript-eslint": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
@@ -39,12 +46,15 @@
|
||||
"cors": "^2.8.6",
|
||||
"dockerode": "^4.0.9",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"helmet": "^8.1.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-pty": "^1.1.0",
|
||||
"systeminformation": "^5.31.1",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2"
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Tests for authentication: login, rate limiting, and auth middleware.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
it('returns 200 and sets a cookie on valid credentials', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 401 on wrong password', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: 'wrong-password' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 on unknown username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'nobody', password: 'anything' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 when credentials are missing', async () => {
|
||||
const res = await request(app).post('/api/auth/login').send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
it('rejects requests with no token (401)', async () => {
|
||||
const res = await request(app).get('/api/stacks');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects requests with an invalid token (401)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Authorization', 'Bearer this.is.not.valid');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts a valid Bearer token', async () => {
|
||||
// Issue a real token using the known test secret
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
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
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('accepts a valid cookie token', async () => {
|
||||
// First login to get the cookie
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
const cookies = loginRes.headers['set-cookie'] as string | string[];
|
||||
const cookieHeader = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Cookie', cookieHeader);
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Protected endpoint: console-token ───────────────────────────────────────
|
||||
|
||||
describe('POST /api/system/console-token', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('returns a token when authenticated', async () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Tests for the public /api/health endpoint.
|
||||
* This endpoint must be reachable without authentication.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
// setupTestDb must run before any app import so DATA_DIR is set first
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
it('returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('returns uptime as a number', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('does not require an auth token', async () => {
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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,
|
||||
* because DatabaseService initialises its path on first getInstance() call.
|
||||
*/
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const TEST_USERNAME = 'testadmin';
|
||||
export const TEST_PASSWORD = 'testpassword123';
|
||||
export let TEST_JWT_SECRET = '';
|
||||
|
||||
export async function setupTestDb(): Promise<string> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-test-'));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
// Also point COMPOSE_DIR to a temp dir so FileSystemService doesn't fail on missing dir
|
||||
const composeDir = path.join(tmpDir, 'compose');
|
||||
fs.mkdirSync(composeDir, { recursive: true });
|
||||
process.env.COMPOSE_DIR = composeDir;
|
||||
|
||||
// Initialise the DB (singleton will use DATA_DIR we just set)
|
||||
const { DatabaseService } = await import('../../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
// Seed admin credentials
|
||||
const passwordHash = await bcrypt.hash(TEST_PASSWORD, 1); // cost=1 for speed in tests
|
||||
TEST_JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
||||
db.updateGlobalSetting('auth_username', TEST_USERNAME);
|
||||
db.updateGlobalSetting('auth_password_hash', passwordHash);
|
||||
db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET);
|
||||
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
export function cleanupTestDb(tmpDir: string): void {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('POST /api/nodes — api_url SSRF validation (C2 fix)', () => {
|
||||
it('rejects localhost api_url', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/loopback/i);
|
||||
});
|
||||
|
||||
it('rejects 127.0.0.1 api_url', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node-2', type: 'remote', api_url: 'http://127.0.0.1:5432' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects non-http scheme', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node-3', type: 'remote', api_url: 'ftp://example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/http/i);
|
||||
});
|
||||
|
||||
it('rejects malformed URL', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node-4', type: 'remote', api_url: 'not-a-url' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts valid LAN IP', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({
|
||||
name: 'lan-node',
|
||||
type: 'remote',
|
||||
api_url: 'http://192.168.1.50:3000',
|
||||
api_token: 'sometoken',
|
||||
});
|
||||
// Should succeed (201 or 200) — not a validation error
|
||||
expect(res.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('requires api_url for remote nodes', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'missing-url', type: 'remote' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stack name validation on GET routes (H3 fix)', () => {
|
||||
it('rejects path traversal in GET /api/stacks/:stackName', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/..%2F..%2Fetc%2Fpasswd')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects dots in stack name', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/.hidden')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from '../utils/validation';
|
||||
|
||||
// ─── isValidStackName ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isValidStackName', () => {
|
||||
it('accepts alphanumeric names', () => {
|
||||
expect(isValidStackName('mystack')).toBe(true);
|
||||
expect(isValidStackName('MyStack123')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts hyphens and underscores', () => {
|
||||
expect(isValidStackName('my-stack')).toBe(true);
|
||||
expect(isValidStackName('my_stack')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path separators', () => {
|
||||
expect(isValidStackName('../etc')).toBe(false);
|
||||
expect(isValidStackName('foo/bar')).toBe(false);
|
||||
expect(isValidStackName('foo\\bar')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects dots', () => {
|
||||
expect(isValidStackName('.hidden')).toBe(false);
|
||||
expect(isValidStackName('foo.bar')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects spaces and special characters', () => {
|
||||
expect(isValidStackName('my stack')).toBe(false);
|
||||
expect(isValidStackName('foo;rm -rf /')).toBe(false);
|
||||
expect(isValidStackName('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidRemoteUrl ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isValidRemoteUrl', () => {
|
||||
it('accepts valid http URLs', () => {
|
||||
const result = isValidRemoteUrl('http://192.168.1.10:3000');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid https URLs', () => {
|
||||
const result = isValidRemoteUrl('https://sencho.example.com');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
const result = isValidRemoteUrl('not-a-url');
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(isValidRemoteUrl('ftp://example.com').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('file:///etc/passwd').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('javascript:alert(1)').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects localhost', () => {
|
||||
expect(isValidRemoteUrl('http://localhost:3000').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('http://LOCALHOST:3000').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects loopback IPs', () => {
|
||||
expect(isValidRemoteUrl('http://127.0.0.1:3000').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('http://127.1.2.3').valid).toBe(false);
|
||||
// Node.js URL.hostname preserves brackets: new URL('http://[::1]').hostname === '[::1]'
|
||||
expect(isValidRemoteUrl('http://[::1]:3000').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects 0.0.0.0', () => {
|
||||
expect(isValidRemoteUrl('http://0.0.0.0:3000').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('allows LAN/private IPs (users need these for local network nodes)', () => {
|
||||
// Users legitimately run Sencho nodes on their LAN
|
||||
expect(isValidRemoteUrl('http://192.168.1.100:3000').valid).toBe(true);
|
||||
expect(isValidRemoteUrl('http://10.0.0.5:3000').valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isPathWithinBase ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isPathWithinBase', () => {
|
||||
it('accepts paths within the base directory', () => {
|
||||
expect(isPathWithinBase('/app/compose/mystack/.env', '/app/compose/mystack')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the base directory itself', () => {
|
||||
expect(isPathWithinBase('/app/compose/mystack', '/app/compose/mystack')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects paths that escape via ..', () => {
|
||||
expect(isPathWithinBase('/app/compose/mystack/../../../etc/passwd', '/app/compose/mystack')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects sibling directories', () => {
|
||||
expect(isPathWithinBase('/app/compose/other-stack/.env', '/app/compose/mystack')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import WebSocket from 'ws';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import DockerController, { globalDockerNetwork } from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
@@ -14,8 +16,6 @@ import si from 'systeminformation';
|
||||
import http from 'http';
|
||||
import httpProxy from 'http-proxy';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { HostTerminalService } from './services/HostTerminalService';
|
||||
import { DatabaseService } from './services/DatabaseService';
|
||||
@@ -25,15 +25,14 @@ import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
import { templateService } from './services/TemplateService';
|
||||
import { ErrorParser } from './utils/ErrorParser';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
|
||||
import YAML from 'yaml';
|
||||
import fs, { promises as fsPromises } from 'fs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
||||
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
||||
// first invoked (NOT at import time), so intercepting process.emitWarning here —
|
||||
// before the proxy instances are created below — fully prevents it.
|
||||
// first invoked (NOT at import time), so intercepting process.emitWarning here -
|
||||
// before the proxy instances are created below - fully prevents it.
|
||||
// http-proxy has no compatible update; this suppression is intentional and safe.
|
||||
const _origEmitWarning = process.emitWarning.bind(process);
|
||||
(process as any).emitWarning = (warning: any, ...args: any[]) => {
|
||||
@@ -64,11 +63,86 @@ 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.
|
||||
// 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
|
||||
// directive. It tells browsers to silently upgrade all HTTP sub-resource fetches
|
||||
// to HTTPS. On a plain-HTTP self-hosted deployment (the common case) this causes
|
||||
// every JS/CSS asset to fail with ERR_SSL_PROTOCOL_ERROR, producing a blank page.
|
||||
// Setting null is the Helmet 8 API to remove a default directive.
|
||||
app.use(helmet({
|
||||
crossOriginEmbedderPolicy: false,
|
||||
// COOP is only meaningful over HTTPS. Over HTTP the browser logs a warning
|
||||
// and ignores it, creating noise in the console with no security benefit.
|
||||
crossOriginOpenerPolicy: false,
|
||||
hsts: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
baseUri: ["'self'"],
|
||||
fontSrc: ["'self'", 'https:', 'data:'],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
objectSrc: ["'none'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrcAttr: ["'none'"],
|
||||
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
||||
// connect-src: explicit 'self' covers same-origin fetch/XHR/WebSocket.
|
||||
// ws: and wss: are included for WebSocket connections in any scheme context.
|
||||
connectSrc: ["'self'", 'ws:', 'wss:'],
|
||||
// worker-src: Monaco editor creates Web Workers via blob: URLs for language
|
||||
// services (syntax highlighting, intellisense). Without blob: they silently fail.
|
||||
workerSrc: ["'self'", 'blob:'],
|
||||
// Helmet 8 merges custom directives with its defaults, which include
|
||||
// upgrade-insecure-requests. Setting it to null explicitly removes it.
|
||||
// On plain-HTTP self-hosted deployments (the common case) this directive
|
||||
// causes every JS/CSS asset to fail with ERR_SSL_PROTOCOL_ERROR → blank page.
|
||||
upgradeInsecureRequests: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 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
|
||||
: true;
|
||||
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
origin: corsOrigin,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
// 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.
|
||||
// When Node.js pipes an ended readable it calls process.nextTick(dest.end()),
|
||||
// which fires *before* the proxyReq socket event, so any attempt to write the
|
||||
// body inside the proxyReq handler results in "write after end" and the request
|
||||
// hangs. Solution: skip JSON parsing for remote-targeted /api/ requests so the
|
||||
// raw stream flows through the proxy intact.
|
||||
app.use((req: Request, res: Response, next: NextFunction): void => {
|
||||
const nodeIdHeader = req.headers['x-node-id'];
|
||||
if (nodeIdHeader) {
|
||||
const nodeId = parseInt(nodeIdHeader as string, 10);
|
||||
const node = NodeRegistry.getInstance().getNode(nodeId);
|
||||
if (
|
||||
node?.type === 'remote' &&
|
||||
req.path.startsWith('/api/') &&
|
||||
!req.path.startsWith('/api/auth/') &&
|
||||
!req.path.startsWith('/api/nodes')
|
||||
) {
|
||||
// Preserve body stream for proxy piping
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
express.json()(req, res, next);
|
||||
});
|
||||
app.use(cookieParser());
|
||||
|
||||
// Node Context Middleware
|
||||
@@ -122,13 +196,15 @@ wsProxyServer.on('error', (err, _req, socket: any) => {
|
||||
});
|
||||
|
||||
// Authentication Middleware
|
||||
// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy)
|
||||
// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy).
|
||||
// Bearer token is evaluated first: node-to-node proxy calls always carry a Bearer token and
|
||||
// should never be shadowed by a stale or cross-instance cookie.
|
||||
const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const cookieToken = req.cookies[COOKIE_NAME];
|
||||
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
|
||||
? req.headers.authorization.slice(7)
|
||||
: null;
|
||||
const token = cookieToken || bearerToken;
|
||||
const token = bearerToken || cookieToken;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
@@ -150,6 +226,22 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
}
|
||||
};
|
||||
|
||||
// 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({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: process.env.NODE_ENV === 'production' ? 5 : 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||
});
|
||||
|
||||
// Public health endpoint - no auth required (used by Docker HEALTHCHECK and uptime monitors)
|
||||
app.get('/api/health', (_req: Request, res: Response): void => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// Auth Routes (no authentication required)
|
||||
|
||||
// Check if setup is needed
|
||||
@@ -165,7 +257,7 @@ app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
|
||||
// Initial setup endpoint
|
||||
app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> => {
|
||||
app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
@@ -216,7 +308,7 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
app.post('/api/auth/login', async (req: Request, res: Response): Promise<void> => {
|
||||
app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
@@ -357,7 +449,7 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
|
||||
proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`);
|
||||
}
|
||||
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
|
||||
// doesn't reject the request with 404 ("Node X not found") — the remote
|
||||
// doesn't reject the request with 404 ("Node X not found") - the remote
|
||||
// has no record of the gateway's node IDs and should treat the request
|
||||
// as local. This affects endpoints like EventSource /api/containers/:id/logs
|
||||
// that pass nodeId as a query param rather than the x-node-id header.
|
||||
@@ -368,11 +460,28 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
|
||||
const newQs = params.toString();
|
||||
proxyReq.path = pathname + (newQs ? `?${newQs}` : '');
|
||||
}
|
||||
// Body forwarding: the conditional json parser (see top of file) skips
|
||||
// parsing for remote requests, so req's raw stream is intact and
|
||||
// http-proxy's req.pipe(proxyReq) forwards the body automatically.
|
||||
// No manual body rewriting needed here.
|
||||
},
|
||||
proxyRes: (proxyRes) => {
|
||||
// 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
|
||||
// expired. Without this distinction, any node with a bad token causes an
|
||||
// immediate logout loop.
|
||||
proxyRes.headers['x-sencho-proxy'] = '1';
|
||||
},
|
||||
error: (err, _req, proxyRes) => {
|
||||
console.error('[Proxy] Remote node error:', (err as Error).message);
|
||||
if (!(proxyRes as Response).headersSent) {
|
||||
(proxyRes as Response).status(502).json({
|
||||
// proxyRes can be either a ServerResponse (HTTP) or a raw Socket (WS/TCP errors).
|
||||
// Only attempt to send an HTTP 502 if it is a proper ServerResponse with a
|
||||
// headersSent flag - otherwise silently drop (the socket will be destroyed).
|
||||
const res = proxyRes as any;
|
||||
if (typeof res?.headersSent === 'boolean' && !res.headersSent && typeof res.status === 'function') {
|
||||
res.status(502).json({
|
||||
error: 'Remote node is unreachable. Check the API URL and ensure Sencho is running on that host.'
|
||||
});
|
||||
}
|
||||
@@ -408,10 +517,22 @@ app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
|
||||
const server = http.createServer(app);
|
||||
|
||||
// WebSocket server with authentication
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
let terminalWs: WebSocket | null = null;
|
||||
|
||||
// Notification push - set of authenticated browser clients subscribed to real-time alerts
|
||||
const notificationSubscribers = new Set<WebSocket>();
|
||||
NotificationService.getInstance().setBroadcaster((notification) => {
|
||||
if (notificationSubscribers.size === 0) return;
|
||||
const msg = JSON.stringify({ type: 'notification', payload: notification });
|
||||
for (const ws of notificationSubscribers) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle WebSocket upgrade with JWT authentication
|
||||
server.on('upgrade', async (req, socket, head) => {
|
||||
// Parse cookies from the upgrade request
|
||||
@@ -424,7 +545,9 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
const cookieToken = cookies[COOKIE_NAME];
|
||||
const authHeader = req.headers['authorization'] as string | undefined;
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
const token = cookieToken || bearerToken;
|
||||
// Prefer Bearer over cookie: node-to-node proxy upgrades carry a Bearer token and must
|
||||
// not be shadowed by a browser cookie signed with a different instance's JWT secret.
|
||||
const token = bearerToken || cookieToken;
|
||||
|
||||
if (!token) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
@@ -436,7 +559,11 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
jwt.verify(token, jwtSecret);
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string };
|
||||
|
||||
// Node proxy tokens are machine-to-machine credentials and must never be granted
|
||||
// interactive terminal access (host console or container exec).
|
||||
const isProxyToken = decoded.scope === 'node_proxy';
|
||||
|
||||
const url = req.url || '';
|
||||
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
|
||||
@@ -447,12 +574,56 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = NodeRegistry.getInstance().getNode(nodeId);
|
||||
|
||||
// Notification push channel - local only when no remote nodeId is specified.
|
||||
// When a nodeId pointing to a remote node is provided, fall through to the
|
||||
// proxy block below so the browser subscribes to that remote node's push stream.
|
||||
if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) {
|
||||
const notifWss = new WebSocketServer({ noServer: true });
|
||||
notifWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
notifWss.close();
|
||||
notificationSubscribers.add(ws);
|
||||
ws.on('close', () => notificationSubscribers.delete(ws));
|
||||
ws.on('error', () => { notificationSubscribers.delete(ws); ws.terminate(); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance
|
||||
if (node && node.type === 'remote' && node.api_url && node.api_token) {
|
||||
const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
|
||||
req.headers['authorization'] = `Bearer ${node.api_token}`;
|
||||
|
||||
// Interactive console paths (host console / container exec) are guarded on the remote by
|
||||
// an isProxyToken check that rejects the long-lived api_token (scope: 'node_proxy').
|
||||
// Exchange it for a short-lived console_session token before forwarding so the remote
|
||||
// allows the connection while keeping the guard intact for direct api_token access.
|
||||
const isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws';
|
||||
let bearerTokenForProxy = node.api_token;
|
||||
if (isInteractiveConsolePath) {
|
||||
try {
|
||||
const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${node.api_token}` },
|
||||
});
|
||||
if (tokenRes.ok) {
|
||||
const data = await tokenRes.json() as { token?: string };
|
||||
if (typeof data.token === 'string') bearerTokenForProxy = data.token;
|
||||
} else {
|
||||
console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`);
|
||||
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WS Proxy] Failed to fetch remote console token:', (e as Error).message);
|
||||
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
req.headers['authorization'] = `Bearer ${bearerTokenForProxy}`;
|
||||
delete req.headers['x-node-id'];
|
||||
// Strip the browser's session cookie — it is signed by this instance's JWT secret and
|
||||
// Strip the browser's session cookie - it is signed by this instance's JWT secret and
|
||||
// would fail verification on the remote. Auth is handled exclusively via the Bearer token.
|
||||
delete req.headers['cookie'];
|
||||
// Strip nodeId from the forwarded URL so the remote treats the request as a local one.
|
||||
@@ -471,8 +642,12 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
|
||||
if (logsMatch) {
|
||||
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
|
||||
const logsWss = new WebSocket.Server({ noServer: true });
|
||||
const logsWss = new WebSocketServer({ noServer: true });
|
||||
logsWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
// Close the per-connection server immediately after the upgrade is complete.
|
||||
// The wss instance is only needed to negotiate the handshake; keeping it open
|
||||
// would accumulate listeners and allocate memory for every connection.
|
||||
logsWss.close();
|
||||
const stackName = decodeURIComponent(logsMatch[1]);
|
||||
try {
|
||||
ComposeService.getInstance(nodeId).streamLogs(stackName, ws);
|
||||
@@ -484,14 +659,29 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
}
|
||||
});
|
||||
} else if (hostConsoleMatch) {
|
||||
const hostConsoleWss = new WebSocket.Server({ noServer: true });
|
||||
// Node proxy tokens must not access interactive host terminals
|
||||
if (isProxyToken) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory = '';
|
||||
try {
|
||||
targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir();
|
||||
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
|
||||
const stackParam = parsedUrl.searchParams.get('stack');
|
||||
if (stackParam) {
|
||||
targetDirectory = path.join(targetDirectory, stackParam);
|
||||
const resolved = path.resolve(baseDir, stackParam);
|
||||
if (!resolved.startsWith(path.resolve(baseDir))) {
|
||||
ws.send('Error: Invalid stack path\r\n');
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
targetDirectory = resolved;
|
||||
} else {
|
||||
targetDirectory = baseDir;
|
||||
}
|
||||
} catch (e) {
|
||||
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
|
||||
@@ -507,7 +697,13 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Generic terminal WebSocket
|
||||
// Generic terminal WebSocket (container exec)
|
||||
// Node proxy tokens must not access interactive container terminals
|
||||
if (isProxyToken) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
@@ -539,14 +735,20 @@ wss.on('connection', (ws) => {
|
||||
// message belongs to the gateway's DB and won't resolve locally. Fall back to local.
|
||||
let nodeId = requestedId;
|
||||
try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); }
|
||||
DockerController.getInstance(nodeId).streamStats(data.containerId, ws);
|
||||
DockerController.getInstance(nodeId).streamStats(data.containerId, ws).catch((err: Error) => {
|
||||
console.error('[WS] streamStats error:', err.message);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||
});
|
||||
} else if (data.action === 'execContainer') {
|
||||
// Handle container exec for bash access
|
||||
// Input, resize, and cleanup are handled inside execContainer's closure
|
||||
const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
||||
let nodeId = requestedId;
|
||||
try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); }
|
||||
DockerController.getInstance(nodeId).execContainer(data.containerId, ws);
|
||||
DockerController.getInstance(nodeId).execContainer(data.containerId, ws).catch((err: Error) => {
|
||||
console.error('[WS] execContainer error:', err.message);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Malformed JSON - ignore silently
|
||||
@@ -580,6 +782,9 @@ app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
@@ -640,20 +845,22 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
|
||||
const service = parsed.services[serviceName];
|
||||
if (!service?.env_file) continue;
|
||||
|
||||
const addEnvPath = (rawPath: string) => {
|
||||
const resolved = path.resolve(stackDir, rawPath);
|
||||
// Reject paths that escape the stack directory
|
||||
if (!resolved.startsWith(path.resolve(stackDir) + path.sep) && resolved !== path.resolve(stackDir)) {
|
||||
console.warn(`[Security] env_file path "${rawPath}" escapes stack directory — skipping`);
|
||||
return;
|
||||
}
|
||||
envFiles.add(resolved);
|
||||
};
|
||||
|
||||
if (typeof service.env_file === 'string') {
|
||||
const resolvedPath = path.isAbsolute(service.env_file)
|
||||
? service.env_file
|
||||
: path.resolve(stackDir, service.env_file);
|
||||
envFiles.add(resolvedPath);
|
||||
addEnvPath(service.env_file);
|
||||
} else if (Array.isArray(service.env_file)) {
|
||||
for (const entry of service.env_file) {
|
||||
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
|
||||
if (entryPath) {
|
||||
const resolvedPath = path.isAbsolute(entryPath)
|
||||
? entryPath
|
||||
: path.resolve(stackDir, entryPath);
|
||||
envFiles.add(resolvedPath);
|
||||
}
|
||||
if (entryPath) addEnvPath(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,6 +880,9 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
|
||||
app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
res.json({ envFiles: envPaths });
|
||||
} catch (error) {
|
||||
@@ -683,6 +893,9 @@ app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
|
||||
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const requestedFile = req.query.file as string | undefined;
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
|
||||
@@ -946,15 +1159,27 @@ app.post('/api/convert', async (req: Request, res: Response) => {
|
||||
// Get all containers stats for dashboard
|
||||
app.get('/api/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getRunningContainers();
|
||||
const allContainers = await dockerController.getAllContainers();
|
||||
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(req.nodeId));
|
||||
const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers();
|
||||
|
||||
const active = containers.length;
|
||||
const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length;
|
||||
// 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.
|
||||
const isManagedByComposeDir = (c: any): boolean => {
|
||||
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
|
||||
if (!workingDir) return false;
|
||||
const resolved = path.resolve(workingDir);
|
||||
return resolved === composeDir || resolved.startsWith(composeDir + path.sep);
|
||||
};
|
||||
|
||||
const active = allContainers.filter((c: any) => c.State === 'running').length;
|
||||
const exited = allContainers.filter((c: any) => c.State === 'exited').length;
|
||||
const total = allContainers.length;
|
||||
const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length;
|
||||
const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length;
|
||||
|
||||
res.json({ active, exited, total, inactive: total - active - exited });
|
||||
res.json({ active, managed, unmanaged, exited, total });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||
}
|
||||
@@ -977,7 +1202,7 @@ app.get('/api/logs/global', async (req: Request, res: Response) => {
|
||||
|
||||
await Promise.all(containers.map(async (c) => {
|
||||
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
|
||||
let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
|
||||
const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
|
||||
|
||||
// Standardize naming: Strip stack name prefix if it exists
|
||||
let containerName = rawName;
|
||||
@@ -1053,9 +1278,11 @@ app.get('/api/logs/global', async (req: Request, res: Response) => {
|
||||
}
|
||||
}));
|
||||
|
||||
// Sort globally by timestamp ascending (newest bottom) and limit to 2000 lines
|
||||
// Sort globally by timestamp ascending (newest bottom).
|
||||
// Limit to 500 lines - the client renders at most 300 rows at once, so
|
||||
// sending 2000 lines was wasting bandwidth and inflating JSON parse time.
|
||||
allLogs.sort((a, b) => a.timestampMs - b.timestampMs);
|
||||
res.json(allLogs.slice(-2000));
|
||||
res.json(allLogs.slice(-500));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch global logs' });
|
||||
}
|
||||
@@ -1075,7 +1302,7 @@ app.get('/api/logs/global/stream', async (req: Request, res: Response) => {
|
||||
|
||||
await Promise.all(containers.map(async (c) => {
|
||||
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
|
||||
let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
|
||||
const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
|
||||
let containerName = rawName;
|
||||
if (rawName.startsWith(`${stackName}-`)) containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, '');
|
||||
else if (rawName.startsWith(`${stackName}_`)) containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, '');
|
||||
@@ -1225,11 +1452,48 @@ app.post('/api/agents', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Keys that contain auth credentials - never exposed to the frontend or writable via settings API
|
||||
const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']);
|
||||
|
||||
// Strict allowlist of keys writable via the settings API (prevents overwriting auth credentials)
|
||||
const ALLOWED_SETTING_KEYS = new Set([
|
||||
'host_cpu_limit',
|
||||
'host_ram_limit',
|
||||
'host_disk_limit',
|
||||
'docker_janitor_gb',
|
||||
'global_crash',
|
||||
'global_logs_refresh',
|
||||
'developer_mode',
|
||||
'template_registry_url',
|
||||
'metrics_retention_hours',
|
||||
'log_retention_days',
|
||||
]);
|
||||
|
||||
// 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' }),
|
||||
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),
|
||||
}).partial();
|
||||
|
||||
app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
// Strip auth credentials - these are managed exclusively by /api/auth/* endpoints
|
||||
for (const key of PRIVATE_SETTINGS_KEYS) {
|
||||
delete settings[key];
|
||||
}
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
@@ -1237,13 +1501,43 @@ app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
app.post('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { key, value } = req.body;
|
||||
DatabaseService.getInstance().updateGlobalSetting(key, value);
|
||||
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
|
||||
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
res.status(400).json({ error: 'Setting value is required' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().updateGlobalSetting(key, String(value));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to update setting:', error);
|
||||
res.status(500).json({ error: 'Failed to update setting' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = SettingsPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const updateMany = db.getDb().transaction((entries: [string, string][]) => {
|
||||
for (const [k, v] of entries) {
|
||||
db.updateGlobalSetting(k, v);
|
||||
}
|
||||
});
|
||||
updateMany(Object.entries(parsed.data) as [string, string][]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk update settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update settings' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/alerts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
let stackName = req.query.stackName as string | undefined;
|
||||
@@ -1256,12 +1550,26 @@ app.get('/api/alerts', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
const AlertCreateSchema = z.object({
|
||||
stack_name: z.string().min(1).max(255),
|
||||
metric: z.enum(['cpu_percent', 'memory_percent', 'memory_mb', 'net_rx', 'net_tx', 'restart_count']),
|
||||
operator: z.enum(['>', '>=', '<', '<=', '==']),
|
||||
threshold: z.number().min(0),
|
||||
duration_mins: z.coerce.number().int().min(0).max(1440),
|
||||
cooldown_mins: z.coerce.number().int().min(0).max(10080),
|
||||
});
|
||||
|
||||
app.post('/api/alerts', async (req: Request, res: Response) => {
|
||||
const parsed = AlertCreateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid alert data', details: parsed.error.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const alert = req.body;
|
||||
DatabaseService.getInstance().addStackAlert(alert);
|
||||
DatabaseService.getInstance().addStackAlert(parsed.data);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to add alert:', error);
|
||||
res.status(500).json({ error: 'Failed to add alert' });
|
||||
}
|
||||
});
|
||||
@@ -1276,7 +1584,7 @@ app.delete('/api/alerts/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/notifications', async (req: Request, res: Response) => {
|
||||
app.get('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const history = DatabaseService.getInstance().getNotificationHistory();
|
||||
res.json(history);
|
||||
@@ -1285,7 +1593,7 @@ app.get('/api/notifications', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notifications/read', async (req: Request, res: Response) => {
|
||||
app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
DatabaseService.getInstance().markAllNotificationsRead();
|
||||
res.json({ success: true });
|
||||
@@ -1294,7 +1602,7 @@ app.post('/api/notifications/read', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/:id', async (req: Request, res: Response) => {
|
||||
app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
DatabaseService.getInstance().deleteNotification(id);
|
||||
@@ -1304,7 +1612,7 @@ app.delete('/api/notifications/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/notifications', async (req: Request, res: Response) => {
|
||||
app.delete('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
DatabaseService.getInstance().deleteAllNotifications();
|
||||
res.json({ success: true });
|
||||
@@ -1313,7 +1621,7 @@ app.delete('/api/notifications', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notifications/test', async (req: Request, res: Response) => {
|
||||
app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, url } = req.body;
|
||||
await NotificationService.getInstance().testDispatch(type, url);
|
||||
@@ -1323,6 +1631,27 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Issue a short-lived console session token for WebSocket proxy delegation.
|
||||
// When the gateway needs to proxy an interactive terminal (host console or container exec)
|
||||
// to a remote node, it calls this endpoint (authenticated with the long-lived api_token)
|
||||
// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session'
|
||||
// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths.
|
||||
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) {
|
||||
res.status(500).json({ error: 'No JWT secret configured' });
|
||||
return;
|
||||
}
|
||||
const consoleToken = jwt.sign({ scope: 'console_session' }, jwtSecret, { expiresIn: '60s' });
|
||||
res.json({ token: consoleToken });
|
||||
} catch (error) {
|
||||
console.error('Failed to issue console token:', error);
|
||||
res.status(500).json({ error: 'Failed to issue console token' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- System Maintenance Routes (The System Janitor) ---
|
||||
|
||||
app.get('/api/system/orphans', async (req: Request, res: Response) => {
|
||||
@@ -1354,13 +1683,24 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => {
|
||||
|
||||
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { target } = req.body; // 'containers', 'images', 'networks', 'volumes'
|
||||
const { target, scope } = req.body as { target: string; scope?: string };
|
||||
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||
return res.status(400).json({ error: 'Invalid prune target' });
|
||||
}
|
||||
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const result = await dockerController.pruneSystem(target);
|
||||
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
||||
|
||||
let result: { success: boolean; reclaimedBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
result = await dockerController.pruneManagedOnly(
|
||||
target as 'images' | 'volumes' | 'networks',
|
||||
knownStacks
|
||||
);
|
||||
} else {
|
||||
result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes');
|
||||
}
|
||||
|
||||
res.json({ message: 'Prune completed', ...result });
|
||||
} catch (error: any) {
|
||||
@@ -1371,8 +1711,8 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => {
|
||||
|
||||
app.get('/api/system/docker-df', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const df = await dockerController.getDiskUsage();
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const df = await DockerController.getInstance(req.nodeId).getDiskUsageClassified(knownStacks);
|
||||
res.json(df);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch docker disk usage:', error);
|
||||
@@ -1380,10 +1720,23 @@ app.get('/api/system/docker-df', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Single endpoint returning classified images, volumes, and networks in one call
|
||||
app.get('/api/system/resources', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const result = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch classified resources:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch resources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Keep legacy endpoints for backward compat with remote proxy routing
|
||||
app.get('/api/system/images', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const images = await dockerController.getImages();
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const { images } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(images);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch images:', error);
|
||||
@@ -1393,8 +1746,8 @@ app.get('/api/system/images', async (req: Request, res: Response) => {
|
||||
|
||||
app.get('/api/system/volumes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const volumes = await dockerController.getVolumes();
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const { volumes } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(volumes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch volumes:', error);
|
||||
@@ -1404,8 +1757,8 @@ app.get('/api/system/volumes', async (req: Request, res: Response) => {
|
||||
|
||||
app.get('/api/system/networks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const networks = await dockerController.getNetworks();
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const { networks } = await DockerController.getInstance(req.nodeId).getClassifiedResources(knownStacks);
|
||||
res.json(networks);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch networks:', error);
|
||||
@@ -1463,6 +1816,11 @@ app.get('/api/templates', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => {
|
||||
templateService.clearCache();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/templates/deploy', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { stackName, template, envVars } = req.body;
|
||||
@@ -1564,6 +1922,7 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp
|
||||
// Node Management API
|
||||
// =========================
|
||||
|
||||
|
||||
// List all nodes
|
||||
app.get('/api/nodes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -1599,8 +1958,14 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
|
||||
if (!type || !['local', 'remote'].includes(type)) {
|
||||
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
|
||||
}
|
||||
if (type === 'remote' && (!api_url || typeof api_url !== 'string')) {
|
||||
return res.status(400).json({ error: 'API URL is required for remote nodes' });
|
||||
if (type === 'remote') {
|
||||
if (!api_url || typeof api_url !== 'string') {
|
||||
return res.status(400).json({ error: 'API URL is required for remote nodes' });
|
||||
}
|
||||
const urlCheck = isValidRemoteUrl(api_url);
|
||||
if (!urlCheck.valid) {
|
||||
return res.status(400).json({ error: urlCheck.reason });
|
||||
}
|
||||
}
|
||||
|
||||
const id = DatabaseService.getInstance().addNode({
|
||||
@@ -1628,6 +1993,13 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const updates = req.body;
|
||||
|
||||
if (updates.api_url !== undefined && updates.api_url !== '') {
|
||||
const urlCheck = isValidRemoteUrl(updates.api_url);
|
||||
if (!urlCheck.valid) {
|
||||
return res.status(400).json({ error: urlCheck.reason });
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().updateNode(id, updates);
|
||||
|
||||
// Evict cached Docker connection so it reconnects with new config
|
||||
@@ -1711,4 +2083,35 @@ async function startServer() {
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
// Only start the server when this file is the entry point (not when imported by tests).
|
||||
if (require.main === module) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
// Exports used by tests (supertest requires the http.Server instance).
|
||||
export { app, server };
|
||||
|
||||
// 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…`);
|
||||
|
||||
server.close(() => {
|
||||
console.log('[Shutdown] HTTP server closed');
|
||||
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');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force-exit after 10 s if connections refuse to drain
|
||||
setTimeout(() => {
|
||||
console.error('[Shutdown] Timed out waiting for connections — forcing exit');
|
||||
process.exit(1);
|
||||
}, 10_000).unref();
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
@@ -110,7 +110,7 @@ export class ComposeService {
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
|
||||
let logStr = logs.toString('utf-8');
|
||||
const logStr = logs.toString('utf-8');
|
||||
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export class ComposeService {
|
||||
|
||||
let activeProcesses = 0;
|
||||
let streamEndedHandled = false;
|
||||
let localProcesses: ReturnType<typeof spawn>[] = [];
|
||||
const localProcesses: ReturnType<typeof spawn>[] = [];
|
||||
|
||||
const onWsClose = () => {
|
||||
localProcesses.forEach(cp => { try { cp.kill(); } catch { } });
|
||||
|
||||
@@ -136,6 +136,11 @@ export class DatabaseService {
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -167,6 +172,8 @@ export class DatabaseService {
|
||||
stmt.run('docker_janitor_gb', '5');
|
||||
stmt.run('global_logs_refresh', '5');
|
||||
stmt.run('developer_mode', '0');
|
||||
stmt.run('metrics_retention_hours', '24');
|
||||
stmt.run('log_retention_days', '30');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
|
||||
@@ -244,6 +251,17 @@ export class DatabaseService {
|
||||
stmt.run(key, value);
|
||||
}
|
||||
|
||||
// --- System State (operational/runtime values - not user-defined config) ---
|
||||
|
||||
public getSystemState(key: string): string | null {
|
||||
const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
public setSystemState(key: string, value: string): void {
|
||||
this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
// --- Stack Alerts ---
|
||||
|
||||
public getStackAlerts(stackName?: string): StackAlert[] {
|
||||
@@ -292,9 +310,9 @@ export class DatabaseService {
|
||||
}));
|
||||
}
|
||||
|
||||
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): void {
|
||||
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
|
||||
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
|
||||
stmt.run(notification.level, notification.message, notification.timestamp);
|
||||
const result = stmt.run(notification.level, notification.message, notification.timestamp);
|
||||
|
||||
this.db.exec(`
|
||||
DELETE FROM notification_history
|
||||
@@ -302,6 +320,14 @@ export class DatabaseService {
|
||||
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
|
||||
)
|
||||
`);
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
level: notification.level,
|
||||
message: notification.message,
|
||||
timestamp: notification.timestamp,
|
||||
is_read: false,
|
||||
};
|
||||
}
|
||||
|
||||
public markAllNotificationsRead(): void {
|
||||
@@ -353,6 +379,11 @@ export class DatabaseService {
|
||||
stmt.run(cutoff);
|
||||
}
|
||||
|
||||
public cleanupOldNotifications(daysToKeep = 30): void {
|
||||
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
|
||||
this.db.prepare('DELETE FROM notification_history WHERE timestamp < ?').run(cutoff);
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
public getNodes(): Node[] {
|
||||
|
||||
@@ -11,6 +11,32 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
const execAsync = promisify(exec);
|
||||
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
||||
|
||||
export interface ClassifiedImage {
|
||||
Id: string;
|
||||
RepoTags: string[];
|
||||
Size: number;
|
||||
Containers: number;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged' | 'unused';
|
||||
}
|
||||
|
||||
export interface ClassifiedVolume {
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Mountpoint: string;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged';
|
||||
}
|
||||
|
||||
export interface ClassifiedNetwork {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Scope: string;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged' | 'system';
|
||||
}
|
||||
|
||||
class DockerController {
|
||||
private docker: Docker;
|
||||
private nodeId: number;
|
||||
@@ -65,7 +91,7 @@ class DockerController {
|
||||
const calculateReclaimableVolumes = (items: any[]) => {
|
||||
if (!items || !Array.isArray(items)) return 0;
|
||||
return items.filter(i => i.UsageData?.RefCount === 0).reduce((acc, item) => {
|
||||
let size = item.UsageData?.Size || 0;
|
||||
const size = item.UsageData?.Size || 0;
|
||||
return acc + size;
|
||||
}, 0);
|
||||
};
|
||||
@@ -112,6 +138,172 @@ class DockerController {
|
||||
return this.validateApiData<any[]>(data);
|
||||
}
|
||||
|
||||
public async getClassifiedResources(knownStackNames: string[]): Promise<{
|
||||
images: ClassifiedImage[];
|
||||
volumes: ClassifiedVolume[];
|
||||
networks: ClassifiedNetwork[];
|
||||
}> {
|
||||
const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
|
||||
const knownSet = new Set(knownStackNames);
|
||||
|
||||
const [rawImages, rawVolumeData, rawNetworks, allContainers] = await Promise.all([
|
||||
this.docker.listImages({ all: false }),
|
||||
this.docker.listVolumes(),
|
||||
this.docker.listNetworks(),
|
||||
this.docker.listContainers({ all: true }),
|
||||
]);
|
||||
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
|
||||
// Build imageId → project mapping from container labels
|
||||
const imageToProject = new Map<string, string>();
|
||||
for (const c of allContainers as any[]) {
|
||||
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
|
||||
if (project && c.ImageID) imageToProject.set(c.ImageID, project);
|
||||
}
|
||||
|
||||
const images: ClassifiedImage[] = this.validateApiData<any[]>(rawImages).map((img: any) => {
|
||||
const project = imageToProject.get(img.Id) ?? null;
|
||||
const managedStatus: ClassifiedImage['managedStatus'] =
|
||||
img.Containers === 0 ? 'unused' :
|
||||
project && knownSet.has(project) ? 'managed' : 'unmanaged';
|
||||
return {
|
||||
Id: img.Id,
|
||||
RepoTags: img.RepoTags ?? [],
|
||||
Size: img.Size ?? 0,
|
||||
Containers: img.Containers ?? 0,
|
||||
managedBy: managedStatus === 'managed' ? project : null,
|
||||
managedStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => {
|
||||
const project: string | undefined = vol.Labels?.['com.docker.compose.project'];
|
||||
const managedStatus: ClassifiedVolume['managedStatus'] =
|
||||
project && knownSet.has(project) ? 'managed' : 'unmanaged';
|
||||
return {
|
||||
Name: vol.Name,
|
||||
Driver: vol.Driver,
|
||||
Mountpoint: vol.Mountpoint,
|
||||
managedBy: managedStatus === 'managed' ? project! : null,
|
||||
managedStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const networks: ClassifiedNetwork[] = this.validateApiData<any[]>(rawNetworks).map((net: any) => {
|
||||
if (SYSTEM_NETWORKS.has(net.Name)) {
|
||||
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const };
|
||||
}
|
||||
const project: string | undefined = net.Labels?.['com.docker.compose.project'];
|
||||
const managedStatus: ClassifiedNetwork['managedStatus'] =
|
||||
project && knownSet.has(project) ? 'managed' : 'unmanaged';
|
||||
return {
|
||||
Id: net.Id,
|
||||
Name: net.Name,
|
||||
Driver: net.Driver,
|
||||
Scope: net.Scope,
|
||||
managedBy: managedStatus === 'managed' ? project! : null,
|
||||
managedStatus,
|
||||
};
|
||||
});
|
||||
|
||||
return { images, volumes, networks };
|
||||
}
|
||||
|
||||
public async pruneManagedOnly(
|
||||
target: 'images' | 'volumes' | 'networks',
|
||||
knownStackNames: string[]
|
||||
): Promise<{ success: boolean; reclaimedBytes: number }> {
|
||||
const knownSet = new Set(knownStackNames);
|
||||
let reclaimedBytes = 0;
|
||||
|
||||
if (target === 'volumes') {
|
||||
const rawVolumeData = await this.docker.listVolumes();
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
const prunable = rawVolumes.filter((v: any) => {
|
||||
const project: string | undefined = v.Labels?.['com.docker.compose.project'];
|
||||
return project && knownSet.has(project) && (v.UsageData?.RefCount ?? 1) === 0;
|
||||
});
|
||||
for (const vol of prunable) {
|
||||
try {
|
||||
await this.docker.getVolume(vol.Name).remove({ force: true });
|
||||
reclaimedBytes += vol.UsageData?.Size ?? 0;
|
||||
} catch (e) {
|
||||
console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e);
|
||||
}
|
||||
}
|
||||
} else if (target === 'networks') {
|
||||
const rawNetworks = await this.docker.listNetworks();
|
||||
const prunable = (rawNetworks as any[]).filter((n: any) => {
|
||||
const project: string | undefined = n.Labels?.['com.docker.compose.project'];
|
||||
return project && knownSet.has(project);
|
||||
});
|
||||
for (const net of prunable) {
|
||||
try {
|
||||
await this.docker.getNetwork(net.Id).remove({ force: true });
|
||||
} catch (e) {
|
||||
console.error(`[pruneManagedOnly] Failed to remove network ${net.Name}:`, e);
|
||||
}
|
||||
}
|
||||
} else if (target === 'images') {
|
||||
const allContainers = await this.docker.listContainers({ all: true });
|
||||
const unmanagedImageIds = new Set<string>();
|
||||
for (const c of allContainers as any[]) {
|
||||
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
|
||||
if (!project || !knownSet.has(project)) unmanagedImageIds.add(c.ImageID);
|
||||
}
|
||||
const rawImages = await this.docker.listImages({ all: false });
|
||||
const prunable = (rawImages as any[]).filter((img: any) =>
|
||||
img.Containers === 0 && !unmanagedImageIds.has(img.Id)
|
||||
);
|
||||
for (const img of prunable) {
|
||||
try {
|
||||
await this.docker.getImage(img.Id).remove({ force: true });
|
||||
reclaimedBytes += img.Size ?? 0;
|
||||
} catch (e) {
|
||||
console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, reclaimedBytes };
|
||||
}
|
||||
|
||||
public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
|
||||
reclaimableImages: number;
|
||||
reclaimableContainers: number;
|
||||
reclaimableVolumes: number;
|
||||
managedImageBytes: number;
|
||||
unmanagedImageBytes: number;
|
||||
managedVolumeBytes: number;
|
||||
unmanagedVolumeBytes: number;
|
||||
}> {
|
||||
const [base, classified] = await Promise.all([
|
||||
this.getDiskUsage(),
|
||||
this.getClassifiedResources(knownStackNames),
|
||||
]);
|
||||
|
||||
const managedImageBytes = classified.images
|
||||
.filter(i => i.managedStatus === 'managed')
|
||||
.reduce((acc, i) => acc + i.Size, 0);
|
||||
const unmanagedImageBytes = classified.images
|
||||
.filter(i => i.managedStatus === 'unmanaged')
|
||||
.reduce((acc, i) => acc + i.Size, 0);
|
||||
|
||||
const rawVolumeData = await this.docker.listVolumes();
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
const knownSet = new Set(knownStackNames);
|
||||
|
||||
const managedVolumeBytes = rawVolumes
|
||||
.filter((v: any) => knownSet.has(v.Labels?.['com.docker.compose.project'] ?? ''))
|
||||
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
|
||||
const unmanagedVolumeBytes = rawVolumes
|
||||
.filter((v: any) => !knownSet.has(v.Labels?.['com.docker.compose.project'] ?? ''))
|
||||
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
|
||||
|
||||
return { ...base, managedImageBytes, unmanagedImageBytes, managedVolumeBytes, unmanagedVolumeBytes };
|
||||
}
|
||||
|
||||
public async removeImage(id: string) {
|
||||
const image = this.docker.getImage(id);
|
||||
await image.remove({ force: true });
|
||||
@@ -425,15 +617,27 @@ class DockerController {
|
||||
const stats = await container.stats({ stream: true });
|
||||
|
||||
stats.on('data', (chunk: Buffer) => {
|
||||
ws.send(chunk.toString());
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(chunk.toString());
|
||||
}
|
||||
});
|
||||
|
||||
stats.on('error', (err: Error) => {
|
||||
ws.send(JSON.stringify({ error: err.message }));
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
stats.on('end', () => {
|
||||
ws.send(JSON.stringify({ end: true }));
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ end: true }));
|
||||
}
|
||||
});
|
||||
|
||||
// Destroy the Docker stats stream when the WebSocket closes to prevent
|
||||
// orphaned streams polling the daemon after client disconnect.
|
||||
ws.on('close', () => {
|
||||
try { (stats as any).destroy(); } catch { /* stream already ended */ }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,7 +743,7 @@ class DockerController {
|
||||
}
|
||||
}
|
||||
|
||||
export let globalDockerNetwork = { rxSec: 0, txSec: 0 };
|
||||
export const globalDockerNetwork = { rxSec: 0, txSec: 0 };
|
||||
let lastNetSum = { rx: 0, tx: 0, timestamp: Date.now() };
|
||||
|
||||
export const updateGlobalDockerNetwork = async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
|
||||
@@ -16,12 +16,19 @@ export class HostTerminalService {
|
||||
static spawnTerminal(ws: WebSocket, targetDirectory: string) {
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell();
|
||||
|
||||
// Strip sensitive backend secrets from the PTY environment so they are not
|
||||
// visible to the console user via `env` / `printenv`.
|
||||
const SENSITIVE_KEYS = ['JWT_SECRET', 'AUTH_PASSWORD', 'AUTH_PASSWORD_HASH', 'DATABASE_URL'];
|
||||
const safeEnv = Object.fromEntries(
|
||||
Object.entries(process.env as Record<string, string>).filter(([k]) => !SENSITIVE_KEYS.includes(k))
|
||||
);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
cwd: targetDirectory,
|
||||
env: process.env as Record<string, string>,
|
||||
env: safeEnv,
|
||||
});
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
|
||||
@@ -109,7 +109,7 @@ async function getAuthToken(registry: string, repo: string): Promise<string | nu
|
||||
// ─── Remote digest lookup ─────────────────────────────────────────────────────
|
||||
|
||||
// Include manifest list types so we get the fat-manifest digest for multi-arch
|
||||
// images — this matches what Docker stores in local RepoDigests.
|
||||
// images - this matches what Docker stores in local RepoDigests.
|
||||
const MANIFEST_ACCEPT = [
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
@@ -145,7 +145,7 @@ export class ImageUpdateService {
|
||||
private static readonly MANUAL_COOLDOWN_MS = 10 * 60 * 1000; // 10 min between manual triggers
|
||||
private static readonly INTER_IMAGE_DELAY_MS = 300; // be polite to registries
|
||||
|
||||
private constructor() {}
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): ImageUpdateService {
|
||||
if (!ImageUpdateService.instance) {
|
||||
@@ -195,7 +195,7 @@ export class ImageUpdateService {
|
||||
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
// Only check local nodes — remote nodes run their own instance
|
||||
// Only check local nodes - remote nodes run their own instance
|
||||
for (const node of db.getNodes()) {
|
||||
if (node.type !== 'local' || !node.id) continue;
|
||||
try {
|
||||
@@ -282,7 +282,7 @@ export class ImageUpdateService {
|
||||
if (!localDigest) return false; // Locally built or never pulled with a digest
|
||||
|
||||
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag);
|
||||
if (!remoteDigest) return false; // Registry unreachable — no false positives
|
||||
if (!remoteDigest) return false; // Registry unreachable - no false positives
|
||||
|
||||
const hasUpdate = localDigest !== remoteDigest;
|
||||
console.log(
|
||||
|
||||
@@ -51,7 +51,7 @@ export class LogFormatter {
|
||||
// Fast JSON Check (Starts with { and ends with })
|
||||
if (trimmedLine.startsWith('{') && trimmedLine.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedLine);
|
||||
JSON.parse(trimmedLine);
|
||||
// If valid, lightly highlight it (e.g., colorize string representation slightly)
|
||||
// We re-stringify it to ensure it's on one line, but maybe just highlight properties
|
||||
processedLine = LogFormatter.highlightJson(trimmedLine);
|
||||
|
||||
@@ -164,7 +164,7 @@ export class MonitorService {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// RECLAIMABLE might be something like "1.2GB" or "400MB" Let's parse it manually or just use raw sizes from docker api. Actually docker system df JSON format gives Reclaimable field as string e.g. "1.196GB" (or "0B").
|
||||
let reclaimStr = parsed.Reclaimable;
|
||||
const reclaimStr = parsed.Reclaimable;
|
||||
if (reclaimStr) {
|
||||
// Extract the number and the unit. e.g "1.196GB" (92%) -> 1.196
|
||||
const match = reclaimStr.match(/^([0-9.]+)([a-zA-Z]+)/);
|
||||
@@ -187,13 +187,14 @@ export class MonitorService {
|
||||
// Only trigger once every while? To avoid spamming, we just check if it's over limit
|
||||
// Let's ensure we only spam once per limit breach. We can use a local static variable.
|
||||
const LAST_JANITOR_ALERT_KEY = 'last_janitor_alert_timestamp';
|
||||
const lastAlert = parseInt(settings[LAST_JANITOR_ALERT_KEY] || '0', 10);
|
||||
const lastAlertRaw = DatabaseService.getInstance().getSystemState(LAST_JANITOR_ALERT_KEY);
|
||||
const lastAlert = parseInt(lastAlertRaw || '0', 10);
|
||||
const janitorCooldown = 24 * 60 * 60 * 1000; // 24 hours cooldown for janitor
|
||||
|
||||
if (reclaimGb >= janitorLimitGb) {
|
||||
if (Date.now() - lastAlert > janitorCooldown) {
|
||||
await notifier.dispatchAlert('info', `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`);
|
||||
DatabaseService.getInstance().updateGlobalSetting(LAST_JANITOR_ALERT_KEY, Date.now().toString());
|
||||
DatabaseService.getInstance().setSystemState(LAST_JANITOR_ALERT_KEY, Date.now().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,8 +299,14 @@ export class MonitorService {
|
||||
}
|
||||
|
||||
try {
|
||||
db.cleanupOldMetrics(24);
|
||||
} catch (e) { }
|
||||
const settings = db.getGlobalSettings();
|
||||
const retentionHours = parseInt(settings['metrics_retention_hours'] || '24', 10);
|
||||
db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours);
|
||||
const retentionDays = parseInt(settings['log_retention_days'] || '30', 10);
|
||||
db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
|
||||
} catch (e) {
|
||||
console.error('MonitorService: failed to cleanup old data', e);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateCondition(actual: number, operator: string, threshold: number): boolean {
|
||||
|
||||
@@ -157,14 +157,14 @@ export class NodeRegistry {
|
||||
const headers = { Authorization: `Bearer ${node.api_token}` };
|
||||
|
||||
try {
|
||||
// Step 1: Verify auth. A 401 here means wrong token — surface that clearly.
|
||||
// Step 1: Verify auth. A 401 here means wrong token - surface that clearly.
|
||||
const authRes = await axios.get(`${baseUrl}/api/auth/check`, { headers, timeout: 8000 });
|
||||
if (authRes.status !== 200) throw new Error(`Unexpected status ${authRes.status}`);
|
||||
|
||||
db.updateNodeStatus(node.id, 'online');
|
||||
|
||||
// Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing
|
||||
// endpoint doesn't fail the whole test — each field falls back to '-' gracefully.
|
||||
// endpoint doesn't fail the whole test - each field falls back to '-' gracefully.
|
||||
const [statsResult, sysResult, imagesResult] = await Promise.allSettled([
|
||||
axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }),
|
||||
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { DatabaseService, NotificationHistory } from './DatabaseService';
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
private dbService: DatabaseService;
|
||||
private broadcaster: ((notification: NotificationHistory) => void) | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.dbService = DatabaseService.getInstance();
|
||||
@@ -15,15 +16,25 @@ export class NotificationService {
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
/** Wire up the WebSocket push function after the WS server is initialised. */
|
||||
public setBroadcaster(fn: (notification: NotificationHistory) => void): void {
|
||||
this.broadcaster = fn;
|
||||
}
|
||||
|
||||
public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string) {
|
||||
// 1. Log to history
|
||||
this.dbService.addNotificationHistory({
|
||||
// 1. Log to history and get the full inserted record (with id)
|
||||
const notification = this.dbService.addNotificationHistory({
|
||||
level,
|
||||
message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 2. Fetch enabled agents
|
||||
// 2. Push to connected browser clients via WebSocket
|
||||
if (this.broadcaster) {
|
||||
this.broadcaster(notification);
|
||||
}
|
||||
|
||||
// 3. Fetch enabled agents
|
||||
const agents = this.dbService.getEnabledAgents();
|
||||
if (agents.length === 0) {
|
||||
console.log('No active notification agents found. Skipping external dispatch.');
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface Template {
|
||||
docs_url?: string;
|
||||
architectures?: string[];
|
||||
stars?: number;
|
||||
source?: string;
|
||||
repository?: {
|
||||
url: string;
|
||||
stackfile: string;
|
||||
@@ -40,11 +41,165 @@ export interface TemplatesResponse {
|
||||
templates: Template[];
|
||||
}
|
||||
|
||||
// Static category map for LSIO apps (the LSIO API does not expose category metadata).
|
||||
// Apps can belong to multiple categories. Unmapped apps fall back to ['Other'].
|
||||
const LSIO_CATEGORY_MAP: Record<string, string[]> = {
|
||||
// Media Servers
|
||||
'plex': ['Media'],
|
||||
'jellyfin': ['Media'],
|
||||
'emby': ['Media'],
|
||||
'navidrome': ['Media'],
|
||||
'airsonic-advanced': ['Media'],
|
||||
'airsonic': ['Media'],
|
||||
'beets': ['Media'],
|
||||
'calibre': ['Media', 'Books'],
|
||||
'calibre-web': ['Media', 'Books'],
|
||||
'kavita': ['Media', 'Books'],
|
||||
'komga': ['Media', 'Books'],
|
||||
'mylar3': ['Media', 'Books'],
|
||||
'ubooquity': ['Media', 'Books'],
|
||||
'lazylibrarian': ['Media', 'Books'],
|
||||
'cops': ['Media', 'Books'],
|
||||
'photoprism': ['Media', 'Productivity'],
|
||||
'immich': ['Media', 'Productivity'],
|
||||
'piwigo': ['Media'],
|
||||
'lychee': ['Media'],
|
||||
'davos': ['Media'],
|
||||
'mstream': ['Media'],
|
||||
'koel': ['Media'],
|
||||
'grocy': ['Productivity'],
|
||||
// *arr Automation suite
|
||||
'sonarr': ['Automation', 'Media'],
|
||||
'radarr': ['Automation', 'Media'],
|
||||
'lidarr': ['Automation', 'Media'],
|
||||
'readarr': ['Automation', 'Media'],
|
||||
'bazarr': ['Automation', 'Media'],
|
||||
'whisparr': ['Automation', 'Media'],
|
||||
'prowlarr': ['Automation'],
|
||||
'jackett': ['Automation'],
|
||||
'nzbhydra2': ['Automation'],
|
||||
'overseerr': ['Automation', 'Media'],
|
||||
'ombi': ['Automation', 'Media'],
|
||||
'requestrr': ['Automation'],
|
||||
'tautulli': ['Monitoring', 'Media'],
|
||||
'organizr': ['Automation'],
|
||||
'recyclarr': ['Automation'],
|
||||
'notifiarr': ['Automation'],
|
||||
'unpackerr': ['Automation'],
|
||||
// Dashboards / Homepages
|
||||
'heimdall': ['Utilities'],
|
||||
'homer': ['Utilities'],
|
||||
'dasherr': ['Utilities'],
|
||||
'flame': ['Utilities'],
|
||||
'homarr': ['Utilities'],
|
||||
'dashdot': ['Monitoring'],
|
||||
// Downloaders
|
||||
'qbittorrent': ['Downloaders'],
|
||||
'transmission': ['Downloaders'],
|
||||
'deluge': ['Downloaders'],
|
||||
'sabnzbd': ['Downloaders'],
|
||||
'nzbget': ['Downloaders'],
|
||||
'aria2': ['Downloaders'],
|
||||
'jdownloader-2': ['Downloaders'],
|
||||
'pyload-ng': ['Downloaders'],
|
||||
'rutorrent': ['Downloaders'],
|
||||
'flood': ['Downloaders'],
|
||||
'medusa': ['Automation', 'Downloaders'],
|
||||
'sickchill': ['Automation', 'Downloaders'],
|
||||
// Monitoring
|
||||
'grafana': ['Monitoring'],
|
||||
'netdata': ['Monitoring'],
|
||||
'uptime-kuma': ['Monitoring'],
|
||||
'statping-ng': ['Monitoring'],
|
||||
'healthchecks': ['Monitoring'],
|
||||
'smokeping': ['Monitoring'],
|
||||
'librespeed': ['Monitoring'],
|
||||
'speedtest-tracker': ['Monitoring'],
|
||||
'scrutiny': ['Monitoring'],
|
||||
'prometheus': ['Monitoring'],
|
||||
'loki': ['Monitoring'],
|
||||
'influxdb': ['Monitoring'],
|
||||
// Networking / Reverse Proxy
|
||||
'nginx': ['Networking'],
|
||||
'swag': ['Networking'],
|
||||
'letsencrypt': ['Networking'],
|
||||
'ddclient': ['Networking'],
|
||||
'duckdns': ['Networking'],
|
||||
'wireguard': ['Networking', 'Security'],
|
||||
'openvpn-as': ['Networking', 'Security'],
|
||||
'netbootxyz': ['Networking'],
|
||||
'pihole': ['Networking'],
|
||||
'unbound': ['Networking'],
|
||||
'adguardhome': ['Networking'],
|
||||
'cloudflared': ['Networking'],
|
||||
'haproxy': ['Networking'],
|
||||
'traefik': ['Networking'],
|
||||
'nginx-proxy-manager': ['Networking'],
|
||||
'fail2ban': ['Networking', 'Security'],
|
||||
// Security / Auth
|
||||
'vaultwarden': ['Security'],
|
||||
'authelia': ['Security'],
|
||||
'lldap': ['Security'],
|
||||
'endlessh': ['Security'],
|
||||
'sshwifty': ['Security'],
|
||||
// Development / CI
|
||||
'gitea': ['Development'],
|
||||
'code-server': ['Development'],
|
||||
'drone': ['Development'],
|
||||
'drone-runner-docker': ['Development'],
|
||||
'registry': ['Development'],
|
||||
'jenkins': ['Development'],
|
||||
'gogs': ['Development'],
|
||||
'woodpecker-ci': ['Development'],
|
||||
'gitlab': ['Development'],
|
||||
'fleet': ['Development'],
|
||||
// Productivity / Self-hosted SaaS
|
||||
'nextcloud': ['Productivity'],
|
||||
'bookstack': ['Productivity', 'Documentation'],
|
||||
'dokuwiki': ['Productivity', 'Documentation'],
|
||||
'wikijs': ['Productivity', 'Documentation'],
|
||||
'paperless-ngx': ['Productivity'],
|
||||
'mealie': ['Productivity'],
|
||||
'freshrss': ['Productivity'],
|
||||
'miniflux': ['Productivity'],
|
||||
'wallabag': ['Productivity'],
|
||||
'trilium': ['Productivity'],
|
||||
'hedgedoc': ['Productivity'],
|
||||
'etherpad': ['Productivity'],
|
||||
'monica': ['Productivity'],
|
||||
'firefly-iii': ['Productivity'],
|
||||
'shlink': ['Productivity'],
|
||||
'yourls': ['Productivity'],
|
||||
'stirling-pdf': ['Productivity'],
|
||||
'syncthing': ['Productivity'],
|
||||
'tandoor': ['Productivity'],
|
||||
'linkwarden': ['Productivity'],
|
||||
'vikunja': ['Productivity'],
|
||||
// Utilities / Backup
|
||||
'duplicati': ['Utilities'],
|
||||
'restic': ['Utilities'],
|
||||
'rsnapshot': ['Utilities'],
|
||||
'mysql-workbench': ['Utilities'],
|
||||
'sqlitebrowser': ['Utilities'],
|
||||
'filezilla': ['Utilities'],
|
||||
'rdesktop': ['Utilities'],
|
||||
'webtop': ['Utilities'],
|
||||
};
|
||||
|
||||
function getCategoriesForApp(name: string): string[] {
|
||||
return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other'];
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
private cachedTemplates: Template[] = [];
|
||||
private lastFetchTime: number = 0;
|
||||
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
public clearCache(): void {
|
||||
this.cachedTemplates = [];
|
||||
this.lastFetchTime = 0;
|
||||
}
|
||||
|
||||
public async getTemplates(): Promise<Template[]> {
|
||||
const now = Date.now();
|
||||
if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) {
|
||||
@@ -73,6 +228,8 @@ export class TemplateService {
|
||||
docs_url: app.readme,
|
||||
architectures: app.arch,
|
||||
stars: app.stars,
|
||||
categories: getCategoriesForApp(app.name),
|
||||
source: 'linuxserver',
|
||||
// Map configs if available, otherwise default to empty arrays
|
||||
ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`),
|
||||
volumes: (app.config?.volumes || []).map((v: any) => {
|
||||
@@ -91,7 +248,10 @@ export class TemplateService {
|
||||
});
|
||||
} else {
|
||||
// Legacy Portainer v2 Format (Fallback for custom registries)
|
||||
this.cachedTemplates = (response.data.templates || []).filter((t: Template) => !!t.image && t.type === 1);
|
||||
// The Portainer v2 spec includes a native `categories` field - pass it through.
|
||||
this.cachedTemplates = (response.data.templates || [])
|
||||
.filter((t: Template) => !!t.image && t.type === 1)
|
||||
.map((t: Template) => ({ ...t, source: 'custom' }));
|
||||
}
|
||||
|
||||
this.lastFetchTime = now;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Stack name must only contain URL-safe characters with no path separators.
|
||||
* Prevents path-traversal attacks when the name is used to build filesystem paths.
|
||||
*/
|
||||
export const isValidStackName = (name: string): boolean =>
|
||||
/^[a-zA-Z0-9_-]+$/.test(name);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function isValidRemoteUrl(
|
||||
raw: string,
|
||||
): { valid: true; url: URL } | { valid: false; reason: string } {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:3000)',
|
||||
};
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return { valid: false, reason: 'API URL must use http:// or https://' };
|
||||
}
|
||||
// Node.js URL API preserves brackets for IPv6: new URL('http://[::1]').hostname === '[::1]'
|
||||
const loopback = /^(localhost|127(\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i;
|
||||
if (loopback.test(url.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'API URL cannot point to localhost or loopback — use the actual host address',
|
||||
};
|
||||
}
|
||||
return { valid: true, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a resolved file path stays within a given base directory.
|
||||
* Returns true if the path is safe, false if it escapes the base.
|
||||
*/
|
||||
export function isPathWithinBase(resolvedPath: string, baseDir: string): boolean {
|
||||
const normalizedBase = path.resolve(baseDir);
|
||||
const normalizedPath = path.resolve(resolvedPath);
|
||||
return (
|
||||
normalizedPath === normalizedBase ||
|
||||
normalizedPath.startsWith(normalizedBase + path.sep)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
// 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.
|
||||
pool: 'forks',
|
||||
// Timeout generous for DB init and HTTP calls.
|
||||
testTimeout: 15_000,
|
||||
// Sequential within each file (DB state is shared per file).
|
||||
sequence: { concurrent: false },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Resolve the data directory, mirroring DatabaseService.ts logic.
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
|
||||
# If running as root (the default Docker container start), fix volume ownership,
|
||||
# fix Docker socket group access, then drop privileges before executing the app.
|
||||
#
|
||||
# This is the industry-standard pattern used by the official PostgreSQL, Redis,
|
||||
# and MariaDB Docker images, and by Docker management tools like Portainer and
|
||||
# Dockge that also require access to /var/run/docker.sock as a non-root user.
|
||||
#
|
||||
# The UID guard also ensures compatibility with strict environments like
|
||||
# Kubernetes (runAsNonRoot: true) or OpenShift, where the container is forced to
|
||||
# run as a random high UID. In that case both blocks are skipped and the app
|
||||
# exec's directly without crashing.
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
|
||||
# 1. Fix data volume ownership.
|
||||
# Handles host volumes previously created by root or a different UID, which
|
||||
# would cause SQLITE_READONLY errors when the non-root sencho user starts.
|
||||
# Only touches files with wrong user OR group (efficient on large dirs).
|
||||
mkdir -p "$DATA_DIR"
|
||||
find "$DATA_DIR" \( \! -user sencho -o \! -group sencho \) \
|
||||
-exec chown sencho:sencho '{}' +
|
||||
echo "[entrypoint] Data directory ownership ensured: $DATA_DIR"
|
||||
|
||||
# 2. Fix Docker socket group access.
|
||||
# The Docker socket on the host is owned by the host's docker group, whose
|
||||
# GID varies by Linux distribution and does not match any group inside the
|
||||
# container by default.
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
DOCKER_SOCK_MODE=$(stat -c '%a' /var/run/docker.sock)
|
||||
echo "[entrypoint] Docker socket found: GID=$DOCKER_SOCK_GID mode=$DOCKER_SOCK_MODE"
|
||||
|
||||
if [ "$DOCKER_SOCK_GID" = "0" ]; then
|
||||
echo "[entrypoint] WARNING: Docker socket is root:root -- adding sencho to root group"
|
||||
addgroup sencho root 2>/dev/null || true
|
||||
else
|
||||
if ! getent group "$DOCKER_SOCK_GID" > /dev/null 2>&1; then
|
||||
addgroup -S -g "$DOCKER_SOCK_GID" docker-host
|
||||
echo "[entrypoint] Created group docker-host with GID $DOCKER_SOCK_GID"
|
||||
fi
|
||||
DOCKER_GROUP=$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1)
|
||||
addgroup sencho "$DOCKER_GROUP" 2>/dev/null || true
|
||||
echo "[entrypoint] Added sencho to group '$DOCKER_GROUP' (GID $DOCKER_SOCK_GID)"
|
||||
fi
|
||||
else
|
||||
echo "[entrypoint] WARNING: /var/run/docker.sock not found -- Docker features unavailable"
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Dropping privileges to sencho (uid=$(id -u sencho))"
|
||||
|
||||
# 3. Drop privileges.
|
||||
# Replace this shell with su-exec so Node becomes PID 1 and receives
|
||||
# SIGTERM/SIGINT directly. su-exec calls getgrouplist() for named users,
|
||||
# so all supplementary groups added above are inherited by the process.
|
||||
exec su-exec sencho "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "mint",
|
||||
"name": "Sencho",
|
||||
"colors": {
|
||||
"primary": "#0F172A",
|
||||
"light": "#3B82F6",
|
||||
"dark": "#0F172A"
|
||||
},
|
||||
"logo": {
|
||||
"light": "/images/logo/logo-light.svg",
|
||||
"dark": "/images/logo/logo-dark.svg",
|
||||
"href": "/"
|
||||
},
|
||||
"favicon": "/images/logo/favicon.ico",
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"getting-started/introduction",
|
||||
"getting-started/quickstart",
|
||||
"getting-started/configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Features",
|
||||
"pages": [
|
||||
"features/overview",
|
||||
"features/dashboard",
|
||||
"features/stack-management",
|
||||
"features/editor",
|
||||
"features/resources",
|
||||
"features/app-store",
|
||||
"features/global-observability",
|
||||
"features/host-console",
|
||||
"features/multi-node",
|
||||
"features/alerts-notifications"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"reference/settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Operations",
|
||||
"pages": [
|
||||
"operations/troubleshooting",
|
||||
"operations/backup"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Alerts & Notifications
|
||||
description: Set threshold-based alerts on container metrics and route them to Discord, Slack, or any webhook.
|
||||
---
|
||||
|
||||
Sencho can watch your containers for resource anomalies and notify you when thresholds are breached. Alerts are defined per stack, and notifications are delivered through external agents you configure.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/alerts-notifications/notifications-settings.png" alt="Notifications & Alerts settings showing Discord, Slack, and Webhook tabs" />
|
||||
</Frame>
|
||||
|
||||
## How alerts work
|
||||
|
||||
Sencho's monitoring service samples container metrics every minute and evaluates all defined alert rules. When a rule's condition holds true for the configured duration, a notification is dispatched. A cooldown period prevents the same alert from firing repeatedly.
|
||||
|
||||
## Setting up a notification agent
|
||||
|
||||
At least one agent must be enabled before alerts can be delivered. Go to **Settings → Notifications**.
|
||||
|
||||
### Discord
|
||||
|
||||
1. In Discord, go to your server's **Settings → Integrations → Webhooks**
|
||||
2. Click **New Webhook**, choose a channel, and copy the webhook URL
|
||||
3. In Sencho, open **Settings → Notifications → Discord**, paste the URL, enable the toggle, and click **Save**
|
||||
4. Click **Test** to send a test message
|
||||
|
||||
### Slack
|
||||
|
||||
1. In Slack, go to **api.slack.com/apps**, create an app, and add the **Incoming Webhooks** feature
|
||||
2. Activate it and copy the generated webhook URL for your chosen channel
|
||||
3. In Sencho, open **Settings → Notifications → Slack**, paste the URL, enable the toggle, and click **Save**
|
||||
|
||||
### Generic Webhook
|
||||
|
||||
Any HTTP endpoint that accepts a POST with a JSON body can receive Sencho alerts. Go to **Settings → Notifications → Webhook**, enter the URL, and click **Save**.
|
||||
|
||||
The payload format is:
|
||||
```json
|
||||
{
|
||||
"level": "warning",
|
||||
"message": "cpu_percent exceeded 90% for 5 minutes on stack my-app",
|
||||
"timestamp": "2026-03-22T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Creating stack alerts
|
||||
|
||||
Stack alerts are configured per-stack. Right-click a stack in the sidebar (or click the **⋮** button) and select **Alerts**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-management/stack-context-menu.png" alt="Stack context menu showing the Alerts option" />
|
||||
</Frame>
|
||||
|
||||
The alerts sheet shows existing rules for the stack and a form to create new ones.
|
||||
|
||||
### Alert fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Metric** | The container metric to watch |
|
||||
| **Operator** | Comparison operator: `>`, `>=`, `<`, `<=`, `==` |
|
||||
| **Threshold** | The value to compare against |
|
||||
| **Duration (minutes)** | How long the condition must hold before firing |
|
||||
| **Cooldown (minutes)** | Minimum time between repeated notifications for this rule |
|
||||
|
||||
### Available metrics
|
||||
|
||||
| Metric | Unit | Description |
|
||||
|--------|------|-------------|
|
||||
| `cpu_percent` | % | CPU usage relative to total host cores |
|
||||
| `memory_mb` | MB | RSS memory used by the container |
|
||||
| `memory_percent` | % | Memory used as a fraction of the host total |
|
||||
| `net_rx` | bytes/s | Inbound network rate |
|
||||
| `net_tx` | bytes/s | Outbound network rate |
|
||||
| `restart_count` | count | Number of times the container has restarted |
|
||||
|
||||
### Example: alert on high CPU
|
||||
|
||||
To alert when any container in a stack uses more than 80% CPU for over 5 consecutive minutes, with a 30-minute cooldown:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Metric | `cpu_percent` |
|
||||
| Operator | `>` |
|
||||
| Threshold | `80` |
|
||||
| Duration | `5` |
|
||||
| Cooldown | `30` |
|
||||
|
||||
## Notification history
|
||||
|
||||
All dispatched notifications appear in the **notification bell** (top-right of the nav bar). Click it to see recent alerts with their level, message, and timestamp. Mark all as read or clear individual entries from there.
|
||||
|
||||
<Note>
|
||||
Notifications only reach external agents (Discord, Slack, Webhook) if at least one agent is enabled. Dashboard notifications appear regardless.
|
||||
</Note>
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-store/app-store-overview.png" alt="App Store showing a grid of application templates with category filters" />
|
||||
</Frame>
|
||||
|
||||
## Browsing templates
|
||||
|
||||
Templates are loaded from a remote registry (configurable in **Settings → App Store**). The default registry provides 190+ self-hosted application templates.
|
||||
|
||||
**Search:** Type in the search bar to filter templates by name or description in real-time.
|
||||
|
||||
**Categories:** Click any category pill to narrow the list:
|
||||
`Automation` · `Books` · `Development` · `Documentation` · `Downloaders` · `Media` · `Monitoring` · `Networking` · `Other` · `Product`
|
||||
|
||||
Each template card shows:
|
||||
- Application logo
|
||||
- Name and short description
|
||||
- Category tags
|
||||
- Links to the GitHub repo and documentation (where available)
|
||||
|
||||
## Deploying a template
|
||||
|
||||
Click any template card to open the **deployment sheet** on the right side of the screen. Fill in the fields and click **Deploy**.
|
||||
|
||||
### 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).
|
||||
|
||||
### Environment variables
|
||||
|
||||
Each template declares the variables it needs. Sencho pre-fills sensible defaults where available (e.g. `PUID=1000`, `PGID=1000`, `TZ` from your browser locale).
|
||||
|
||||
Edit any value before deploying. You can also click **Custom Env** to add arbitrary key-value pairs not defined by the template.
|
||||
|
||||
### Volumes
|
||||
|
||||
For each container mount point, enter the **host path** where that data should be stored. Suggested defaults (e.g. `./config`, `./data`) are shown as placeholders.
|
||||
|
||||
<Note>
|
||||
Relative paths like `./config` are resolved relative to the stack directory inside your `COMPOSE_DIR`. Absolute paths map directly to the host filesystem.
|
||||
</Note>
|
||||
|
||||
### 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.
|
||||
|
||||
### What happens on Deploy
|
||||
|
||||
1. Sencho creates a new stack directory in `COMPOSE_DIR`
|
||||
2. Writes the generated `compose.yaml` and `.env` file
|
||||
3. Runs `docker compose up -d`
|
||||
4. On success: switches you to the Editor view for that stack
|
||||
5. On failure: rolls back by running `docker compose down` and deleting the stack directory
|
||||
|
||||
A toast notification confirms success or shows the error message.
|
||||
|
||||
## Custom template registry
|
||||
|
||||
By default Sencho uses the LinuxServer.io template registry. To use your own:
|
||||
|
||||
1. Open **Settings → App Store**
|
||||
2. Enter your registry URL (must serve a JSON array of template objects in Portainer v2 format)
|
||||
3. Click **Save**
|
||||
|
||||
To force a refresh of the cached templates, click **Refresh Cache** in the same settings panel.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Dashboard
|
||||
description: Real-time system stats, historical metrics, and a quick-start converter for your host machine.
|
||||
---
|
||||
|
||||
The **Home** tab is the first thing you see after logging in. It shows a live snapshot of your host's health alongside container activity across all stacks.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing container stats, system stats, and historical charts" />
|
||||
</Frame>
|
||||
|
||||
## Container stats
|
||||
|
||||
The top row summarises container state at a glance:
|
||||
|
||||
| Card | What it shows |
|
||||
|------|---------------|
|
||||
| **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 |
|
||||
|
||||
**Managed** means the container belongs to a stack in your `COMPOSE_DIR`. **External** means it exists on the Docker host but was started outside Sencho (e.g. by another Compose project or `docker run`).
|
||||
|
||||
## System stats
|
||||
|
||||
The second row shows host-level resource usage, polled every few seconds:
|
||||
|
||||
| Card | What it shows |
|
||||
|------|---------------|
|
||||
| **Host CPU** | Current CPU usage percentage and core count |
|
||||
| **Host RAM** | Used / total memory in GB and percentage |
|
||||
| **Host Disk** | Used / total disk space for the primary mount point |
|
||||
|
||||
When any value exceeds configured thresholds (set in **Settings → System Limits**), the card changes colour as a visual warning.
|
||||
|
||||
## Historical metrics charts
|
||||
|
||||
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
|
||||
|
||||
The x-axis shows time labels. Hover over a data point to see the exact value at that moment.
|
||||
|
||||
<Note>
|
||||
Charts only show data from the moment Sencho started. If you just installed Sencho, they will be mostly empty until metrics accumulate.
|
||||
</Note>
|
||||
|
||||
## Convert `docker run` to Compose
|
||||
|
||||
The bottom panel provides a quick converter: paste any `docker run` command and Sencho transforms it into a valid `docker-compose.yaml` snippet.
|
||||
|
||||
```bash
|
||||
# Example input
|
||||
docker run -d --name myapp -p 8080:80 -e TZ=UTC -v /data:/app/data nginx:latest
|
||||
```
|
||||
|
||||
Click **Convert** and the output YAML appears ready to copy. You can then create a new stack and paste it into the editor.
|
||||
|
||||
This is useful when migrating existing containers to managed Compose stacks without having to write YAML by hand.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/editor/editor-overview.png" alt="Editor view showing container panel and Monaco editor" />
|
||||
</Frame>
|
||||
|
||||
## Compose file editor
|
||||
|
||||
The right panel shows your `compose.yaml` with full syntax highlighting. By default the editor is **read-only** to prevent accidental changes.
|
||||
|
||||
Click **Edit** to enter edit mode. Your changes are unsaved until you explicitly save them.
|
||||
|
||||
### Save options
|
||||
|
||||
| Button | What it does |
|
||||
|--------|--------------|
|
||||
| **Save** | Writes the file to disk. Does not restart any containers. |
|
||||
| **Save & Deploy** | Writes the file, then immediately runs `docker compose up -d`. Use this to apply compose changes in one step. |
|
||||
| **Discard** | Reverts the editor to the last saved version. Unsaved changes are lost. |
|
||||
|
||||
## Environment file editor
|
||||
|
||||
Click the **.env** tab to switch to the environment file editor. If your `compose.yaml` references multiple env files (via `env_file:`), a dropdown lets you select which file to edit.
|
||||
|
||||
The `.env` editor has the same save/discard controls as the compose editor. Changes take effect the next time the stack is deployed.
|
||||
|
||||
<Note>
|
||||
Sencho reads the `env_file:` paths from your `compose.yaml` to discover available env files. If no `env_file:` is declared, a default `.env` in the stack directory is used.
|
||||
</Note>
|
||||
|
||||
## Container panel
|
||||
|
||||
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)
|
||||
|
||||
### Container actions
|
||||
|
||||
Each container row has three action buttons:
|
||||
|
||||
| Button | What it does |
|
||||
|--------|--------------|
|
||||
| **Logs** (external link icon) | Opens a live log stream modal for that container |
|
||||
| **Terminal** (terminal icon) | Opens an interactive bash session inside the container |
|
||||
| **Copy ID** | Copies the container ID to the clipboard |
|
||||
|
||||
### Log viewer
|
||||
|
||||
The log viewer streams output from a single container in real-time using Server-Sent Events. Logs auto-scroll to the bottom as new lines arrive. Close the modal to stop the stream.
|
||||
|
||||
### Container terminal (exec)
|
||||
|
||||
The terminal modal gives you an interactive bash shell inside the running container — equivalent to `docker exec -it <id> bash`. It uses a full xterm.js emulator with color support and tab completion.
|
||||
|
||||
<Warning>
|
||||
The container terminal requires the container to have `bash` (or `sh`) installed. Minimal images (e.g. Alpine-based) may need `sh` instead.
|
||||
</Warning>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/global-observability/global-observability-overview.png" alt="Global Observability view showing real-time log lines from multiple containers" />
|
||||
</Frame>
|
||||
|
||||
## Log format
|
||||
|
||||
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
|
||||
|
||||
## Streaming modes
|
||||
|
||||
Sencho supports two modes for fetching logs, switchable via the **Developer mode** toggle:
|
||||
|
||||
| Mode | How it works | Best for |
|
||||
|------|-------------|----------|
|
||||
| **Standard** (default) | Polls all containers every N seconds (configurable in Settings → Developer) | General use; lower overhead |
|
||||
| **Developer mode** | Server-Sent Events stream; logs arrive as they are emitted | Debugging; watching a specific event in real-time |
|
||||
|
||||
The default polling interval is 5 seconds. You can change it to 1, 3, 5, or 10 seconds in **Settings → Developer → Global Logs Refresh Rate**.
|
||||
|
||||
## Filtering logs
|
||||
|
||||
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 |
|
||||
| **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.
|
||||
|
||||
## Controls
|
||||
|
||||
| Button | What it does |
|
||||
|--------|-------------|
|
||||
| **Clear** | Clears the current log buffer in the UI (does not delete logs from Docker) |
|
||||
| **Auto-scroll** toggle | When enabled, the view scrolls to the bottom as new lines arrive |
|
||||
|
||||
## Capacity limits
|
||||
|
||||
To avoid browser memory issues, the log view keeps a maximum of **2,000 entries** in memory at a time. Older entries are dropped as new ones arrive. For deeper investigation, use the [per-container log viewer](/features/editor#log-viewer) in the Editor tab, or `docker compose logs` directly from the [Host Console](/features/host-console).
|
||||
|
||||
<Note>
|
||||
Log retention on the backend is controlled by **Settings → Developer → Log Retention Days** (default: 30 days). This affects historical logs stored in Sencho's database, not the live stream.
|
||||
</Note>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Host Console
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/host-console/host-console-overview.png" alt="Host Console showing a PowerShell prompt inside the Sencho container working directory" />
|
||||
</Frame>
|
||||
|
||||
## What it is
|
||||
|
||||
The host console spawns a real PTY (pseudo-terminal) process on the Sencho host using `node-pty`. Your keystrokes are sent over a WebSocket and the terminal output is streamed back in real-time. The terminal emulator is [xterm.js](https://xtermjs.org/), the same engine used by VS Code's built-in terminal.
|
||||
|
||||
Features:
|
||||
- Full colour and cursor support
|
||||
- Tab completion (via the host shell)
|
||||
- Scrollback buffer (10,000 lines)
|
||||
- Automatic terminal resizing when you resize the browser window
|
||||
- Copy and paste
|
||||
|
||||
## Opening the console
|
||||
|
||||
Click **Console** in the top navigation bar. The session starts immediately in the stack's working directory if a stack is selected, otherwise in the `COMPOSE_DIR` root.
|
||||
|
||||
The header shows the connection status (**Connected** in green) and which node the console is attached to.
|
||||
|
||||
Click **Close Console** to end the session and terminate the shell process on the host.
|
||||
|
||||
## Shell type
|
||||
|
||||
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)
|
||||
|
||||
## Security model
|
||||
|
||||
The host console has a stricter authentication requirement than other features:
|
||||
|
||||
- Requires a valid **browser session** (httpOnly cookie). Node-proxy tokens used for multi-node communication are explicitly blocked.
|
||||
- Each session is issued a short-lived **console token** (60-second TTL) before the WebSocket is established.
|
||||
- Because the console gives full shell access to the host, you should secure your Sencho instance with HTTPS and a strong password if it is exposed to a network.
|
||||
|
||||
<Warning>
|
||||
The host console provides unrestricted shell access to the machine running Sencho. Do not expose Sencho on a public network without HTTPS and strong authentication.
|
||||
</Warning>
|
||||
|
||||
## Common uses
|
||||
|
||||
- Inspecting files in your `COMPOSE_DIR` without leaving the browser
|
||||
- Running `docker compose logs --follow` or `docker ps` directly
|
||||
- Editing files with `nano` or `vim` for quick fixes
|
||||
- Running maintenance scripts or one-off commands on the host
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/multi-node/node-manager.png" alt="Node Manager showing a local and a remote node, both Online" />
|
||||
</Frame>
|
||||
|
||||
## How it works
|
||||
|
||||
There is no central server. Each Sencho instance manages its own host independently. When you select a remote node, your browser's API calls are proxied through your local Sencho instance to the remote one, authenticated by a long-lived Bearer token. No SSH. No shared Docker sockets.
|
||||
|
||||
## The local node
|
||||
|
||||
Your primary Sencho installation is always listed as **Local**. It is the default node, marked with a star, and cannot be deleted. All operations on the local node run directly against the host's Docker socket.
|
||||
|
||||
## Adding a remote node
|
||||
|
||||
### 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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
### Step 2: Add the node on your primary instance
|
||||
|
||||
On your **primary** Sencho instance, open **Settings → Nodes** and click **+ Add Node**. Fill in:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | A display name (e.g. `prod-server`, `media-box`) |
|
||||
| **Type** | Select **Remote** |
|
||||
| **Sencho API URL** | The full HTTP/HTTPS URL of the remote instance (e.g. `http://192.168.1.20:3001`) |
|
||||
| **API Token** | The token you generated in Step 1 |
|
||||
|
||||
Click **Create**. Sencho immediately tests the connection and shows the result.
|
||||
|
||||
### Step 3: Verify connectivity
|
||||
|
||||
A successful connection shows the remote node as **Online** with a green badge. If it shows **Offline** or **Unknown**, check:
|
||||
- The remote Sencho instance is running and reachable from your primary host
|
||||
- The API URL is correct (include the port if non-standard)
|
||||
- The token was copied correctly without extra whitespace
|
||||
|
||||
Click the **wifi icon** (test connection) on any node row at any time to re-check status.
|
||||
|
||||
## The 1:1 path rule for remote nodes
|
||||
|
||||
The Compose directory path matters on remote nodes too. When you register a remote node, its `COMPOSE_DIR` is whatever that remote instance was configured with. Make sure the remote Sencho's `COMPOSE_DIR` follows the [1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule) on that remote host.
|
||||
|
||||
## 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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/multi-node/node-manager.png" alt="Node switcher dropdown and node list" />
|
||||
</Frame>
|
||||
|
||||
Node status indicators:
|
||||
|
||||
| Indicator | Meaning |
|
||||
|-----------|---------|
|
||||
| Green dot | Node is reachable and responding |
|
||||
| Red dot | Node is unreachable |
|
||||
| Gray dot | Status not yet checked |
|
||||
|
||||
## Editing and deleting nodes
|
||||
|
||||
Click the **pencil icon** on any remote node row to edit its name, URL, or token. Click the **trash icon** to remove it. The local node cannot be edited or deleted.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- 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.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Features Overview
|
||||
description: A high-level tour of everything Sencho can do.
|
||||
---
|
||||
|
||||
## Stack management
|
||||
|
||||
Deploy, start, stop, restart, and remove Docker Compose stacks through a point-and-click interface. Edit `compose.yaml` and `.env` files directly in the built-in Monaco editor with syntax highlighting. [Learn more →](/features/stack-management)
|
||||
|
||||
## Editor
|
||||
|
||||
Full in-browser code editor for your Compose and environment files. Toggle edit mode, save to disk, or save and deploy in one step. Per-container live stats and an interactive bash exec terminal live alongside the editor. [Learn more →](/features/editor)
|
||||
|
||||
## 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)
|
||||
|
||||
## Real-time logs & stats
|
||||
|
||||
Stream container logs and resource metrics (CPU, memory, network I/O) live in the browser via WebSocket and Server-Sent Events connections. The Home dashboard shows historical CPU and RAM charts over the last 24 hours.
|
||||
|
||||
## Global observability
|
||||
|
||||
The **Logs** view aggregates output from all containers across all stacks into a single scrollable stream. Filter by stack, log level (stdout/stderr), or search for keywords. Switch to developer mode for real-time SSE streaming.
|
||||
|
||||
## Resources hub
|
||||
|
||||
View and manage all Docker images, volumes, and networks. Resources are classified as:
|
||||
|
||||
| Label | Meaning |
|
||||
|-------|---------|
|
||||
| **Managed** | Owned by a Sencho stack |
|
||||
| **External** | Part of another Compose project |
|
||||
| **Unused / Reclaimable** | Safe to prune |
|
||||
|
||||
Run scoped prune operations to clean up Sencho-managed resources only, or target all Docker resources when needed.
|
||||
|
||||
## App Store
|
||||
|
||||
Browse 190+ pre-configured application templates. Filter by category (Media, Automation, Development, etc.), configure environment variables, volumes, and ports, and deploy with a single click.
|
||||
|
||||
## 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.
|
||||
|
||||
## Alerts & notifications
|
||||
|
||||
Configure threshold-based alerts (CPU, memory, network, restart count) per stack. Route notifications to Discord, Slack, or any generic webhook endpoint. [Learn more →](/features/alerts-notifications)
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Resources Hub
|
||||
description: Browse, filter, and clean up Docker images, volumes, networks, and unmanaged containers.
|
||||
---
|
||||
|
||||
The **Resources** tab gives you a full view of everything Docker is storing on your host, broken down by type and ownership.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/resources/resources-overview.png" alt="Resources Hub showing disk footprint, quick clean panel, and images table" />
|
||||
</Frame>
|
||||
|
||||
## Docker disk footprint
|
||||
|
||||
The stacked bar at the top visualises how your Docker disk usage is distributed:
|
||||
|
||||
| Segment | Meaning |
|
||||
|---------|---------|
|
||||
| **Sencho Managed** (green) | Images used by stacks in your `COMPOSE_DIR` |
|
||||
| **External Projects** (orange) | Images used by Docker projects outside Sencho |
|
||||
| **Reclaimable** (gray) | Unused images and dangling layers safe to delete |
|
||||
|
||||
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.
|
||||
|
||||
| Button | What it removes |
|
||||
|--------|----------------|
|
||||
| **Prune Unused Images** | Images with no running containers in Sencho stacks |
|
||||
| **Prune Unused Volumes** | Volumes not attached to any Sencho container |
|
||||
| **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.
|
||||
|
||||
A confirmation dialog appears before any destructive operation, showing a summary of what will be removed.
|
||||
|
||||
## Resource tabs
|
||||
|
||||
### Images
|
||||
|
||||
Lists all Docker images on the host with their ID, repository tag, size, and status.
|
||||
|
||||
**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
|
||||
|
||||
Click the trash icon on any row to delete an individual image. Sencho will warn you if the image is in use.
|
||||
|
||||
### Volumes
|
||||
|
||||
Lists all Docker volumes. Columns: name, driver, mount point, size, and managed status.
|
||||
|
||||
**Filter buttons:** `All` · `Managed` · `External`
|
||||
|
||||
<Warning>
|
||||
Deleting a volume is permanent. Any data stored in it will be lost. Always back up important volume data before pruning.
|
||||
</Warning>
|
||||
|
||||
### Networks
|
||||
|
||||
Lists all Docker networks. Columns: name, driver, scope (`local`, `global`, `swarm`), and managed status.
|
||||
|
||||
**Filter buttons:** `All` · `Managed` · `External` · `System`
|
||||
|
||||
System networks (like `bridge`, `host`, `none`) are shown but cannot be deleted.
|
||||
|
||||
### Unmanaged
|
||||
|
||||
Lists containers running on the host that are not part of any Sencho-managed stack. This includes containers started with `docker run`, or Compose projects outside your `COMPOSE_DIR`.
|
||||
|
||||
This view is useful for identifying orphaned containers after a failed deployment or after moving stacks in and out of `COMPOSE_DIR`.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Stack Management
|
||||
description: Create, deploy, control, and remove Docker Compose stacks.
|
||||
---
|
||||
|
||||
A **stack** in Sencho is a Docker Compose project: a directory inside your `COMPOSE_DIR` that contains at least a `compose.yaml` (or `docker-compose.yml`) file. Sencho automatically discovers every subdirectory as a stack.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-management/create-stack-dialog.png" alt="Create New Stack dialog" />
|
||||
</Frame>
|
||||
|
||||
## Creating a stack
|
||||
|
||||
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
|
||||
|
||||
Sencho creates a new directory inside `COMPOSE_DIR` with a blank `compose.yaml` file. You'll land in the editor automatically.
|
||||
|
||||
## The stack list
|
||||
|
||||
All discovered stacks appear in the left sidebar. Each shows a color-coded status dot:
|
||||
|
||||
| Color | Meaning |
|
||||
|-------|---------|
|
||||
| Green | All containers running |
|
||||
| Red | One or more containers exited |
|
||||
| Gray | No containers / status unknown |
|
||||
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/editor/editor-overview.png" alt="Stack editor with control buttons" />
|
||||
</Frame>
|
||||
|
||||
## Controlling a running stack
|
||||
|
||||
The stack header exposes four actions:
|
||||
|
||||
| Button | Command | What it does |
|
||||
|--------|---------|--------------|
|
||||
| **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. |
|
||||
| **Restart** | `docker compose restart` | Restarts all containers in the stack. |
|
||||
| **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. |
|
||||
| **Delete** | `down` + removes files | Stops and removes containers, then deletes the stack directory. |
|
||||
|
||||
<Warning>
|
||||
**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.
|
||||
</Warning>
|
||||
|
||||
## Stack context menu
|
||||
|
||||
Right-click or use the **⋮** button on any stack in the sidebar to access:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-management/stack-context-menu.png" alt="Stack context menu showing Alerts option" />
|
||||
</Frame>
|
||||
|
||||
- **Alerts** — configure metric-based alerting rules for this stack
|
||||
- **Check for updates** — manually trigger an image update check
|
||||
|
||||
## Converting a `docker run` command
|
||||
|
||||
If you have an existing `docker run` command and want to turn it into a Compose stack, go to the **Home** tab and paste it into the "Convert Docker Run to Compose" field. Sencho converts it to YAML that you can save as a new stack.
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
title: Configuration
|
||||
description: Environment variables, volume mounts, and the 1:1 path rule.
|
||||
---
|
||||
|
||||
Sencho is configured entirely through environment variables and Docker volume mounts. There is no config file to edit inside the container.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `JWT_SECRET` | Secret key used to sign session tokens. Use a long, random string (32+ characters). Changing this invalidates all active sessions. |
|
||||
| `COMPOSE_DIR` | Absolute path to the directory that contains your Compose stacks. Every subdirectory inside becomes a stack in Sencho. |
|
||||
|
||||
## Optional environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3000` | Port the Sencho HTTP server listens on. |
|
||||
| `DATA_DIR` | `/app/data` | Directory where Sencho stores its SQLite database, node registry, and cached metrics. |
|
||||
| `NODE_ENV` | `production` | Set automatically in the Docker image. Only change this for local development. |
|
||||
|
||||
## Required volume mounts
|
||||
|
||||
### Docker socket
|
||||
|
||||
Sencho needs access to the Docker daemon to manage containers:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
### Data directory
|
||||
|
||||
Sencho's database persists all your settings, nodes, alerts, and metrics history. Mount a named volume or host path so it survives container restarts:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./sencho-data:/app/data
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Without a persistent data mount, Sencho will lose all configuration — including registered nodes, alerts, and settings — every time the container restarts.
|
||||
</Warning>
|
||||
|
||||
### Compose directory — the 1:1 path rule
|
||||
|
||||
<Warning>
|
||||
This is the most common source of deployment problems. Read carefully.
|
||||
</Warning>
|
||||
|
||||
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
|
||||
volumes:
|
||||
- /home/boris/docker:/home/boris/docker
|
||||
environment:
|
||||
- COMPOSE_DIR=/home/boris/docker
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ❌ Wrong — paths differ, relative volumes will break
|
||||
volumes:
|
||||
- /home/boris/docker:/app/compose
|
||||
environment:
|
||||
- COMPOSE_DIR=/app/compose
|
||||
```
|
||||
|
||||
If you use a simple path like `/opt/compose` on your host, mount it at `/opt/compose` in the container:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /opt/compose:/opt/compose
|
||||
environment:
|
||||
- COMPOSE_DIR=/opt/compose
|
||||
```
|
||||
|
||||
## Full docker-compose.yml example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
sencho:
|
||||
image: saelix/sencho:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./sencho-data:/app/data
|
||||
- /opt/compose:/opt/compose # 1:1 path rule
|
||||
environment:
|
||||
- JWT_SECRET=your-long-random-secret-here
|
||||
- COMPOSE_DIR=/opt/compose
|
||||
- DATA_DIR=/app/data
|
||||
```
|
||||
|
||||
## Optional: global environment file
|
||||
|
||||
If your Compose stacks share common variables (e.g. `PUID`, `PGID`, `TZ`), you can pass an `env_file` to the Sencho container so those variables are available in the host environment when `docker compose` runs:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
sencho:
|
||||
image: saelix/sencho:latest
|
||||
env_file:
|
||||
- /opt/compose/globals.env # shared vars for all stacks
|
||||
environment:
|
||||
- JWT_SECRET=your-secret
|
||||
- COMPOSE_DIR=/opt/compose
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/compose:/opt/compose
|
||||
- ./sencho-data:/app/data
|
||||
```
|
||||
|
||||
## Reverse proxy setup
|
||||
|
||||
Sencho works behind any reverse proxy. The only requirement is that WebSocket connections are forwarded correctly (used for live logs, container terminals, and the host console).
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name sencho.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik (Docker labels)
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sencho.rule=Host(`sencho.yourdomain.com`)"
|
||||
- "traefik.http.services.sencho.loadbalancer.server.port=3000"
|
||||
```
|
||||
|
||||
<Note>
|
||||
Traefik handles WebSocket upgrades automatically for HTTP/1.1 backends. No extra configuration needed.
|
||||
</Note>
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing system stats and container metrics" />
|
||||
</Frame>
|
||||
|
||||
## 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).
|
||||
|
||||
<Note>
|
||||
Sencho never accesses remote servers directly via SSH or Docker TCP. Remote management works by proxying API requests to another running Sencho instance.
|
||||
</Note>
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Quickstart
|
||||
description: Get Sencho running in under five minutes.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed on the host
|
||||
- A directory where your Compose projects live (e.g. `/opt/compose`)
|
||||
|
||||
## Run with Docker
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name sencho \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/compose:/app/compose \
|
||||
-v sencho_data:/app/data \
|
||||
-e JWT_SECRET=change-me \
|
||||
ghcr.io/ansоcode/sencho:latest
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` in your browser. On first boot you'll be prompted to create an admin account.
|
||||
|
||||
<Note>
|
||||
Replace `/opt/compose` with the path to your Compose projects directory. Every subdirectory inside it becomes a stack in Sencho.
|
||||
</Note>
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Backup & Restore
|
||||
description: What to back up, how to restore it, and how to migrate Sencho to a new host.
|
||||
---
|
||||
|
||||
Sencho stores all its state in two places: the **data directory** (SQLite database) and your **compose directory** (your actual stack files). Both need to be backed up for a complete recovery.
|
||||
|
||||
---
|
||||
|
||||
## What to back up
|
||||
|
||||
### 1. Data directory (`DATA_DIR`)
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Backing up
|
||||
|
||||
### Simple file copy
|
||||
|
||||
```bash
|
||||
# Stop Sencho to ensure the SQLite WAL is flushed (recommended but not strictly required)
|
||||
docker stop sencho
|
||||
|
||||
# Copy the data directory
|
||||
cp -r /path/to/sencho-data /path/to/backup/sencho-data-$(date +%Y%m%d)
|
||||
|
||||
# Copy compose stacks
|
||||
cp -r /opt/compose /path/to/backup/compose-$(date +%Y%m%d)
|
||||
|
||||
# Restart
|
||||
docker start sencho
|
||||
```
|
||||
|
||||
### SQLite online backup (without stopping)
|
||||
|
||||
SQLite supports hot backups via its `.backup` command. This is safe to run while Sencho is running:
|
||||
|
||||
```bash
|
||||
sqlite3 /path/to/sencho-data/sencho.db ".backup '/path/to/backup/sencho.db'"
|
||||
```
|
||||
|
||||
### Automated daily backup (cron example)
|
||||
|
||||
```cron
|
||||
0 3 * * * sqlite3 /path/to/sencho-data/sencho.db ".backup '/backups/sencho-$(date +\%Y\%m\%d).db'" && find /backups -name "sencho-*.db" -mtime +30 -delete
|
||||
```
|
||||
|
||||
This backs up the database at 3 AM daily and deletes backups older than 30 days.
|
||||
|
||||
---
|
||||
|
||||
## Restoring
|
||||
|
||||
### Restore from backup
|
||||
|
||||
1. Stop Sencho:
|
||||
```bash
|
||||
docker stop sencho
|
||||
```
|
||||
|
||||
2. Replace the data directory with your backup:
|
||||
```bash
|
||||
rm -rf /path/to/sencho-data/*
|
||||
cp /path/to/backup/sencho.db /path/to/sencho-data/sencho.db
|
||||
```
|
||||
|
||||
3. Restore your compose directory if needed:
|
||||
```bash
|
||||
cp -r /path/to/backup/compose /opt/compose
|
||||
```
|
||||
|
||||
4. Start Sencho:
|
||||
```bash
|
||||
docker start sencho
|
||||
```
|
||||
|
||||
Sencho will read the restored database and resume with all your previous settings, nodes, and alert rules intact.
|
||||
|
||||
---
|
||||
|
||||
## Migrating to a new host
|
||||
|
||||
### Step 1: Prepare the new host
|
||||
|
||||
Install Docker and Docker Compose on the new machine. Create the same directory structure you use for your compose files, following the [1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule).
|
||||
|
||||
### Step 2: Copy data
|
||||
|
||||
Transfer your backup files to the new host:
|
||||
|
||||
```bash
|
||||
scp -r /path/to/sencho-data newhost:/path/to/sencho-data
|
||||
scp -r /opt/compose newhost:/opt/compose
|
||||
```
|
||||
|
||||
### Step 3: Deploy Sencho on the new host
|
||||
|
||||
Use the same `docker-compose.yml` you used on the old host (with the same `COMPOSE_DIR` and `DATA_DIR` paths):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Step 4: Update remote node references
|
||||
|
||||
If other Sencho instances were pointing to your old host as a remote node, update their node config to use the new host's IP or hostname. Generate a new API token on the restored instance and distribute it.
|
||||
|
||||
### Step 5: Verify
|
||||
|
||||
- Log in and confirm your stacks, nodes, and alerts are all present
|
||||
- Check that at least one stack deploys correctly
|
||||
- Verify the node switcher shows the expected nodes with green status
|
||||
|
||||
---
|
||||
|
||||
## What is NOT backed up by this process
|
||||
|
||||
| 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 |
|
||||
| Sencho logs (docker logs) | Container stdout | Not persisted beyond container lifetime |
|
||||
|
||||
<Note>
|
||||
Sencho does not currently have a built-in backup scheduler or export function. The approaches above use standard OS tools and SQLite's own backup mechanism.
|
||||
</Note>
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: Solutions to the most common Sencho setup and runtime problems.
|
||||
---
|
||||
|
||||
## Containers won't start after deploy
|
||||
|
||||
**Symptom:** You click Deploy and containers immediately exit or never appear.
|
||||
|
||||
**Check:** Open the [Host Console](/features/host-console) and run:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
**Cause:** Your `COMPOSE_DIR` is mounted at a different path inside the Sencho container than it has on the host.
|
||||
|
||||
**Fix:** Your compose volume mount and `COMPOSE_DIR` environment variable must use the **same absolute path** on both sides:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml for Sencho itself
|
||||
volumes:
|
||||
- /opt/docker:/opt/docker # same path inside and outside
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## "Permission denied" on the Docker socket
|
||||
|
||||
**Symptom:** Sencho starts but shows errors accessing Docker, or the stack list is empty even though containers exist.
|
||||
|
||||
**Cause:** The Sencho container cannot read `/var/run/docker.sock`.
|
||||
|
||||
**Fix:** Ensure the socket is mounted:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
On Linux, the Docker socket is owned by the `docker` group. The Sencho entrypoint detects the socket's GID automatically and adds the internal `sencho` user to the matching group. If you see permission errors despite a correct mount, check that the socket file is readable:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Login page shows "Something went wrong"
|
||||
|
||||
**Symptom:** You enter credentials and get a generic error instead of being logged in.
|
||||
|
||||
**Possible causes and fixes:**
|
||||
|
||||
| Cause | Fix |
|
||||
|-------|-----|
|
||||
| `JWT_SECRET` is not set or is empty | Set a non-empty value for `JWT_SECRET` in your environment |
|
||||
| Container restarted and session cookie is stale | Clear browser cookies for the Sencho domain and try again |
|
||||
| Rate limit triggered (5 failed attempts in 15 min) | Wait 15 minutes, or restart the container to reset the limiter |
|
||||
|
||||
---
|
||||
|
||||
## WebSocket connections fail (logs/console not streaming)
|
||||
|
||||
**Symptom:** The log viewer or host console shows a spinner that never resolves, or you see "Disconnected" immediately after connecting.
|
||||
|
||||
**Cause:** A reverse proxy is not forwarding WebSocket upgrade headers.
|
||||
|
||||
**Fix:** Add WebSocket support to your proxy config:
|
||||
|
||||
```nginx
|
||||
# Nginx
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
```
|
||||
|
||||
Traefik handles WebSocket upgrades automatically for HTTP/1.1 backends — no extra config needed.
|
||||
|
||||
---
|
||||
|
||||
## Remote node shows "Offline" or "Unknown"
|
||||
|
||||
**Symptom:** A node you added shows a red or gray status dot.
|
||||
|
||||
**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`.
|
||||
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.
|
||||
|
||||
Click the **wifi icon** on the node row to re-test connectivity after making changes.
|
||||
|
||||
---
|
||||
|
||||
## Forgotten admin password
|
||||
|
||||
Sencho has no password recovery flow. To reset the password:
|
||||
|
||||
1. Stop the Sencho container
|
||||
2. Connect to the SQLite database directly:
|
||||
|
||||
```bash
|
||||
sqlite3 /path/to/data/sencho.db
|
||||
```
|
||||
|
||||
3. Delete the existing credentials so Sencho re-enters first-boot setup mode:
|
||||
|
||||
```sql
|
||||
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.
|
||||
|
||||
<Warning>
|
||||
This resets authentication entirely. All active sessions become invalid. Your stacks, nodes, and alert rules are not affected.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
## Checking the health endpoint
|
||||
|
||||
Sencho exposes a health endpoint for monitoring and container health checks:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
# {"status":"ok","uptime":12345.67}
|
||||
```
|
||||
|
||||
A `200` response confirms the backend is running. Use this endpoint in your uptime monitor or load balancer health check.
|
||||
|
||||
---
|
||||
|
||||
## Getting logs from the Sencho container itself
|
||||
|
||||
```bash
|
||||
docker logs sencho
|
||||
# or, to follow:
|
||||
docker logs -f sencho
|
||||
```
|
||||
|
||||
The backend logs all route errors and service failures to stdout. This is the first place to look when the UI shows an error with no useful message.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: Settings Reference
|
||||
description: Complete reference for every option in the Sencho Settings Hub.
|
||||
---
|
||||
|
||||
Open the Settings Hub by clicking **Settings** in the top navigation bar. The left sidebar lists all available sections.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/settings/settings-overview.png" alt="Settings Hub showing the Account tab and the full section sidebar" />
|
||||
</Frame>
|
||||
|
||||
---
|
||||
|
||||
## Account
|
||||
|
||||
**Scope:** Global (applies to this Sencho instance, not per-node)
|
||||
|
||||
Change the admin account password. All three fields are required.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Current Password** | Your existing password for verification |
|
||||
| **New Password** | Must be at least 6 characters |
|
||||
| **Confirm New Password** | Must match New Password |
|
||||
|
||||
Click **Update Password** to apply. The change takes effect immediately; existing sessions remain valid.
|
||||
|
||||
---
|
||||
|
||||
## System Limits
|
||||
|
||||
**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.
|
||||
|
||||
| Setting | Range | Description |
|
||||
|---------|-------|-------------|
|
||||
| **Host CPU Limit** | 1–100% | CPU percentage above which the CPU card turns orange/red |
|
||||
| **Host RAM Limit** | 1–100% | RAM percentage above which the RAM card turns orange/red |
|
||||
| **Host Disk Limit** | 1–100% | Disk percentage above which the Disk card turns orange/red |
|
||||
| **Docker Janitor Threshold** | ≥ 0 GB | Minimum free disk space to maintain. When free space falls below this value, a warning is shown. Set to `0` to disable. |
|
||||
| **Global Crash Alerts** | On / Off | When enabled, Sencho sends a notification whenever any managed container exits unexpectedly |
|
||||
|
||||
Click **Save** to apply. An unsaved-changes indicator appears when you have edits pending.
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
**Scope:** Global
|
||||
|
||||
Configure external destinations for alert notifications. Three agent types are supported, each on its own sub-tab: **Discord**, **Slack**, and **Webhook**.
|
||||
|
||||
For each agent:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Enable toggle** | Activates or deactivates this agent. Disabled agents receive no messages even if a URL is saved. |
|
||||
| **Webhook URL** | The endpoint Sencho will POST to when an alert fires |
|
||||
|
||||
Click **Save** to persist changes. Click **Test** to send a test payload immediately and verify delivery.
|
||||
|
||||
At least one agent must be enabled for stack alerts to deliver notifications. See [Alerts & Notifications](/features/alerts-notifications) for how to create alert rules.
|
||||
|
||||
---
|
||||
|
||||
## Appearance
|
||||
|
||||
**Scope:** Global (stored in the browser, not the server)
|
||||
|
||||
| Setting | Options | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Theme** | Light / Dark / Auto | `Auto` follows the OS system preference. Changes apply immediately without a page reload. |
|
||||
|
||||
---
|
||||
|
||||
## Developer
|
||||
|
||||
**Scope:** Per-node (applies to the currently selected node)
|
||||
|
||||
Advanced settings for log streaming behaviour and data retention. Most users can leave these at their defaults.
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Developer Mode** | Off | When on, the [Global Observability](/features/global-observability) view switches from polling to real-time Server-Sent Events streaming |
|
||||
| **Global Logs Refresh Rate** | 5s | Polling interval in standard mode. Options: `1s`, `3s`, `5s`, `10s` |
|
||||
| **Metrics Retention Hours** | 24 | How many hours of CPU/RAM history to keep for dashboard charts. Max: 8,760 (1 year) |
|
||||
| **Log Retention Days** | 30 | How many days of notification history to keep in the database. Max: 365 |
|
||||
|
||||
Click **Save** to apply.
|
||||
|
||||
<Note>
|
||||
Lower refresh rates (1s) increase backend CPU usage as Sencho polls Docker more frequently. Use only when actively debugging.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Nodes
|
||||
|
||||
**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.
|
||||
|
||||
Quick reference:
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Add a remote node | Click **+ Add Node** |
|
||||
| Generate a token for this instance | Click **Generate Token** |
|
||||
| Test an existing node's connectivity | Click the wifi icon on any row |
|
||||
| Edit a node | Click the pencil icon |
|
||||
| Delete a node | Click the trash icon (remote nodes only) |
|
||||
|
||||
---
|
||||
|
||||
## App Store
|
||||
|
||||
**Scope:** Global
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Template Registry URL** | LinuxServer.io registry | The URL Sencho fetches templates from. Must return a JSON array in Portainer v2 template format. Leave blank to restore the default. |
|
||||
|
||||
Click **Save** to update the URL. Click **Refresh Cache** to clear the cached template list and fetch fresh data from the registry immediately.
|
||||
|
||||
See [App Store](/features/app-store#custom-template-registry) for more on custom registries.
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Authentication E2E tests.
|
||||
* Tests login, logout, and unauthenticated redirect.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, isDashboard, TEST_USERNAME, TEST_PASSWORD } from './helpers';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('login with valid credentials shows the dashboard', async ({ page }) => {
|
||||
await loginAs(page, TEST_USERNAME, TEST_PASSWORD);
|
||||
expect(await isDashboard(page)).toBe(true);
|
||||
// URL should not be on a login page
|
||||
expect(page.url()).not.toMatch(/login/i);
|
||||
});
|
||||
|
||||
test('login with wrong password shows an error', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Skip if already logged in
|
||||
if (await isDashboard(page)) {
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await page.locator('#username').fill(TEST_USERNAME);
|
||||
await page.locator('#password').fill('definitely-wrong-password-xyz');
|
||||
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
|
||||
|
||||
// Should show error message, not navigate to dashboard
|
||||
await expect(page.locator('text=/invalid|incorrect|wrong|failed/i')).toBeVisible({ timeout: 5_000 });
|
||||
expect(await isDashboard(page)).toBe(false);
|
||||
});
|
||||
|
||||
test('visiting the app without auth redirects to login', async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
// Should be on login or setup, not dashboard
|
||||
expect(await isDashboard(page)).toBe(false);
|
||||
// Login button or setup form should be visible
|
||||
const loginOrSetup = await page.locator(
|
||||
'button:has-text("Login"), button:has-text("Sign in"), button[type="submit"]'
|
||||
).first().isVisible();
|
||||
expect(loginOrSetup).toBe(true);
|
||||
});
|
||||
|
||||
test('logout returns to the login screen', async ({ page }) => {
|
||||
await loginAs(page);
|
||||
// The logout button renders a Lucide LogOut icon — Lucide adds a CSS class matching the icon name
|
||||
const logoutBtn = page.locator('button:has(.lucide-log-out)');
|
||||
await logoutBtn.click();
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(await isDashboard(page)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared helpers for Sencho E2E tests.
|
||||
*
|
||||
* CREDENTIALS: Set E2E_USERNAME and E2E_PASSWORD env vars to match
|
||||
* your dev instance's admin account. Defaults assume the initial setup
|
||||
* was completed with username "admin" and password "password123".
|
||||
*
|
||||
* E2E_USERNAME=admin E2E_PASSWORD=mypassword npx playwright test
|
||||
*/
|
||||
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 */
|
||||
const DASHBOARD_INDICATOR = 'img[alt="Sencho Logo"]';
|
||||
|
||||
/** Returns true if the current page is the first-run setup screen */
|
||||
async function isSetupPage(page: Page): Promise<boolean> {
|
||||
return page.locator('#confirmPassword, input[placeholder*="Confirm"]').isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/** Returns true if the current page is the login screen */
|
||||
async function isLoginPage(page: Page): Promise<boolean> {
|
||||
return page.locator('button:has-text("Login"), button:has-text("Sign in")').isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/** Returns true if the dashboard (EditorLayout) is loaded */
|
||||
export async function isDashboard(page: Page): Promise<boolean> {
|
||||
return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the app root, complete first-run setup if needed, then log in.
|
||||
* After this call the dashboard is guaranteed to be visible.
|
||||
*/
|
||||
export async function loginAs(page: Page, username = TEST_USERNAME, password = TEST_PASSWORD) {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the app to finish its auth check (loading spinner disappears)
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ── First-run setup ───────────────────────────────────────────────────────
|
||||
if (await isSetupPage(page)) {
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#password').fill(password);
|
||||
const confirmInput = page.locator('#confirmPassword');
|
||||
if (await confirmInput.isVisible()) await confirmInput.fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
// After setup, the app logs in automatically and shows the dashboard
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────────────
|
||||
if (await isLoginPage(page)) {
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#password').fill(password);
|
||||
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Already on the dashboard ──────────────────────────────────────────────
|
||||
if (await isDashboard(page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'loginAs: could not determine page state — expected setup, login, or dashboard. ' +
|
||||
'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Node management E2E tests.
|
||||
* Tests the SSRF validation we added (C2 fix) is surfaced in the UI.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs } from './helpers';
|
||||
|
||||
test.describe('Node management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
// Open Settings modal then navigate to the Nodes section
|
||||
await page.getByRole('button', { name: /settings/i }).click();
|
||||
await page.getByRole('button', { name: /^nodes$/i }).click();
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the Add Node dialog and switch the type to Remote so the API URL
|
||||
* field becomes visible. Returns false (and skips) if the button isn't found.
|
||||
*/
|
||||
async function openAddNodeAsRemote(page: import('@playwright/test').Page): Promise<boolean> {
|
||||
const addBtn = page.getByRole('button', { name: /add node/i }).first();
|
||||
if (!await addBtn.isVisible()) {
|
||||
test.skip();
|
||||
return false;
|
||||
}
|
||||
await addBtn.click();
|
||||
// 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.
|
||||
await page.locator('#node-type').click();
|
||||
await page.getByRole('option', { name: /remote/i }).click();
|
||||
// Confirm the API URL field is now visible before proceeding
|
||||
await expect(page.locator('#node-api-url')).toBeVisible({ timeout: 3_000 });
|
||||
return true;
|
||||
}
|
||||
|
||||
test('adding a node with localhost api_url shows a validation error', async ({ page }) => {
|
||||
if (!await openAddNodeAsRemote(page)) return;
|
||||
|
||||
await page.locator('#node-name').fill('bad-node');
|
||||
await page.locator('#node-api-url').fill('http://localhost:6379');
|
||||
// api_token is required to enable the submit button; use a dummy value since we're testing URL validation
|
||||
await page.locator('#node-api-token').fill('dummy-token');
|
||||
// Use .last() to target the dialog submit button, not the trigger
|
||||
await page.getByRole('button', { name: /add node/i }).last().click();
|
||||
|
||||
await expect(page.getByText(/loopback|localhost/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('adding a node with an invalid URL shows an error', async ({ page }) => {
|
||||
if (!await openAddNodeAsRemote(page)) return;
|
||||
|
||||
await page.locator('#node-name').fill('bad-url-node');
|
||||
await page.locator('#node-api-url').fill('not-a-url-at-all');
|
||||
// api_token is required to enable the submit button; use a dummy value since we're testing URL validation
|
||||
await page.locator('#node-api-token').fill('dummy-token');
|
||||
await page.getByRole('button', { name: /add node/i }).last().click();
|
||||
|
||||
await expect(page.getByText(/valid url|invalid url/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Docs screenshot capture.
|
||||
*
|
||||
* Takes canonical screenshots of key UI views and writes them to docs/images/.
|
||||
* Run via: npx playwright test e2e/screenshots.spec.ts
|
||||
*
|
||||
* The CI `update-screenshots` job runs this on every push to develop and
|
||||
* commits any changed images back to the repo so sync-docs picks them up.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { test } from '@playwright/test';
|
||||
import { loginAs } from './helpers';
|
||||
|
||||
const DOCS_IMAGES = path.resolve(__dirname, '../docs/images');
|
||||
|
||||
test.use({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
// Always capture — this spec exists solely to produce screenshots
|
||||
screenshot: 'on',
|
||||
});
|
||||
|
||||
test.beforeAll(() => {
|
||||
fs.mkdirSync(DOCS_IMAGES, { recursive: true });
|
||||
});
|
||||
|
||||
test('login page', async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: path.join(DOCS_IMAGES, 'login.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('dashboard', async ({ page }) => {
|
||||
await loginAs(page);
|
||||
// Wait for stats widgets to settle
|
||||
await page.waitForTimeout(1_000);
|
||||
await page.screenshot({ path: path.join(DOCS_IMAGES, 'dashboard.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('stacks', async ({ page }) => {
|
||||
await loginAs(page);
|
||||
await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 10_000 });
|
||||
await page.screenshot({ path: path.join(DOCS_IMAGES, 'stacks.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('resources', async ({ page }) => {
|
||||
await loginAs(page);
|
||||
await page.getByRole('button', { name: /resources/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: path.join(DOCS_IMAGES, 'resources.png'), fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Stack management E2E tests — happy path CRUD.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs } from './helpers';
|
||||
|
||||
const TEST_STACK = 'e2e-test-stack';
|
||||
|
||||
/** Wait for the stacks sidebar to be ready (Create Stack button is always rendered). */
|
||||
async function waitForStacksLoaded(page: import('@playwright/test').Page) {
|
||||
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** 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(() => {});
|
||||
}, TEST_STACK);
|
||||
}
|
||||
|
||||
test.describe('Stack management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
});
|
||||
|
||||
test('create a new stack', async ({ page }) => {
|
||||
// Remove leftover from prior runs (using browser context auth)
|
||||
await deleteTestStackViaApi(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Reload to get a fresh sidebar without the deleted stack
|
||||
await page.reload();
|
||||
await loginAs(page); // may re-login if cookie expired, otherwise skips to dashboard
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Create Stack' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.locator('#create-stack-name').fill(TEST_STACK);
|
||||
await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Wait for dialog to close (success) or error message to appear (failure)
|
||||
await Promise.race([
|
||||
page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 8_000 }),
|
||||
page.getByText(/already exists/i).waitFor({ state: 'visible', timeout: 8_000 }),
|
||||
]).catch(() => {});
|
||||
|
||||
// The stack should now exist — refresh and verify via the sidebar
|
||||
await page.reload();
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('delete the test stack', async ({ page }) => {
|
||||
// Confirm the stack exists in the sidebar
|
||||
await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click on the stack to open the editor
|
||||
await page.getByText(TEST_STACK).first().click();
|
||||
|
||||
// The toolbar Delete button has the Lucide Trash2 icon
|
||||
const deleteBtn = page.locator('button:has(.lucide-trash-2)');
|
||||
await expect(deleteBtn).toBeVisible({ timeout: 10_000 });
|
||||
await deleteBtn.click();
|
||||
|
||||
// AlertDialog confirmation
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Stack should no longer appear in the sidebar (exact match to avoid false positives from
|
||||
// similarly-named stacks; scoped to the CommandList)
|
||||
await expect(
|
||||
page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true })
|
||||
).not.toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
});
|
||||
@@ -19,5 +19,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@animate-ui": "https://animate-ui.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<link rel="icon" type="image/png" href="/sencho-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sencho</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap" rel="stylesheet" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
|
||||
@@ -33,10 +33,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 61 KiB |
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -17,6 +17,11 @@ export interface TemplateEnv {
|
||||
default?: string;
|
||||
}
|
||||
|
||||
interface TemplateVolume {
|
||||
container?: string;
|
||||
bind?: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
type?: number;
|
||||
title: string;
|
||||
@@ -24,13 +29,14 @@ export interface Template {
|
||||
logo?: string;
|
||||
image?: string;
|
||||
ports?: string[];
|
||||
volumes?: any[];
|
||||
volumes?: TemplateVolume[];
|
||||
env?: TemplateEnv[];
|
||||
categories?: string[];
|
||||
github_url?: string;
|
||||
docs_url?: string;
|
||||
architectures?: string[];
|
||||
stars?: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface AppStoreViewProps {
|
||||
@@ -48,6 +54,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('All');
|
||||
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
|
||||
const [portVars, setPortVars] = useState<Record<string, string>>({});
|
||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||
@@ -66,8 +73,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
if (!res.ok) throw new Error('Failed to fetch templates');
|
||||
const data = await res.json();
|
||||
setTemplates(data || []);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to load App Shop");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || "Failed to load App Shop");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -93,7 +100,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
|
||||
// Initialize Volumes
|
||||
const initVols: Record<string, string> = {};
|
||||
t.volumes?.forEach((v: any) => {
|
||||
t.volumes?.forEach((v) => {
|
||||
if (v.container) {
|
||||
initVols[v.container] = v.bind || `./${v.container.split('/').filter(Boolean).pop() || 'data'}`;
|
||||
}
|
||||
@@ -144,7 +151,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
|
||||
// Process Volumes
|
||||
if (modifiedTemplate.volumes) {
|
||||
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v: any) => {
|
||||
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v) => {
|
||||
if (v.container && volVars[v.container] !== undefined) {
|
||||
return { ...v, bind: volVars[v.container] };
|
||||
}
|
||||
@@ -173,18 +180,28 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
toast.success(`${selectedTemplate?.title} deployed successfully!`);
|
||||
setIsSheetOpen(false);
|
||||
onDeploySuccess(stackName.trim());
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Deployment failed');
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || 'Deployment failed');
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = templates.filter(t =>
|
||||
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(t.categories && t.categories.join(' ').toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
templates.forEach(t => t.categories?.forEach(c => cats.add(c)));
|
||||
return ['All', ...Array.from(cats).sort()];
|
||||
}, [templates]);
|
||||
|
||||
const filtered = useMemo(() => templates.filter(t => {
|
||||
const matchesCategory = selectedCategory === 'All' || t.categories?.includes(selectedCategory);
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchesSearch = !q ||
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
t.description?.toLowerCase().includes(q) ||
|
||||
(t.categories && t.categories.join(' ').toLowerCase().includes(q));
|
||||
return matchesCategory && matchesSearch;
|
||||
}), [templates, selectedCategory, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
@@ -196,11 +213,32 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
placeholder="Search App Store..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && categories.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1 flex-1 scrollbar-none">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategory === cat ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="shrink-0 h-7 text-xs px-3 rounded-full"
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
{filtered.length} app{filtered.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
@@ -233,7 +271,12 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
<CardContent className="pt-0 mt-auto">
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{t.categories.slice(0, 3).map(c => (
|
||||
<Badge variant="secondary" key={c} className="text-[10px] px-1.5 py-0 pb-0.5">
|
||||
<Badge
|
||||
variant={selectedCategory === c ? 'default' : 'secondary'}
|
||||
key={c}
|
||||
className="text-[10px] px-1.5 py-0 pb-0.5 cursor-pointer"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedCategory(c); }}
|
||||
>
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -361,14 +404,15 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
{selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Volumes (Host : Container)</h4>
|
||||
{selectedTemplate.volumes.map((v: any, idx: number) => {
|
||||
if (!v.container) return null;
|
||||
{selectedTemplate.volumes.map((v, idx: number) => {
|
||||
const containerPath = v.container;
|
||||
if (!containerPath) return null;
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-mono">Container: {v.container}</Label>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Container: {containerPath}</Label>
|
||||
<Input
|
||||
value={volVars[v.container] !== undefined ? volVars[v.container] : ''}
|
||||
onChange={(e) => setVolVars(prev => ({ ...prev, [v.container]: e.target.value }))}
|
||||
value={volVars[containerPath] !== undefined ? volVars[containerPath] : ''}
|
||||
onChange={(e) => setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))}
|
||||
placeholder={`/path/to/host/dir`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
type TerminalContainer = HTMLDivElement & { __resizeObserver?: ResizeObserver };
|
||||
|
||||
interface BashExecModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -191,14 +193,15 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
|
||||
resizeObserver.observe(containerEl);
|
||||
|
||||
// Store observer cleanup on the container element for later
|
||||
(containerEl as any).__resizeObserver = resizeObserver;
|
||||
(containerEl as TerminalContainer).__resizeObserver = resizeObserver;
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up ResizeObserver
|
||||
if (terminalRef.current && (terminalRef.current as any).__resizeObserver) {
|
||||
(terminalRef.current as any).__resizeObserver.disconnect();
|
||||
delete (terminalRef.current as any).__resizeObserver;
|
||||
const el = terminalRef.current as TerminalContainer | null;
|
||||
if (el?.__resizeObserver) {
|
||||
el.__resizeObserver.disconnect();
|
||||
delete el.__resizeObserver;
|
||||
}
|
||||
};
|
||||
}, [isOpen, containerId, cleanup]);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import TerminalComponent from './Terminal';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
@@ -15,7 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { Label } from './ui/label';
|
||||
import { Command, CommandInput, CommandList, CommandItem } from './ui/command';
|
||||
@@ -32,6 +35,7 @@ import { AppStoreView } from './AppStoreView';
|
||||
import { LogViewer } from './LogViewer';
|
||||
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -45,6 +49,16 @@ interface StackStatus {
|
||||
[key: string]: 'running' | 'exited' | 'unknown';
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
is_read: number; // 0 | 1 (SQLite boolean)
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -56,6 +70,12 @@ const formatBytes = (bytes: number) => {
|
||||
export default function EditorLayout() {
|
||||
const { logout } = useAuth();
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
// Stable ref so notification callbacks always read the latest nodes list
|
||||
// without needing nodes in their dependency arrays (which would cause loops).
|
||||
const nodesRef = useRef<Node[]>([]);
|
||||
nodesRef.current = nodes;
|
||||
// Tracks cleanup functions for per-remote-node notification WebSocket connections.
|
||||
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<string>('');
|
||||
@@ -67,7 +87,15 @@ export default function EditorLayout() {
|
||||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||||
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string, net: string, lastRx?: number, lastTx?: number }>>({});
|
||||
// Incoming WebSocket stats are written here first (no re-render), then flushed
|
||||
// to React state in one batched update every 1.5 s.
|
||||
const pendingStatsRef = useRef<Record<string, { cpu: string; ram: string; net: string; lastRx: number; lastTx: number }>>({});
|
||||
// Raw rx/tx byte totals used for rate calculation. Never cleared on flush so
|
||||
// the delta is always computed against the most recent known value, avoiding
|
||||
// the stale-closure bug that occurs when reading containerStats directly.
|
||||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [newStackName, setNewStackName] = useState('');
|
||||
@@ -75,13 +103,15 @@ export default function EditorLayout() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
const [isFileLoading, setIsFileLoading] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const saved = localStorage.getItem('sencho-theme');
|
||||
if (saved !== null) {
|
||||
return saved === 'dark';
|
||||
}
|
||||
return true; // Default to dark mode
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||||
return 'dark'; // Default to dark mode
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(() =>
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -100,7 +130,7 @@ export default function EditorLayout() {
|
||||
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Notifications & Settings state
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
||||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
||||
@@ -110,17 +140,34 @@ export default function EditorLayout() {
|
||||
setAlertSheetOpen(true);
|
||||
};
|
||||
|
||||
// Theme toggle effect
|
||||
// Listen for system dark mode changes (for 'auto' theme)
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
if (isDarkMode) {
|
||||
html.classList.add('dark');
|
||||
localStorage.setItem('sencho-theme', 'dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
localStorage.setItem('sencho-theme', 'light');
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Apply dark class and persist theme preference
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
localStorage.setItem('sencho-theme', theme);
|
||||
}, [isDarkMode, theme]);
|
||||
|
||||
// Force Monaco to re-measure its container after the tab switch DOM settles.
|
||||
// Monaco's internal child is position:static with an explicit pixel height that
|
||||
// creates a circular CSS dependency (Monaco drives card height → grid height → Monaco).
|
||||
// Fix: reset Monaco to 0×0 first (breaks the cycle), then trigger a forced synchronous
|
||||
// reflow so the container has its CSS-correct size before Monaco re-measures.
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
const editor = monacoEditorRef.current;
|
||||
if (!editor) return;
|
||||
editor.layout({ width: 0, height: 0 }); // collapse → breaks CSS circular dependency
|
||||
editor.layout(); // forced reflow → measures correct container size
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [activeTab]);
|
||||
|
||||
const refreshStacks = async (background = false) => {
|
||||
if (!background) setIsLoading(true);
|
||||
@@ -155,12 +202,156 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Notification polling - independent of active node, runs once on mount
|
||||
// Notification WS push - subscribe to local real-time alerts.
|
||||
// Initial history load is handled by the [nodes] effect below.
|
||||
useEffect(() => {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsBase = `${wsProtocol}//${window.location.host}`;
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isMounted = true;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRY_DELAY_MS = 30000;
|
||||
|
||||
const connect = () => {
|
||||
if (!isMounted) return;
|
||||
ws = new WebSocket(`${wsBase}/ws/notifications`);
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isMounted) {
|
||||
// Component unmounted while the handshake was in-flight (React StrictMode double-mount)
|
||||
ws?.close();
|
||||
return;
|
||||
}
|
||||
retryCount = 0; // Reset backoff on successful connect
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.type === 'notification' && msg.payload) {
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const tagged: Notification = {
|
||||
...(msg.payload as Omit<Notification, 'nodeId' | 'nodeName'>),
|
||||
nodeId: localNode?.id ?? -1,
|
||||
nodeName: localNode?.name ?? 'Local',
|
||||
};
|
||||
setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WS notifications] parse error', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isMounted) return;
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS);
|
||||
retryCount++;
|
||||
console.debug(`[WS notifications] closed (code=${event.code}), reconnecting in ${delay}ms (attempt ${retryCount})`);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
// onerror always fires before onclose - log it and let onclose handle reconnect
|
||||
console.warn('[WS notifications] error event', event);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
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
|
||||
// "WebSocket is closed before the connection is established".
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fetch all notifications when the nodes list changes (e.g. remote node added/removed).
|
||||
// nodesRef ensures fetchNotifications always reads the latest nodes at call time.
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
const notificationInterval = setInterval(fetchNotifications, 5000);
|
||||
return () => clearInterval(notificationInterval);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Open / close per-remote-node notification WebSocket connections as the nodes list changes.
|
||||
// Uses remoteNotifWsRef to avoid tearing down existing connections on unrelated node updates.
|
||||
useEffect(() => {
|
||||
const remoteNodes = nodes.filter(n => n.type === 'remote');
|
||||
const currentIds = new Set(remoteNotifWsRef.current.keys());
|
||||
const newIds = new Set(remoteNodes.map(n => n.id));
|
||||
|
||||
// Close connections for nodes that are no longer registered as remote
|
||||
for (const id of currentIds) {
|
||||
if (!newIds.has(id)) {
|
||||
remoteNotifWsRef.current.get(id)?.();
|
||||
remoteNotifWsRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Open connections for newly-added remote nodes
|
||||
for (const rn of remoteNodes) {
|
||||
if (remoteNotifWsRef.current.has(rn.id)) continue;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let active = true;
|
||||
let retryCount = 0;
|
||||
|
||||
const connect = () => {
|
||||
if (!active) return;
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/notifications?nodeId=${rn.id}`);
|
||||
|
||||
ws.onopen = () => { if (!active) { ws?.close(); } else { retryCount = 0; } };
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.type === 'notification' && msg.payload) {
|
||||
// Read node name from ref so it stays fresh even if the node was renamed
|
||||
const current = nodesRef.current.find(n => n.id === rn.id);
|
||||
setNotifications(prev =>
|
||||
[{ ...msg.payload as Omit<Notification, 'nodeId' | 'nodeName'>, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev]
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[WS notifications:${rn.name}] parse error`, e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!active) return;
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
|
||||
retryCount++;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.warn(`[WS notifications:${rn.name}] error`, e);
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
remoteNotifWsRef.current.set(rn.id, () => {
|
||||
active = false;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
|
||||
});
|
||||
}
|
||||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Cleanup all remote notification WebSocket connections on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const cleanup of remoteNotifWsRef.current.values()) cleanup();
|
||||
remoteNotifWsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-fetch stacks whenever the active node changes (or becomes available on mount).
|
||||
// Also clears any stale editor/container state that belonged to the previous node.
|
||||
@@ -180,12 +371,36 @@ export default function EditorLayout() {
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/notifications');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data);
|
||||
const currentNodes = nodesRef.current;
|
||||
const localNode = currentNodes.find(n => n.type === 'local');
|
||||
const remoteNodes = currentNodes.filter(n => n.type === 'remote');
|
||||
|
||||
const [localResult, ...remoteResults] = await Promise.allSettled([
|
||||
apiFetch('/notifications', { localOnly: true }),
|
||||
...remoteNodes.map(n => fetchForNode('/notifications', n.id)),
|
||||
]);
|
||||
|
||||
const all: Notification[] = [];
|
||||
|
||||
if (localResult.status === 'fulfilled' && localResult.value.ok) {
|
||||
const data = await localResult.value.json() as Omit<Notification, 'nodeId' | 'nodeName'>[];
|
||||
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
for (let i = 0; i < remoteNodes.length; i++) {
|
||||
const result = remoteResults[i];
|
||||
if (result?.status === 'fulfilled' && result.value.ok) {
|
||||
const data = await result.value.json() as Omit<Notification, 'nodeId' | 'nodeName'>[];
|
||||
const rn = remoteNodes[i];
|
||||
data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name }));
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.timestamp - a.timestamp);
|
||||
setNotifications(all);
|
||||
} catch (e) {
|
||||
console.error('[Notifications] fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchImageUpdates = async () => {
|
||||
@@ -195,32 +410,61 @@ export default function EditorLayout() {
|
||||
const data = await res.json();
|
||||
setStackUpdates(data);
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e: unknown) {
|
||||
console.error('[ImageUpdates] fetch failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await apiFetch('/notifications/read', { method: 'POST' });
|
||||
fetchNotifications();
|
||||
} catch (e) { }
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read).map(n => n.nodeId))];
|
||||
await Promise.allSettled(unreadNodeIds.map(nodeId =>
|
||||
nodeId === localNode?.id
|
||||
? apiFetch('/notifications/read', { method: 'POST', localOnly: true })
|
||||
: fetchForNode('/notifications/read', nodeId, { method: 'POST' })
|
||||
));
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 })));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to mark notifications as read');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (id: number) => {
|
||||
const deleteNotification = async (notif: Notification) => {
|
||||
try {
|
||||
await apiFetch(`/notifications/${id}`, { method: 'DELETE' });
|
||||
fetchNotifications();
|
||||
} catch (e) { }
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
if (notif.nodeId === localNode?.id) {
|
||||
await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true });
|
||||
} else {
|
||||
await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' });
|
||||
}
|
||||
setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId)));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to delete notification');
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllNotifications = async () => {
|
||||
try {
|
||||
await apiFetch('/notifications', { method: 'DELETE' });
|
||||
fetchNotifications();
|
||||
} catch (e) { }
|
||||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||||
const uniqueNodeIds = [...new Set(notifications.map(n => n.nodeId))];
|
||||
await Promise.allSettled(uniqueNodeIds.map(nodeId =>
|
||||
nodeId === localNode?.id
|
||||
? apiFetch('/notifications', { method: 'DELETE', localOnly: true })
|
||||
: fetchForNode('/notifications', nodeId, { method: 'DELETE' })
|
||||
));
|
||||
setNotifications([]);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to clear notifications');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const wsMap: Record<string, WebSocket> = {};
|
||||
|
||||
(containers || []).forEach(container => {
|
||||
if (!container?.Id) return;
|
||||
try {
|
||||
@@ -238,6 +482,7 @@ export default function EditorLayout() {
|
||||
const data = JSON.parse(event.data);
|
||||
// Skip initial empty chunks where stats fields are missing
|
||||
if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return;
|
||||
|
||||
const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0);
|
||||
const onlineCpus = data.cpu_stats.online_cpus || 1;
|
||||
@@ -247,37 +492,29 @@ export default function EditorLayout() {
|
||||
let currentRx = 0;
|
||||
let currentTx = 0;
|
||||
if (data.networks) {
|
||||
Object.values(data.networks).forEach((net: any) => {
|
||||
Object.values(data.networks as Record<string, { rx_bytes?: number; tx_bytes?: number }>).forEach((net) => {
|
||||
currentRx += net.rx_bytes || 0;
|
||||
currentTx += net.tx_bytes || 0;
|
||||
});
|
||||
}
|
||||
|
||||
setContainerStats(prev => {
|
||||
const prevStat = prev[container.Id];
|
||||
// Calculate rate if we have a previous value
|
||||
const rxRate = prevStat?.lastRx !== undefined ? Math.max(0, currentRx - prevStat.lastRx) : 0;
|
||||
const txRate = prevStat?.lastTx !== undefined ? Math.max(0, currentTx - prevStat.lastTx) : 0;
|
||||
// Rate is derived from rawBytesRef which is never cleared on flush,
|
||||
// so the delta is always accurate - no stale-closure risk.
|
||||
const prevRaw = rawBytesRef.current[container.Id];
|
||||
const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0;
|
||||
const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0;
|
||||
rawBytesRef.current[container.Id] = { lastRx: currentRx, lastTx: currentTx };
|
||||
|
||||
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
|
||||
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
|
||||
|
||||
// Check if values actually changed to prevent infinite re-renders
|
||||
const newCpu = cpuPercent + '%';
|
||||
if (prevStat && prevStat.cpu === newCpu && prevStat.ram === ramUsage && prevStat.lastRx === currentRx && prevStat.lastTx === currentTx) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[container.Id]: {
|
||||
cpu: newCpu,
|
||||
ram: ramUsage,
|
||||
net: netIO,
|
||||
lastRx: currentRx,
|
||||
lastTx: currentTx
|
||||
}
|
||||
};
|
||||
});
|
||||
// Write into the buffer ref only - zero re-render cost.
|
||||
pendingStatsRef.current[container.Id] = {
|
||||
cpu: cpuPercent + '%',
|
||||
ram: ramUsage,
|
||||
net: netIO,
|
||||
lastRx: currentRx,
|
||||
lastTx: currentTx,
|
||||
};
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
@@ -286,16 +523,39 @@ export default function EditorLayout() {
|
||||
// Ignore WebSocket errors
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
Object.values(wsMap).forEach(ws => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
|
||||
// Flush buffered stats into React state once every 1.5 s.
|
||||
// Snapshot + clear the buffer BEFORE calling setState so the updater
|
||||
// function remains pure (no side-effects inside it).
|
||||
const flushInterval = setInterval(() => {
|
||||
const pending = pendingStatsRef.current;
|
||||
if (Object.keys(pending).length === 0) return;
|
||||
pendingStatsRef.current = {};
|
||||
|
||||
setContainerStats(prev => {
|
||||
let hasChanges = false;
|
||||
const next = { ...prev };
|
||||
for (const [id, newStats] of Object.entries(pending)) {
|
||||
const old = prev[id];
|
||||
if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) {
|
||||
next[id] = newStats;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
return hasChanges ? next : prev;
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
clearInterval(flushInterval);
|
||||
// Discard buffered stats for the old stack so stale entries don't
|
||||
// briefly appear when a new stack is selected.
|
||||
pendingStatsRef.current = {};
|
||||
Object.values(wsMap).forEach(ws => {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
});
|
||||
};
|
||||
}, [containers]);
|
||||
}, [containers]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadFile = async (filename: string) => {
|
||||
if (!filename) return;
|
||||
@@ -453,9 +713,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
toast.error(error.message || 'Failed to deploy stack');
|
||||
toast.error((error as Error).message || 'Failed to deploy stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -481,9 +741,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to stop:', error);
|
||||
toast.error(error.message || 'Failed to stop stack');
|
||||
toast.error((error as Error).message || 'Failed to stop stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -509,9 +769,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to restart:', error);
|
||||
toast.error(error.message || 'Failed to restart stack');
|
||||
toast.error((error as Error).message || 'Failed to restart stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -537,9 +797,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to update:', error);
|
||||
toast.error(error.message || 'Failed to update stack');
|
||||
toast.error((error as Error).message || 'Failed to update stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -570,9 +830,9 @@ export default function EditorLayout() {
|
||||
setIsEditing(false);
|
||||
}
|
||||
await refreshStacks();
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to delete stack:', error);
|
||||
toast.error(error.message || 'Failed to delete stack');
|
||||
toast.error((error as Error).message || 'Failed to delete stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -600,9 +860,9 @@ export default function EditorLayout() {
|
||||
await refreshStacks();
|
||||
// Auto-load the new stack in the editor pane
|
||||
await loadFile(stackName);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to create stack:', error);
|
||||
toast.error(error.message || 'Failed to create stack');
|
||||
toast.error((error as Error).message || 'Failed to create stack');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -668,7 +928,7 @@ export default function EditorLayout() {
|
||||
{/* Branding Header */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/sencho-logo.png" alt="Sencho Logo" className="w-12 h-12" />
|
||||
<img src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'} alt="Sencho Logo" className="w-12 h-12" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">Sencho</h1>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
@@ -709,7 +969,7 @@ export default function EditorLayout() {
|
||||
<SelectItem key={node.id} value={node.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-green-500' :
|
||||
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
{node.name}
|
||||
</div>
|
||||
@@ -819,7 +1079,7 @@ export default function EditorLayout() {
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top Header Bar */}
|
||||
<div className="h-16 flex items-center justify-between px-6 border-b border-border gap-4">
|
||||
{/* Node Context Pill — visible only when a remote node is active */}
|
||||
{/* Node Context Pill - visible only when a remote node is active */}
|
||||
<div className="flex-shrink-0">
|
||||
{activeNode?.type === 'remote' ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium">
|
||||
@@ -834,156 +1094,161 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Home Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => {
|
||||
setSelectedFile(null);
|
||||
setContent('');
|
||||
setOriginalContent('');
|
||||
setEnvContent('');
|
||||
setOriginalEnvContent('');
|
||||
setEnvFiles([]);
|
||||
setSelectedEnvFile('');
|
||||
setEnvExists(false);
|
||||
setContainers([]);
|
||||
setIsEditing(false);
|
||||
setActiveView('dashboard');
|
||||
}}
|
||||
title="Go to Home Dashboard"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Home
|
||||
</Button>
|
||||
{/* Console Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'host-console' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Console
|
||||
</Button>
|
||||
{/* Resources Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('resources')}
|
||||
title="System Resources"
|
||||
>
|
||||
<HardDrive className="w-4 h-4 mr-2" />
|
||||
Resources
|
||||
</Button>
|
||||
{/* App Store Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('templates')}
|
||||
title="App Store"
|
||||
>
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
App Store
|
||||
</Button>
|
||||
{/* Global Observability Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('global-observability')}
|
||||
title="Global Logs"
|
||||
>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
{/* Home Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => {
|
||||
setSelectedFile(null);
|
||||
setContent('');
|
||||
setOriginalContent('');
|
||||
setEnvContent('');
|
||||
setOriginalEnvContent('');
|
||||
setEnvFiles([]);
|
||||
setSelectedEnvFile('');
|
||||
setEnvExists(false);
|
||||
setContainers([]);
|
||||
setIsEditing(false);
|
||||
setActiveView('dashboard');
|
||||
}}
|
||||
title="Go to Home Dashboard"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Home
|
||||
</Button>
|
||||
{/* Console Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'host-console' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Console
|
||||
</Button>
|
||||
{/* Resources Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'resources' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'resources' ? (selectedFile ? 'editor' : 'dashboard') : 'resources')}
|
||||
title="System Resources"
|
||||
>
|
||||
<HardDrive className="w-4 h-4 mr-2" />
|
||||
Resources
|
||||
</Button>
|
||||
{/* App Store Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'templates' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'templates' ? (selectedFile ? 'editor' : 'dashboard') : 'templates')}
|
||||
title="App Store"
|
||||
>
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
App Store
|
||||
</Button>
|
||||
{/* Global Observability Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'global-observability' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'global-observability' ? (selectedFile ? 'editor' : 'dashboard') : 'global-observability')}
|
||||
title="Global Logs"
|
||||
>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
|
||||
{/* Settings Modal Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
title="Notification Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
{/* Settings Modal Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
title="Notification Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
{/* Notifications Popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
|
||||
<Bell className="w-4 h-4" />
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h4 className="font-semibold">Notifications</h4>
|
||||
<div className="flex gap-2">
|
||||
{/* Notifications Popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
|
||||
<Bell className="w-4 h-4" />
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
|
||||
Mark all as read
|
||||
</Button>
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{notifications.map((notif: any) => (
|
||||
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
|
||||
<div className="flex items-center gap-2 mb-1 pr-6">
|
||||
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
|
||||
{notif.level}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(notif.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium pr-6">{notif.message}</p>
|
||||
|
||||
{/* Delete individual notification button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNotification(notif.id);
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h4 className="font-semibold">Notifications</h4>
|
||||
<div className="flex gap-2">
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<ScrollArea className="h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{notifications.map((notif) => (
|
||||
<div key={`${notif.nodeId}-${notif.id}`} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
|
||||
<div className="flex items-center gap-2 mb-1 pr-6">
|
||||
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
|
||||
{notif.level}
|
||||
</Badge>
|
||||
{nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{notif.nodeName}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(notif.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium pr-6">{notif.message}</p>
|
||||
|
||||
{/* Delete individual notification button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNotification(notif);
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>{/* end right-side buttons */}
|
||||
</div>
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
||||
{activeView === 'templates' ? (
|
||||
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
|
||||
) : activeView === 'resources' ? (
|
||||
@@ -1173,8 +1438,18 @@ export default function EditorLayout() {
|
||||
<div className="flex items-center gap-4">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
|
||||
<TabsList className="bg-muted">
|
||||
<TabsTrigger value="compose" className="rounded-lg">compose.yaml</TabsTrigger>
|
||||
<TabsTrigger value="env" disabled={!envExists} className="rounded-lg">.env</TabsTrigger>
|
||||
<TabsTrigger value="compose" className="relative rounded-lg data-[state=active]:bg-transparent data-[state=active]:shadow-none">
|
||||
{activeTab === 'compose' && (
|
||||
<motion.div layoutId="editor-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 400, damping: 30 }} />
|
||||
)}
|
||||
<span className="relative z-10">compose.yaml</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="env" disabled={!envExists} className="relative rounded-lg data-[state=active]:bg-transparent data-[state=active]:shadow-none">
|
||||
{activeTab === 'env' && (
|
||||
<motion.div layoutId="editor-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 400, damping: 30 }} />
|
||||
)}
|
||||
<span className="relative z-10">.env</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -1225,13 +1500,14 @@ export default function EditorLayout() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{!isFileLoading && (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
|
||||
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
||||
value={activeTab === 'compose' ? safeContent : safeEnvContent}
|
||||
onMount={(editor) => { monacoEditorRef.current = editor; }}
|
||||
onChange={(value) => {
|
||||
if (!isEditing) return; // Prevent changes in view mode
|
||||
if (activeTab === 'compose') {
|
||||
@@ -1308,8 +1584,8 @@ export default function EditorLayout() {
|
||||
<SettingsModal
|
||||
isOpen={settingsModalOpen}
|
||||
onClose={() => setSettingsModalOpen(false)}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
/>
|
||||
|
||||
{/* Stack Alert Sheet */}
|
||||
|
||||
@@ -7,6 +7,12 @@ import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
// Max entries held in React state. Bounds SSE-mode memory growth.
|
||||
const MAX_LOG_ENTRIES = 2000;
|
||||
// Max rows rendered as DOM nodes at once. Prevents the renderer from
|
||||
// creating thousands of DOM nodes that OOM the browser on RAM-constrained hosts.
|
||||
const MAX_DISPLAY_ROWS = 300;
|
||||
|
||||
|
||||
interface LogEntry {
|
||||
stackName: string;
|
||||
@@ -15,6 +21,9 @@ interface LogEntry {
|
||||
level: string;
|
||||
message: string;
|
||||
timestampMs: number;
|
||||
// Assigned client-side at ingestion. Gives React a stable, collision-free
|
||||
// key so the slice window can shift without touching existing DOM nodes.
|
||||
_id: number;
|
||||
}
|
||||
|
||||
export function GlobalObservabilityView() {
|
||||
@@ -37,6 +46,9 @@ export function GlobalObservabilityView() {
|
||||
|
||||
// SSE throttle buffer
|
||||
const bufferRef = useRef<LogEntry[]>([]);
|
||||
// Monotonic counter for stable React keys. Incremented once per log entry
|
||||
// at ingestion so duplicate-content lines never share a key.
|
||||
const logIdRef = useRef(0);
|
||||
|
||||
// Fetch settings on mount
|
||||
useEffect(() => {
|
||||
@@ -81,8 +93,9 @@ export function GlobalObservabilityView() {
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const entry: LogEntry = JSON.parse(event.data);
|
||||
entry._id = ++logIdRef.current;
|
||||
bufferRef.current.push(entry);
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
@@ -96,7 +109,7 @@ export function GlobalObservabilityView() {
|
||||
setLogs(prev => {
|
||||
const merged = [...prev, ...batch];
|
||||
merged.sort((a, b) => a.timestampMs - b.timestampMs);
|
||||
return merged.slice(-10000);
|
||||
return merged.slice(-MAX_LOG_ENTRIES);
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
@@ -115,7 +128,11 @@ export function GlobalObservabilityView() {
|
||||
try {
|
||||
const logsRes = await apiFetch('/logs/global');
|
||||
if (logsRes.ok) {
|
||||
setLogs(await logsRes.json());
|
||||
const data: LogEntry[] = await logsRes.json();
|
||||
// Stamp each entry with a monotonic _id at ingestion so React
|
||||
// has a stable, collision-free key for every log line.
|
||||
data.forEach(entry => { entry._id = ++logIdRef.current; });
|
||||
setLogs(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch global logs:', error);
|
||||
@@ -156,8 +173,10 @@ export function GlobalObservabilityView() {
|
||||
}, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoScrollEnabled) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (isAutoScrollEnabled && bottomRef.current) {
|
||||
// Use instant scroll to avoid stacking smooth-scroll animations on every
|
||||
// 5-second poll cycle, which wastes layout work and renderer memory.
|
||||
bottomRef.current.scrollIntoView({ behavior: 'instant' });
|
||||
}
|
||||
}, [filteredLogs, isAutoScrollEnabled]);
|
||||
|
||||
@@ -224,7 +243,7 @@ export function GlobalObservabilityView() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Select value={streamFilter} onValueChange={(val: any) => setStreamFilter(val)}>
|
||||
<Select value={streamFilter} onValueChange={(val) => setStreamFilter(val as 'ALL' | 'STDOUT' | 'STDERR')}>
|
||||
<SelectTrigger className="w-[110px] h-8 text-sm">
|
||||
<SelectValue placeholder="Stream" />
|
||||
</SelectTrigger>
|
||||
@@ -258,8 +277,13 @@ export function GlobalObservabilityView() {
|
||||
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
|
||||
{filteredLogs.length > 0 ? (
|
||||
<>
|
||||
{filteredLogs.map((log, idx) => (
|
||||
<div key={idx} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||
{filteredLogs.length > MAX_DISPLAY_ROWS && (
|
||||
<div className="text-gray-600 italic text-xs text-center mb-3 py-1 border-b border-gray-800">
|
||||
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
|
||||
</div>
|
||||
)}
|
||||
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
|
||||
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
||||
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
|
||||
<span className={`mr-2 font-bold ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-green-500'}`}>{log.level}:</span>
|
||||
|
||||
@@ -13,9 +13,16 @@ import { Label } from './ui/label';
|
||||
|
||||
interface Stats {
|
||||
active: number;
|
||||
managed: number;
|
||||
unmanaged: number;
|
||||
exited: number;
|
||||
total: number;
|
||||
inactive: number;
|
||||
}
|
||||
|
||||
interface MetricPoint {
|
||||
timestamp: number;
|
||||
cpu_percent: number;
|
||||
memory_mb: number;
|
||||
}
|
||||
|
||||
interface SystemStats {
|
||||
@@ -60,13 +67,13 @@ export default function HomeDashboard() {
|
||||
const [convertedYaml, setConvertedYaml] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newStackName, setNewStackName] = useState('');
|
||||
const [stats, setStats] = useState<Stats>({ active: 0, exited: 0, total: 0, inactive: 0 });
|
||||
const [stats, setStats] = useState<Stats>({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 });
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
|
||||
|
||||
// Fetch container stats - re-runs when active node changes so stale data is cleared immediately
|
||||
useEffect(() => {
|
||||
setStats({ active: 0, exited: 0, total: 0, inactive: 0 });
|
||||
setStats({ active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 });
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stats');
|
||||
@@ -174,14 +181,20 @@ export default function HomeDashboard() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stackName }),
|
||||
});
|
||||
if (!createResponse.ok) throw new Error('Failed to create stack');
|
||||
if (!createResponse.ok) {
|
||||
const err = await createResponse.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to create stack');
|
||||
}
|
||||
|
||||
// Save the converted YAML content
|
||||
const saveResponse = await apiFetch(`/stacks/${stackName}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content: convertedYaml }),
|
||||
});
|
||||
if (!saveResponse.ok) throw new Error('Failed to save stack content');
|
||||
if (!saveResponse.ok) {
|
||||
const err = await saveResponse.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to save stack content');
|
||||
}
|
||||
|
||||
setCreateDialogOpen(false);
|
||||
setNewStackName('');
|
||||
@@ -190,7 +203,7 @@ export default function HomeDashboard() {
|
||||
window.location.reload(); // Refresh to show new stack
|
||||
} catch (error) {
|
||||
console.error('Failed to create stack:', error);
|
||||
toast.error('Failed to create stack');
|
||||
toast.error((error as Error).message || 'Failed to create stack');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,7 +222,9 @@ export default function HomeDashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-500">{stats.active}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Currently running</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.managed} managed · {stats.unmanaged} external
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
<span>Host Console</span>
|
||||
{activeNode && (
|
||||
<span className="text-muted-foreground font-normal text-sm">
|
||||
— {activeNode.name}
|
||||
- {activeNode.name}
|
||||
</span>
|
||||
)}
|
||||
{stackName && (
|
||||
|
||||
@@ -24,6 +24,7 @@ export function LogViewer({ containerId, containerName, isOpen, onClose }: LogVi
|
||||
useEffect(() => {
|
||||
if (!isOpen || !containerId) return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLogs([]);
|
||||
setIsConnected(false);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function NodeManager() {
|
||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||
const [deletingNode, setDeletingNode] = useState<Node | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ nodeId: number; info: { serverVersion?: string; os?: string; architecture?: string; containers?: number; images?: number; cpus?: number } } | null>(null);
|
||||
|
||||
// Node token generation state
|
||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||
@@ -84,8 +84,8 @@ export function NodeManager() {
|
||||
}
|
||||
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to create node');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to create node');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,8 +105,8 @@ export function NodeManager() {
|
||||
setEditingNodeId(null);
|
||||
setFormData(defaultFormData);
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update node');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to update node');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,8 +135,8 @@ export function NodeManager() {
|
||||
setDeleteOpen(false);
|
||||
setDeletingNode(null);
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete node');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to delete node');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,8 +153,8 @@ export function NodeManager() {
|
||||
toast.error(`Failed to connect: ${result.error}`);
|
||||
}
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Connection test failed');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Connection test failed');
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
@@ -169,8 +169,8 @@ export function NodeManager() {
|
||||
const { token } = await res.json();
|
||||
setGeneratedToken(token);
|
||||
toast.success('Node token generated');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to generate token');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to generate token');
|
||||
} finally {
|
||||
setGeneratingToken(false);
|
||||
}
|
||||
@@ -247,13 +247,13 @@ export function NodeManager() {
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local — Docker socket on this machine
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote — another Sencho instance
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -11,9 +11,10 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Trash2, HelpCircle } from 'lucide-react';
|
||||
import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface StackAlert {
|
||||
id?: number;
|
||||
@@ -31,9 +32,23 @@ interface StackAlertSheetProps {
|
||||
stackName: string;
|
||||
}
|
||||
|
||||
interface AgentStatus {
|
||||
loading: boolean;
|
||||
hasEnabled: boolean;
|
||||
enabledTypes: string[];
|
||||
}
|
||||
|
||||
export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const [alerts, setAlerts] = useState<StackAlert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [agentStatus, setAgentStatus] = useState<AgentStatus>({
|
||||
loading: false,
|
||||
hasEnabled: false,
|
||||
enabledTypes: [],
|
||||
});
|
||||
|
||||
// New Alert Form State
|
||||
const [metric, setMetric] = useState('cpu_percent');
|
||||
@@ -45,18 +60,41 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
useEffect(() => {
|
||||
if (isOpen && stackName) {
|
||||
fetchAlerts();
|
||||
fetchAgentStatus();
|
||||
}
|
||||
}, [isOpen, stackName]);
|
||||
}, [isOpen, stackName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/alerts?stackName=${stackName}`);
|
||||
const res = await apiFetch(`/alerts?stackName=${encodeURIComponent(stackName)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAlerts(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch alerts', e);
|
||||
console.error('[StackAlertSheet] Failed to fetch alerts', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAgentStatus = async () => {
|
||||
setAgentStatus(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
// Always fetch agents from the active node (proxied via x-node-id for remote)
|
||||
const res = await apiFetch('/agents');
|
||||
if (res.ok) {
|
||||
const agents: Array<{ type: string; enabled: boolean }> = await res.json();
|
||||
const enabled = agents.filter(a => a.enabled);
|
||||
setAgentStatus({
|
||||
loading: false,
|
||||
hasEnabled: enabled.length > 0,
|
||||
enabledTypes: enabled.map(a => a.type),
|
||||
});
|
||||
} else {
|
||||
setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[StackAlertSheet] Failed to fetch agent status', e);
|
||||
setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,23 +111,26 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
operator,
|
||||
threshold: parseFloat(threshold),
|
||||
duration_mins: parseInt(duration, 10),
|
||||
cooldown_mins: parseInt(cooldown, 10)
|
||||
cooldown_mins: parseInt(cooldown, 10),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/alerts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newAlert)
|
||||
body: JSON.stringify(newAlert),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Alert rule added.');
|
||||
setThreshold('');
|
||||
fetchAlerts();
|
||||
} else {
|
||||
toast.error('Failed to add alert rule.');
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to add alert rule.');
|
||||
console.error('[StackAlertSheet] addAlert failed:', err);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
console.error('[StackAlertSheet] addAlert threw:', e);
|
||||
toast.error('Network error. Could not reach the node.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -103,10 +144,11 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
toast.success('Alert rule deleted.');
|
||||
fetchAlerts();
|
||||
} else {
|
||||
toast.error('Failed to delete alert rule.');
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to delete alert rule.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} catch {
|
||||
toast.error('Network error. Could not reach the node.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -118,12 +160,81 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
memory_mb: 'Memory Usage (MB)',
|
||||
net_rx: 'Network In (MB)',
|
||||
net_tx: 'Network Out (MB)',
|
||||
restart_count: 'Restart Count'
|
||||
restart_count: 'Restart Count',
|
||||
};
|
||||
|
||||
const agentTypeLabels: Record<string, string> = {
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
const renderAgentStatusBanner = () => {
|
||||
if (agentStatus.loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50 border text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
<span>Checking notification channels…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20 text-sm">
|
||||
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-blue-700 dark:text-blue-400">
|
||||
Remote node: <span className="font-semibold">{activeNode?.name}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.
|
||||
</p>
|
||||
{!agentStatus.hasEnabled && (
|
||||
<p className="text-amber-600 dark:text-amber-400 font-medium mt-1">
|
||||
No notification channels are configured on this remote node. Open Settings → Notifications to configure them.
|
||||
</p>
|
||||
)}
|
||||
{agentStatus.hasEnabled && (
|
||||
<p className="text-green-600 dark:text-green-400 font-medium mt-1">
|
||||
Active channels: {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agentStatus.hasEnabled) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-700 dark:text-amber-400">No notification channels configured</p>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '}
|
||||
<span className="font-medium">Settings → Notifications</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">
|
||||
Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-[400px]">
|
||||
<SheetContent className="overflow-y-auto sm:max-w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Stack Alerts: {stackName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
@@ -132,7 +243,10 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
</SheetHeader>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="mt-4 space-y-5">
|
||||
{/* Notification agent status banner */}
|
||||
{renderAgentStatusBanner()}
|
||||
|
||||
{/* List Existing Alerts */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Existing Rules</h4>
|
||||
@@ -299,7 +413,11 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
</div>
|
||||
|
||||
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
|
||||
Add Rule
|
||||
{isLoading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</>
|
||||
) : (
|
||||
'Add Rule'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AnyProps = Record<string, unknown>;
|
||||
|
||||
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
|
||||
HTMLMotionProps<keyof HTMLElementTagNameMap>,
|
||||
'ref'
|
||||
> & { ref?: React.Ref<T> };
|
||||
|
||||
type WithAsChild<Base extends object> =
|
||||
| (Base & { asChild: true; children: React.ReactElement })
|
||||
| (Base & { asChild?: false | undefined });
|
||||
|
||||
type SlotProps<T extends HTMLElement = HTMLElement> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any;
|
||||
} & DOMMotionProps<T>;
|
||||
|
||||
function mergeRefs<T>(
|
||||
...refs: (React.Ref<T> | undefined)[]
|
||||
): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else {
|
||||
(ref as React.RefObject<T | null>).current = node;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps<T extends HTMLElement>(
|
||||
childProps: AnyProps,
|
||||
slotProps: DOMMotionProps<T>,
|
||||
): AnyProps {
|
||||
const merged: AnyProps = { ...childProps, ...slotProps };
|
||||
|
||||
if (childProps.className || slotProps.className) {
|
||||
merged.className = cn(
|
||||
childProps.className as string,
|
||||
slotProps.className as string,
|
||||
);
|
||||
}
|
||||
|
||||
if (childProps.style || slotProps.style) {
|
||||
merged.style = {
|
||||
...(childProps.style as React.CSSProperties),
|
||||
...(slotProps.style as React.CSSProperties),
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function Slot<T extends HTMLElement = HTMLElement>({
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
}: SlotProps<T>) {
|
||||
const isAlreadyMotion =
|
||||
typeof children.type === 'object' &&
|
||||
children.type !== null &&
|
||||
isMotionComponent(children.type);
|
||||
|
||||
const Base = React.useMemo(
|
||||
() =>
|
||||
isAlreadyMotion
|
||||
? (children.type as React.ElementType)
|
||||
: motion.create(children.type as React.ElementType),
|
||||
[isAlreadyMotion, children.type],
|
||||
);
|
||||
|
||||
if (!React.isValidElement(children)) return null;
|
||||
|
||||
const { ref: childRef, ...childProps } = children.props as AnyProps;
|
||||
|
||||
const mergedProps = mergeProps(childProps, props);
|
||||
|
||||
return (
|
||||
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Slot,
|
||||
type SlotProps,
|
||||
type WithAsChild,
|
||||
type DOMMotionProps,
|
||||
type AnyProps,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
type HTMLMotionProps,
|
||||
type LegacyAnimationControls,
|
||||
type TargetAndTransition,
|
||||
type Transition,
|
||||
} from 'motion/react';
|
||||
|
||||
import { useAutoHeight } from '@/hooks/use-auto-height';
|
||||
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type AutoHeightProps = WithAsChild<
|
||||
{
|
||||
children: React.ReactNode;
|
||||
deps?: React.DependencyList;
|
||||
animate?: TargetAndTransition | LegacyAnimationControls;
|
||||
transition?: Transition;
|
||||
} & Omit<HTMLMotionProps<'div'>, 'animate'>
|
||||
>;
|
||||
|
||||
function AutoHeight({
|
||||
children,
|
||||
deps = [],
|
||||
transition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
bounce: 0,
|
||||
restDelta: 0.01,
|
||||
},
|
||||
style,
|
||||
animate,
|
||||
asChild = false,
|
||||
...props
|
||||
}: AutoHeightProps) {
|
||||
const { ref, height } = useAutoHeight<HTMLDivElement>(deps);
|
||||
|
||||
const Comp = asChild ? Slot : motion.div;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
style={{ overflow: 'hidden', ...style }}
|
||||
animate={{ height, ...animate }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
export { AutoHeight, type AutoHeightProps };
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type FadeProps = WithAsChild<
|
||||
{
|
||||
children?: React.ReactNode;
|
||||
delay?: number;
|
||||
initialOpacity?: number;
|
||||
opacity?: number;
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
} & UseIsInViewOptions &
|
||||
HTMLMotionProps<'div'>
|
||||
>;
|
||||
|
||||
function Fade({
|
||||
ref,
|
||||
transition = { type: 'spring', stiffness: 200, damping: 20 },
|
||||
delay = 0,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
initialOpacity = 0,
|
||||
opacity = 1,
|
||||
asChild = false,
|
||||
...props
|
||||
}: FadeProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const Component = asChild ? Slot : motion.div;
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={localRef as React.Ref<HTMLDivElement>}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={{
|
||||
hidden: { opacity: initialOpacity },
|
||||
visible: { opacity },
|
||||
}}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + delay / 1000,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FadeListProps = Omit<FadeProps, 'children'> & {
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
holdDelay?: number;
|
||||
};
|
||||
|
||||
function Fades({
|
||||
children,
|
||||
delay = 0,
|
||||
holdDelay = 0,
|
||||
...props
|
||||
}: FadeListProps) {
|
||||
const array = React.Children.toArray(children) as React.ReactElement[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{array.map((child, index) => (
|
||||
<Fade
|
||||
key={child.key ?? index}
|
||||
delay={delay + index * holdDelay}
|
||||
{...props}
|
||||
>
|
||||
{child}
|
||||
</Fade>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Fade, Fades, type FadeProps, type FadeListProps };
|
||||
@@ -0,0 +1,641 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, motion, type Transition } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HighlightMode = 'children' | 'parent';
|
||||
|
||||
type Bounds = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const DEFAULT_BOUNDS_OFFSET: Bounds = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
type HighlightContextType<T extends string> = {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
mode: HighlightMode;
|
||||
activeValue: T | null;
|
||||
setActiveValue: (value: T | null) => void;
|
||||
setBounds: (bounds: DOMRect) => void;
|
||||
clearBounds: () => void;
|
||||
id: string;
|
||||
hover: boolean;
|
||||
click: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
activeClassName?: string;
|
||||
setActiveClassName: (className: string) => void;
|
||||
transition?: Transition;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
const HighlightContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
HighlightContextType<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
function useHighlight<T extends string>(): HighlightContextType<T> {
|
||||
const context = React.useContext(HighlightContext);
|
||||
if (!context) {
|
||||
throw new Error('useHighlight must be used within a HighlightProvider');
|
||||
}
|
||||
return context as unknown as HighlightContextType<T>;
|
||||
}
|
||||
|
||||
type BaseHighlightProps<T extends React.ElementType = 'div'> = {
|
||||
as?: T;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
mode?: HighlightMode;
|
||||
value?: string | null;
|
||||
defaultValue?: string | null;
|
||||
onValueChange?: (value: string | null) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
transition?: Transition;
|
||||
hover?: boolean;
|
||||
click?: boolean;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
};
|
||||
|
||||
type ParentModeHighlightProps = {
|
||||
boundsOffset?: Partial<Bounds>;
|
||||
containerClassName?: string;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
type ControlledParentModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> &
|
||||
ParentModeHighlightProps & {
|
||||
mode: 'parent';
|
||||
controlledItems: true;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type ControlledChildrenModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> & {
|
||||
mode?: 'children' | undefined;
|
||||
controlledItems: true;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type UncontrolledParentModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> &
|
||||
ParentModeHighlightProps & {
|
||||
mode: 'parent';
|
||||
controlledItems?: false;
|
||||
itemsClassName?: string;
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
};
|
||||
|
||||
type UncontrolledChildrenModeHighlightProps<
|
||||
T extends React.ElementType = 'div',
|
||||
> = BaseHighlightProps<T> & {
|
||||
mode?: 'children';
|
||||
controlledItems?: false;
|
||||
itemsClassName?: string;
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
};
|
||||
|
||||
type HighlightProps<T extends React.ElementType = 'div'> =
|
||||
| ControlledParentModeHighlightProps<T>
|
||||
| ControlledChildrenModeHighlightProps<T>
|
||||
| UncontrolledParentModeHighlightProps<T>
|
||||
| UncontrolledChildrenModeHighlightProps<T>;
|
||||
|
||||
function Highlight<T extends React.ElementType = 'div'>({
|
||||
ref,
|
||||
...props
|
||||
}: HighlightProps<T>) {
|
||||
const {
|
||||
as: Component = 'div',
|
||||
children,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
className,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
hover = false,
|
||||
click = true,
|
||||
enabled = true,
|
||||
controlledItems,
|
||||
disabled = false,
|
||||
exitDelay = 200,
|
||||
mode = 'children',
|
||||
} = props;
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
||||
|
||||
const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset;
|
||||
const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET;
|
||||
const boundsOffsetTop = boundsOffset.top ?? 0;
|
||||
const boundsOffsetLeft = boundsOffset.left ?? 0;
|
||||
const boundsOffsetWidth = boundsOffset.width ?? 0;
|
||||
const boundsOffsetHeight = boundsOffset.height ?? 0;
|
||||
|
||||
const boundsOffsetRef = React.useRef({
|
||||
top: boundsOffsetTop,
|
||||
left: boundsOffsetLeft,
|
||||
width: boundsOffsetWidth,
|
||||
height: boundsOffsetHeight,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
boundsOffsetRef.current = {
|
||||
top: boundsOffsetTop,
|
||||
left: boundsOffsetLeft,
|
||||
width: boundsOffsetWidth,
|
||||
height: boundsOffsetHeight,
|
||||
};
|
||||
}, [
|
||||
boundsOffsetTop,
|
||||
boundsOffsetLeft,
|
||||
boundsOffsetWidth,
|
||||
boundsOffsetHeight,
|
||||
]);
|
||||
|
||||
const [activeValue, setActiveValue] = React.useState<string | null>(
|
||||
value ?? defaultValue ?? null,
|
||||
);
|
||||
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
|
||||
const [activeClassNameState, setActiveClassNameState] =
|
||||
React.useState<string>('');
|
||||
|
||||
const safeSetActiveValue = (id: string | null) => {
|
||||
setActiveValue((prev) => {
|
||||
if (prev !== id) {
|
||||
onValueChange?.(id);
|
||||
return id;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const safeSetBoundsRef = React.useRef<
|
||||
((bounds: DOMRect) => void) | undefined
|
||||
>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
safeSetBoundsRef.current = (bounds: DOMRect) => {
|
||||
if (!localRef.current) return;
|
||||
|
||||
const containerRect = localRef.current.getBoundingClientRect();
|
||||
const offset = boundsOffsetRef.current;
|
||||
const newBounds: Bounds = {
|
||||
top: bounds.top - containerRect.top + offset.top,
|
||||
left: bounds.left - containerRect.left + offset.left,
|
||||
width: bounds.width + offset.width,
|
||||
height: bounds.height + offset.height,
|
||||
};
|
||||
|
||||
setBoundsState((prev) => {
|
||||
if (
|
||||
prev &&
|
||||
prev.top === newBounds.top &&
|
||||
prev.left === newBounds.left &&
|
||||
prev.width === newBounds.width &&
|
||||
prev.height === newBounds.height
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return newBounds;
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const safeSetBounds = (bounds: DOMRect) => {
|
||||
safeSetBoundsRef.current?.(bounds);
|
||||
};
|
||||
|
||||
const clearBounds = React.useCallback(() => {
|
||||
setBoundsState((prev) => (prev === null ? prev : null));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined) setActiveValue(value);
|
||||
else if (defaultValue !== undefined) setActiveValue(defaultValue);
|
||||
}, [value, defaultValue]);
|
||||
|
||||
const id = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return;
|
||||
const container = localRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (!activeValue) return;
|
||||
const activeEl = container.querySelector<HTMLElement>(
|
||||
`[data-value="${activeValue}"][data-highlight="true"]`,
|
||||
);
|
||||
if (activeEl)
|
||||
safeSetBoundsRef.current?.(activeEl.getBoundingClientRect());
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', onScroll);
|
||||
}, [mode, activeValue]);
|
||||
|
||||
const render = (children: React.ReactNode) => {
|
||||
if (mode === 'parent') {
|
||||
return (
|
||||
<Component
|
||||
ref={localRef}
|
||||
data-slot="motion-highlight-container"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
className={(props as ParentModeHighlightProps)?.containerClassName}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{boundsState && (
|
||||
<motion.div
|
||||
data-slot="motion-highlight"
|
||||
animate={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
initial={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + (exitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
transition={transition}
|
||||
style={{ position: 'absolute', zIndex: 0, ...style }}
|
||||
className={cn(className, activeClassNameState)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<HighlightContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
activeValue,
|
||||
setActiveValue: safeSetActiveValue,
|
||||
id,
|
||||
hover,
|
||||
click,
|
||||
className,
|
||||
style,
|
||||
transition,
|
||||
disabled,
|
||||
enabled,
|
||||
exitDelay,
|
||||
setBounds: safeSetBounds,
|
||||
clearBounds,
|
||||
activeClassName: activeClassNameState,
|
||||
setActiveClassName: setActiveClassNameState,
|
||||
forceUpdateBounds: (props as ParentModeHighlightProps)
|
||||
?.forceUpdateBounds,
|
||||
}}
|
||||
>
|
||||
{enabled
|
||||
? controlledItems
|
||||
? render(children)
|
||||
: render(
|
||||
React.Children.map(children, (child, index) => (
|
||||
<HighlightItem key={index} className={props?.itemsClassName}>
|
||||
{child}
|
||||
</HighlightItem>
|
||||
)),
|
||||
)
|
||||
: children}
|
||||
</HighlightContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function getNonOverridingDataAttributes(
|
||||
element: React.ReactElement,
|
||||
dataAttributes: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return Object.keys(dataAttributes).reduce<Record<string, unknown>>(
|
||||
(acc, key) => {
|
||||
if ((element.props as Record<string, unknown>)[key] === undefined) {
|
||||
acc[key] = dataAttributes[key];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
type ExtendedChildProps = React.ComponentProps<'div'> & {
|
||||
id?: string;
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
'data-active'?: string;
|
||||
'data-value'?: string;
|
||||
'data-disabled'?: boolean;
|
||||
'data-highlight'?: boolean;
|
||||
'data-slot'?: string;
|
||||
};
|
||||
|
||||
type HighlightItemProps<T extends React.ElementType = 'div'> =
|
||||
React.ComponentProps<T> & {
|
||||
as?: T;
|
||||
children: React.ReactElement;
|
||||
id?: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
transition?: Transition;
|
||||
activeClassName?: string;
|
||||
disabled?: boolean;
|
||||
exitDelay?: number;
|
||||
asChild?: boolean;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
function HighlightItem<T extends React.ElementType>({
|
||||
ref,
|
||||
as,
|
||||
children,
|
||||
id,
|
||||
value,
|
||||
className,
|
||||
style,
|
||||
transition,
|
||||
disabled = false,
|
||||
activeClassName,
|
||||
exitDelay,
|
||||
asChild = false,
|
||||
forceUpdateBounds,
|
||||
...props
|
||||
}: HighlightItemProps<T>) {
|
||||
const itemId = React.useId();
|
||||
const {
|
||||
activeValue,
|
||||
setActiveValue,
|
||||
mode,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
hover,
|
||||
click,
|
||||
enabled,
|
||||
className: contextClassName,
|
||||
style: contextStyle,
|
||||
transition: contextTransition,
|
||||
id: contextId,
|
||||
disabled: contextDisabled,
|
||||
exitDelay: contextExitDelay,
|
||||
forceUpdateBounds: contextForceUpdateBounds,
|
||||
setActiveClassName,
|
||||
} = useHighlight();
|
||||
|
||||
const Component = as ?? 'div';
|
||||
const element = children as React.ReactElement<ExtendedChildProps>;
|
||||
const childValue =
|
||||
id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId;
|
||||
const isActive = activeValue === childValue;
|
||||
const isDisabled = disabled === undefined ? contextDisabled : disabled;
|
||||
const itemTransition = transition ?? contextTransition;
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
||||
|
||||
const refCallback = React.useCallback((node: HTMLElement | null) => {
|
||||
localRef.current = node as HTMLDivElement;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return;
|
||||
let rafId: number;
|
||||
let previousBounds: Bounds | null = null;
|
||||
const shouldUpdateBounds =
|
||||
forceUpdateBounds === true ||
|
||||
(contextForceUpdateBounds && forceUpdateBounds !== false);
|
||||
|
||||
const updateBounds = () => {
|
||||
if (!localRef.current) return;
|
||||
|
||||
const bounds = localRef.current.getBoundingClientRect();
|
||||
|
||||
if (shouldUpdateBounds) {
|
||||
if (
|
||||
previousBounds &&
|
||||
previousBounds.top === bounds.top &&
|
||||
previousBounds.left === bounds.left &&
|
||||
previousBounds.width === bounds.width &&
|
||||
previousBounds.height === bounds.height
|
||||
) {
|
||||
rafId = requestAnimationFrame(updateBounds);
|
||||
return;
|
||||
}
|
||||
previousBounds = bounds;
|
||||
rafId = requestAnimationFrame(updateBounds);
|
||||
}
|
||||
|
||||
setBounds(bounds);
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
updateBounds();
|
||||
setActiveClassName(activeClassName ?? '');
|
||||
} else if (!activeValue) clearBounds();
|
||||
|
||||
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
|
||||
}, [
|
||||
mode,
|
||||
isActive,
|
||||
activeValue,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
activeClassName,
|
||||
setActiveClassName,
|
||||
forceUpdateBounds,
|
||||
contextForceUpdateBounds,
|
||||
]);
|
||||
|
||||
if (!React.isValidElement(children)) return children;
|
||||
|
||||
const dataAttributes = {
|
||||
'data-active': isActive ? 'true' : 'false',
|
||||
'aria-selected': isActive,
|
||||
'data-disabled': isDisabled,
|
||||
'data-value': childValue,
|
||||
'data-highlight': true,
|
||||
};
|
||||
|
||||
const commonHandlers = hover
|
||||
? {
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue);
|
||||
element.props.onMouseEnter?.(e);
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(null);
|
||||
element.props.onMouseLeave?.(e);
|
||||
},
|
||||
}
|
||||
: click
|
||||
? {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue);
|
||||
element.props.onClick?.(e);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
if (asChild) {
|
||||
if (mode === 'children') {
|
||||
return React.cloneElement(
|
||||
element,
|
||||
{
|
||||
key: childValue,
|
||||
ref: refCallback,
|
||||
className: cn('relative', element.props.className),
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item-container',
|
||||
}),
|
||||
...commonHandlers,
|
||||
...props,
|
||||
},
|
||||
<>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot="motion-highlight"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 0,
|
||||
...contextStyle,
|
||||
...style,
|
||||
}}
|
||||
className={cn(contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay:
|
||||
(itemTransition?.delay ?? 0) +
|
||||
(exitDelay ?? contextExitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Component
|
||||
data-slot="motion-highlight-item"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
className={className}
|
||||
{...dataAttributes}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
return React.cloneElement(element, {
|
||||
ref: refCallback,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item',
|
||||
}),
|
||||
...commonHandlers,
|
||||
});
|
||||
}
|
||||
|
||||
return enabled ? (
|
||||
<Component
|
||||
key={childValue}
|
||||
ref={localRef}
|
||||
data-slot="motion-highlight-item-container"
|
||||
className={cn(mode === 'children' && 'relative', className)}
|
||||
{...dataAttributes}
|
||||
{...props}
|
||||
{...commonHandlers}
|
||||
>
|
||||
{mode === 'children' && (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot="motion-highlight"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 0,
|
||||
...contextStyle,
|
||||
...style,
|
||||
}}
|
||||
className={cn(contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay:
|
||||
(itemTransition?.delay ?? 0) +
|
||||
(exitDelay ?? contextExitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{React.cloneElement(element, {
|
||||
style: { position: 'relative', zIndex: 1 },
|
||||
className: element.props.className,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item',
|
||||
}),
|
||||
})}
|
||||
</Component>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
useHighlight,
|
||||
type HighlightProps,
|
||||
type HighlightItemProps,
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
|
||||
type DialogContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: DialogProps['onOpenChange'];
|
||||
};
|
||||
|
||||
const [DialogProvider, useDialog] =
|
||||
getStrictContext<DialogContextType>('DialogContext');
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogProvider value={{ isOpen, setIsOpen }}>
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
|
||||
|
||||
function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type DialogPortalProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
const { isOpen } = useDialog();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
forceMount
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogOverlayProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Overlay>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DialogOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: DialogOverlayProps) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
|
||||
<motion.div
|
||||
key="dialog-overlay"
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogFlipDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
from?: DialogFlipDirection;
|
||||
};
|
||||
|
||||
function DialogContent({
|
||||
from = 'top',
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onInteractOutside,
|
||||
transition = { type: 'spring', stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const initialRotation =
|
||||
from === 'bottom' || from === 'left' ? '20deg' : '-20deg';
|
||||
const isVertical = from === 'top' || from === 'bottom';
|
||||
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
|
||||
|
||||
function DialogClose(props: DialogCloseProps) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
type DialogHeaderProps = React.ComponentProps<'div'>;
|
||||
|
||||
function DialogHeader(props: DialogHeaderProps) {
|
||||
return <div data-slot="dialog-header" {...props} />;
|
||||
}
|
||||
|
||||
type DialogFooterProps = React.ComponentProps<'div'>;
|
||||
|
||||
function DialogFooter(props: DialogFooterProps) {
|
||||
return <div data-slot="dialog-footer" {...props} />;
|
||||
}
|
||||
|
||||
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
|
||||
|
||||
function DialogTitle(props: DialogTitleProps) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" {...props} />;
|
||||
}
|
||||
|
||||
type DialogDescriptionProps = React.ComponentProps<
|
||||
typeof DialogPrimitive.Description
|
||||
>;
|
||||
|
||||
function DialogDescription(props: DialogDescriptionProps) {
|
||||
return (
|
||||
<DialogPrimitive.Description data-slot="dialog-description" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
useDialog,
|
||||
type DialogProps,
|
||||
type DialogTriggerProps,
|
||||
type DialogPortalProps,
|
||||
type DialogCloseProps,
|
||||
type DialogOverlayProps,
|
||||
type DialogContentProps,
|
||||
type DialogHeaderProps,
|
||||
type DialogFooterProps,
|
||||
type DialogTitleProps,
|
||||
type DialogDescriptionProps,
|
||||
type DialogContextType,
|
||||
type DialogFlipDirection,
|
||||
};
|
||||
@@ -0,0 +1,564 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
type HighlightItemProps,
|
||||
type HighlightProps,
|
||||
} from '@/components/animate-ui/primitives/effects/highlight';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { useDataState } from '@/hooks/use-data-state';
|
||||
|
||||
type DropdownMenuContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
highlightedValue: string | null;
|
||||
setHighlightedValue: (value: string | null) => void;
|
||||
};
|
||||
|
||||
type DropdownMenuSubContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
};
|
||||
|
||||
const [DropdownMenuProvider, useDropdownMenu] =
|
||||
getStrictContext<DropdownMenuContextType>('DropdownMenuContext');
|
||||
|
||||
const [DropdownMenuSubProvider, useDropdownMenuSub] =
|
||||
getStrictContext<DropdownMenuSubContextType>('DropdownMenuSubContext');
|
||||
|
||||
type DropdownMenuProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
>;
|
||||
|
||||
function DropdownMenu(props: DropdownMenuProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const [highlightedValue, setHighlightedValue] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuProvider
|
||||
value={{ isOpen, setIsOpen, highlightedValue, setHighlightedValue }}
|
||||
>
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DropdownMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuTriggerProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuPortalProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Portal
|
||||
>;
|
||||
|
||||
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuGroupProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Group
|
||||
>;
|
||||
|
||||
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Sub
|
||||
>;
|
||||
|
||||
function DropdownMenuSub(props: DropdownMenuSubProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuSubProvider value={{ isOpen, setIsOpen }}>
|
||||
<DropdownMenuPrimitive.Sub
|
||||
data-slot="dropdown-menu-sub"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DropdownMenuSubProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioGroupProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.RadioGroup
|
||||
>;
|
||||
|
||||
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
disabled,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuSubTriggerProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={highlightedRef}
|
||||
disabled={disabled}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
|
||||
'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
loop,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuSubContentProps) {
|
||||
const { isOpen } = useDropdownMenuSub();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
asChild
|
||||
forceMount
|
||||
loop={loop}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-sub-content"
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuHighlightProps = Omit<
|
||||
HighlightProps,
|
||||
'controlledItems' | 'enabled' | 'hover'
|
||||
> & {
|
||||
animateOnHover?: boolean;
|
||||
};
|
||||
|
||||
function DropdownMenuHighlight({
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
...props
|
||||
}: DropdownMenuHighlightProps) {
|
||||
const { highlightedValue } = useDropdownMenu();
|
||||
|
||||
return (
|
||||
<Highlight
|
||||
data-slot="dropdown-menu-highlight"
|
||||
click={false}
|
||||
controlledItems
|
||||
transition={transition}
|
||||
value={highlightedValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
|
||||
'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuContent({
|
||||
loop,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuContentProps) {
|
||||
const { isOpen } = useDropdownMenu();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
asChild
|
||||
loop={loop}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-content"
|
||||
data-slot="dropdown-menu-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuHighlightItemProps = HighlightItemProps;
|
||||
|
||||
function DropdownMenuHighlightItem(props: DropdownMenuHighlightItemProps) {
|
||||
return <HighlightItem data-slot="dropdown-menu-highlight-item" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Item>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuItem({
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={highlightedRef}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuCheckboxItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={highlightedRef}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
value,
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuRadioItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={highlightedRef}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuLabelProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Label
|
||||
>;
|
||||
|
||||
function DropdownMenuLabel(props: DropdownMenuLabelProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSeparatorProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
>;
|
||||
|
||||
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuShortcutProps = React.ComponentProps<'span'>;
|
||||
|
||||
function DropdownMenuShortcut(props: DropdownMenuShortcutProps) {
|
||||
return <span data-slot="dropdown-menu-shortcut" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemIndicatorProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.ItemIndicator>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuItemIndicator(props: DropdownMenuItemIndicatorProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.ItemIndicator
|
||||
data-slot="dropdown-menu-item-indicator"
|
||||
asChild
|
||||
>
|
||||
<motion.div {...props} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuHighlight,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuHighlightItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
useDropdownMenu,
|
||||
useDropdownMenuSub,
|
||||
type DropdownMenuProps,
|
||||
type DropdownMenuTriggerProps,
|
||||
type DropdownMenuHighlightProps,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuItemIndicatorProps,
|
||||
type DropdownMenuHighlightItemProps,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
type DropdownMenuRadioItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
type DropdownMenuSeparatorProps,
|
||||
type DropdownMenuShortcutProps,
|
||||
type DropdownMenuGroupProps,
|
||||
type DropdownMenuPortalProps,
|
||||
type DropdownMenuSubProps,
|
||||
type DropdownMenuSubContentProps,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
type DropdownMenuRadioGroupProps,
|
||||
type DropdownMenuContextType,
|
||||
type DropdownMenuSubContextType,
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { HoverCard as HoverCardPrimitive } from 'radix-ui';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
type MotionValue,
|
||||
type HTMLMotionProps,
|
||||
type SpringOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type HoverCardContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
const [HoverCardProvider, useHoverCard] =
|
||||
getStrictContext<HoverCardContextType>('HoverCardContext');
|
||||
|
||||
type HoverCardProps = React.ComponentProps<typeof HoverCardPrimitive.Root> & {
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
function HoverCard({
|
||||
followCursor = false,
|
||||
followCursorSpringOptions = { stiffness: 200, damping: 17 },
|
||||
...props
|
||||
}: HoverCardProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
return (
|
||||
<HoverCardProvider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
x,
|
||||
y,
|
||||
followCursor,
|
||||
followCursorSpringOptions,
|
||||
}}
|
||||
>
|
||||
<HoverCardPrimitive.Root
|
||||
data-slot="hover-card"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</HoverCardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardTriggerProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function HoverCardTrigger({ onMouseMove, ...props }: HoverCardTriggerProps) {
|
||||
const { x, y, followCursor } = useHoverCard();
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onMouseMove?.(event);
|
||||
|
||||
const target = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
if (followCursor === 'x' || followCursor === true) {
|
||||
const eventOffsetX = event.clientX - target.left;
|
||||
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
|
||||
x.set(offsetXFromCenter);
|
||||
}
|
||||
|
||||
if (followCursor === 'y' || followCursor === true) {
|
||||
const eventOffsetY = event.clientY - target.top;
|
||||
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
|
||||
y.set(offsetYFromCenter);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger
|
||||
data-slot="hover-card-trigger"
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardPortalProps = Omit<
|
||||
React.ComponentProps<typeof HoverCardPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function HoverCardPortal(props: HoverCardPortalProps) {
|
||||
const { isOpen } = useHoverCard();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<HoverCardPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="hover-card-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardContentProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Content
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function HoverCardContent({
|
||||
align,
|
||||
alignOffset,
|
||||
side,
|
||||
sideOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: HoverCardContentProps) {
|
||||
const { x, y, followCursor, followCursorSpringOptions } = useHoverCard();
|
||||
const translateX = useSpring(x, followCursorSpringOptions);
|
||||
const translateY = useSpring(y, followCursorSpringOptions);
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="hover-card-content"
|
||||
data-slot="hover-card-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
x:
|
||||
followCursor === 'x' || followCursor === true
|
||||
? translateX
|
||||
: undefined,
|
||||
y:
|
||||
followCursor === 'y' || followCursor === true
|
||||
? translateY
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardArrowProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Arrow
|
||||
>;
|
||||
|
||||
function HoverCardArrow(props: HoverCardArrowProps) {
|
||||
return <HoverCardPrimitive.Arrow data-slot="hover-card-arrow" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
HoverCard,
|
||||
HoverCardTrigger,
|
||||
HoverCardPortal,
|
||||
HoverCardContent,
|
||||
HoverCardArrow,
|
||||
useHoverCard,
|
||||
type HoverCardProps,
|
||||
type HoverCardTriggerProps,
|
||||
type HoverCardPortalProps,
|
||||
type HoverCardContentProps,
|
||||
type HoverCardArrowProps,
|
||||
type HoverCardContextType,
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Popover as PopoverPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type PopoverContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const [PopoverProvider, usePopover] =
|
||||
getStrictContext<PopoverContextType>('PopoverContext');
|
||||
|
||||
type PopoverProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
|
||||
|
||||
function Popover(props: PopoverProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<PopoverProvider value={{ isOpen, setIsOpen }}>
|
||||
<PopoverPrimitive.Root
|
||||
data-slot="popover"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</PopoverProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverTriggerProps = React.ComponentProps<
|
||||
typeof PopoverPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function PopoverTrigger(props: PopoverTriggerProps) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverPortalProps = Omit<
|
||||
React.ComponentProps<typeof PopoverPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function PopoverPortal(props: PopoverPortalProps) {
|
||||
const { isOpen } = usePopover();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<PopoverPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="popover-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverContentProps = Omit<
|
||||
React.ComponentProps<typeof PopoverPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function PopoverContent({
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
align,
|
||||
alignOffset,
|
||||
side,
|
||||
sideOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: PopoverContentProps) {
|
||||
return (
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="popover-content"
|
||||
data-slot="popover-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverAnchorProps = React.ComponentProps<typeof PopoverPrimitive.Anchor>;
|
||||
|
||||
function PopoverAnchor({ ...props }: PopoverAnchorProps) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverArrowProps = React.ComponentProps<typeof PopoverPrimitive.Arrow>;
|
||||
|
||||
function PopoverArrow(props: PopoverArrowProps) {
|
||||
return <PopoverPrimitive.Arrow data-slot="popover-arrow" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverCloseProps = React.ComponentProps<typeof PopoverPrimitive.Close>;
|
||||
|
||||
function PopoverClose(props: PopoverCloseProps) {
|
||||
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverPortal,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverClose,
|
||||
PopoverArrow,
|
||||
usePopover,
|
||||
type PopoverProps,
|
||||
type PopoverTriggerProps,
|
||||
type PopoverPortalProps,
|
||||
type PopoverContentProps,
|
||||
type PopoverAnchorProps,
|
||||
type PopoverCloseProps,
|
||||
type PopoverArrowProps,
|
||||
type PopoverContextType,
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type SheetContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const [SheetProvider, useSheet] =
|
||||
getStrictContext<SheetContextType>('SheetContext');
|
||||
|
||||
type SheetProps = React.ComponentProps<typeof SheetPrimitive.Root>;
|
||||
|
||||
function Sheet(props: SheetProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props.open,
|
||||
defaultValue: props.defaultOpen,
|
||||
onChange: props.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SheetProvider value={{ isOpen, setIsOpen }}>
|
||||
<SheetPrimitive.Root
|
||||
data-slot="sheet"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</SheetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetTriggerProps = React.ComponentProps<typeof SheetPrimitive.Trigger>;
|
||||
|
||||
function SheetTrigger(props: SheetTriggerProps) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type SheetCloseProps = React.ComponentProps<typeof SheetPrimitive.Close>;
|
||||
|
||||
function SheetClose(props: SheetCloseProps) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
type SheetPortalProps = React.ComponentProps<typeof SheetPrimitive.Portal>;
|
||||
|
||||
function SheetPortal(props: SheetPortalProps) {
|
||||
const { isOpen } = useSheet();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<SheetPrimitive.Portal forceMount data-slot="sheet-portal" {...props} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetOverlayProps = Omit<
|
||||
React.ComponentProps<typeof SheetPrimitive.Overlay>,
|
||||
'asChild' | 'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function SheetOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: SheetOverlayProps) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay asChild forceMount>
|
||||
<motion.div
|
||||
key="sheet-overlay"
|
||||
data-slot="sheet-overlay"
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</SheetPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type Side = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
type SheetContentProps = React.ComponentProps<typeof SheetPrimitive.Content> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
side?: Side;
|
||||
};
|
||||
|
||||
function SheetContent({
|
||||
side = 'right',
|
||||
transition = { type: 'spring', stiffness: 150, damping: 22 },
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
|
||||
|
||||
const offscreen: Record<Side, { x?: string; y?: string; opacity: number }> = {
|
||||
right: { x: '100%', opacity: 0 },
|
||||
left: { x: '-100%', opacity: 0 },
|
||||
top: { y: '-100%', opacity: 0 },
|
||||
bottom: { y: '100%', opacity: 0 },
|
||||
};
|
||||
|
||||
const positionStyle: Record<Side, React.CSSProperties> = {
|
||||
right: { insetBlock: 0, right: 0 },
|
||||
left: { insetBlock: 0, left: 0 },
|
||||
top: { insetInline: 0, top: 0 },
|
||||
bottom: { insetInline: 0, bottom: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetPrimitive.Content asChild forceMount {...props}>
|
||||
<motion.div
|
||||
key="sheet-content"
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
initial={offscreen[side]}
|
||||
animate={{ [axis]: 0, opacity: 1 }}
|
||||
exit={offscreen[side]}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
...positionStyle[side],
|
||||
...style,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</SheetPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetHeaderProps = React.ComponentProps<'div'>;
|
||||
|
||||
function SheetHeader(props: SheetHeaderProps) {
|
||||
return <div data-slot="sheet-header" {...props} />;
|
||||
}
|
||||
|
||||
type SheetFooterProps = React.ComponentProps<'div'>;
|
||||
|
||||
function SheetFooter(props: SheetFooterProps) {
|
||||
return <div data-slot="sheet-footer" {...props} />;
|
||||
}
|
||||
|
||||
type SheetTitleProps = React.ComponentProps<typeof SheetPrimitive.Title>;
|
||||
|
||||
function SheetTitle(props: SheetTitleProps) {
|
||||
return <SheetPrimitive.Title data-slot="sheet-title" {...props} />;
|
||||
}
|
||||
|
||||
type SheetDescriptionProps = React.ComponentProps<
|
||||
typeof SheetPrimitive.Description
|
||||
>;
|
||||
|
||||
function SheetDescription(props: SheetDescriptionProps) {
|
||||
return (
|
||||
<SheetPrimitive.Description data-slot="sheet-description" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useSheet,
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
type SheetProps,
|
||||
type SheetPortalProps,
|
||||
type SheetOverlayProps,
|
||||
type SheetTriggerProps,
|
||||
type SheetCloseProps,
|
||||
type SheetContentProps,
|
||||
type SheetHeaderProps,
|
||||
type SheetFooterProps,
|
||||
type SheetTitleProps,
|
||||
type SheetDescriptionProps,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Switch as SwitchPrimitives } from 'radix-ui';
|
||||
import {
|
||||
motion,
|
||||
type TargetAndTransition,
|
||||
type VariantLabels,
|
||||
type HTMLMotionProps,
|
||||
type LegacyAnimationControls,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type SwitchContextType = {
|
||||
isChecked: boolean;
|
||||
setIsChecked: (isChecked: boolean) => void;
|
||||
isPressed: boolean;
|
||||
setIsPressed: (isPressed: boolean) => void;
|
||||
};
|
||||
|
||||
const [SwitchProvider, useSwitch] =
|
||||
getStrictContext<SwitchContextType>('SwitchContext');
|
||||
|
||||
type SwitchProps = Omit<
|
||||
React.ComponentProps<typeof SwitchPrimitives.Root>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'button'>;
|
||||
|
||||
function Switch(props: SwitchProps) {
|
||||
// Destructure Radix-only props so they don't leak onto the motion.button DOM element
|
||||
const {
|
||||
checked,
|
||||
defaultChecked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
required,
|
||||
name,
|
||||
value,
|
||||
form,
|
||||
...motionProps
|
||||
} = props;
|
||||
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
const [isChecked, setIsChecked] = useControlledState({
|
||||
value: checked,
|
||||
defaultValue: defaultChecked,
|
||||
onChange: onCheckedChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SwitchProvider
|
||||
value={{ isChecked, setIsChecked, isPressed, setIsPressed }}
|
||||
>
|
||||
<SwitchPrimitives.Root
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={setIsChecked}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
name={name}
|
||||
value={value}
|
||||
form={form}
|
||||
asChild
|
||||
>
|
||||
<motion.button
|
||||
data-slot="switch"
|
||||
whileTap="tap"
|
||||
initial={false}
|
||||
onTapStart={() => setIsPressed(true)}
|
||||
onTapCancel={() => setIsPressed(false)}
|
||||
onTap={() => setIsPressed(false)}
|
||||
{...motionProps}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
</SwitchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchThumbProps = Omit<
|
||||
React.ComponentProps<typeof SwitchPrimitives.Thumb>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
pressedAnimation?:
|
||||
| TargetAndTransition
|
||||
| VariantLabels
|
||||
| boolean
|
||||
| LegacyAnimationControls;
|
||||
};
|
||||
|
||||
function SwitchThumb({
|
||||
pressedAnimation,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: SwitchThumbProps) {
|
||||
const { isPressed } = useSwitch();
|
||||
|
||||
return (
|
||||
<SwitchPrimitives.Thumb asChild>
|
||||
<motion.div
|
||||
data-slot="switch-thumb"
|
||||
whileTap="tab"
|
||||
layout
|
||||
transition={transition}
|
||||
animate={isPressed ? pressedAnimation : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</SwitchPrimitives.Thumb>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchIconPosition = 'left' | 'right' | 'thumb';
|
||||
|
||||
type SwitchIconProps = HTMLMotionProps<'div'> & {
|
||||
position: SwitchIconPosition;
|
||||
};
|
||||
|
||||
function SwitchIcon({
|
||||
position,
|
||||
transition = { type: 'spring', bounce: 0 },
|
||||
...props
|
||||
}: SwitchIconProps) {
|
||||
const { isChecked } = useSwitch();
|
||||
|
||||
const isAnimated = React.useMemo(() => {
|
||||
if (position === 'right') return !isChecked;
|
||||
if (position === 'left') return isChecked;
|
||||
if (position === 'thumb') return true;
|
||||
return false;
|
||||
}, [position, isChecked]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot={`switch-${position}-icon`}
|
||||
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Switch,
|
||||
SwitchThumb,
|
||||
SwitchIcon,
|
||||
useSwitch,
|
||||
type SwitchProps,
|
||||
type SwitchThumbProps,
|
||||
type SwitchIconProps,
|
||||
type SwitchIconPosition,
|
||||
type SwitchContextType,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui';
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
} from 'motion/react';
|
||||
|
||||
import {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
type HighlightProps,
|
||||
type HighlightItemProps,
|
||||
} from '@/components/animate-ui/primitives/effects/highlight';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import {
|
||||
AutoHeight,
|
||||
type AutoHeightProps,
|
||||
} from '@/components/animate-ui/primitives/effects/auto-height';
|
||||
|
||||
type TabsContextType = {
|
||||
value: string | undefined;
|
||||
setValue: TabsProps['onValueChange'];
|
||||
};
|
||||
|
||||
const [TabsProvider, useTabs] =
|
||||
getStrictContext<TabsContextType>('TabsContext');
|
||||
|
||||
type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
|
||||
|
||||
function Tabs(props: TabsProps) {
|
||||
const [value, setValue] = useControlledState({
|
||||
value: props.value,
|
||||
defaultValue: props.defaultValue,
|
||||
onChange: props.onValueChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<TabsProvider value={{ value, setValue }}>
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
{...props}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
</TabsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsHighlightProps = Omit<HighlightProps, 'controlledItems' | 'value'>;
|
||||
|
||||
function TabsHighlight({
|
||||
transition = { type: 'spring', stiffness: 200, damping: 25 },
|
||||
...props
|
||||
}: TabsHighlightProps) {
|
||||
const { value } = useTabs();
|
||||
|
||||
return (
|
||||
<Highlight
|
||||
data-slot="tabs-highlight"
|
||||
controlledItems
|
||||
value={value}
|
||||
transition={transition}
|
||||
click={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
|
||||
|
||||
function TabsList(props: TabsListProps) {
|
||||
return <TabsPrimitive.List data-slot="tabs-list" {...props} />;
|
||||
}
|
||||
|
||||
type TabsHighlightItemProps = HighlightItemProps & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
function TabsHighlightItem(props: TabsHighlightItemProps) {
|
||||
return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
|
||||
}
|
||||
|
||||
type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;
|
||||
|
||||
function TabsTrigger(props: TabsTriggerProps) {
|
||||
return <TabsPrimitive.Trigger data-slot="tabs-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function TabsContent({
|
||||
value,
|
||||
forceMount,
|
||||
transition = { duration: 0.5, ease: 'easeInOut' },
|
||||
...props
|
||||
}: TabsContentProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<TabsPrimitive.Content asChild forceMount={forceMount} value={value}>
|
||||
<motion.div
|
||||
data-slot="tabs-content"
|
||||
layout
|
||||
layoutDependency={value}
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</TabsPrimitive.Content>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsContentsAutoProps = AutoHeightProps & {
|
||||
mode?: 'auto-height';
|
||||
children: React.ReactNode;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
type TabsContentsLayoutProps = Omit<HTMLMotionProps<'div'>, 'transition'> & {
|
||||
mode: 'layout';
|
||||
children: React.ReactNode;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
type TabsContentsProps = TabsContentsAutoProps | TabsContentsLayoutProps;
|
||||
|
||||
const defaultTransition: Transition = {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
|
||||
return !('mode' in props) || props.mode === 'auto-height';
|
||||
}
|
||||
|
||||
function TabsContents(props: TabsContentsProps) {
|
||||
const { value } = useTabs();
|
||||
|
||||
if (isAutoMode(props)) {
|
||||
const { transition = defaultTransition, ...autoProps } = props;
|
||||
|
||||
return (
|
||||
<AutoHeight
|
||||
data-slot="tabs-contents"
|
||||
deps={[value]}
|
||||
transition={transition}
|
||||
{...autoProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { transition = defaultTransition, style, ...layoutProps } = props;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="tabs-contents"
|
||||
layout="size"
|
||||
layoutDependency={value}
|
||||
style={{ overflow: 'hidden', ...style }}
|
||||
transition={{ layout: transition }}
|
||||
{...layoutProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Tabs,
|
||||
TabsHighlight,
|
||||
TabsHighlightItem,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
TabsContents,
|
||||
type TabsProps,
|
||||
type TabsHighlightProps,
|
||||
type TabsHighlightItemProps,
|
||||
type TabsListProps,
|
||||
type TabsTriggerProps,
|
||||
type TabsContentProps,
|
||||
type TabsContentsProps,
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
type SpringOptions,
|
||||
type HTMLMotionProps,
|
||||
type MotionValue,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type TooltipContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
const [LocalTooltipProvider, useTooltip] =
|
||||
getStrictContext<TooltipContextType>('TooltipContext');
|
||||
|
||||
type TooltipProviderProps = React.ComponentProps<
|
||||
typeof TooltipPrimitive.Provider
|
||||
>;
|
||||
|
||||
function TooltipProvider(props: TooltipProviderProps) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" {...props} />;
|
||||
}
|
||||
|
||||
type TooltipProps = React.ComponentProps<typeof TooltipPrimitive.Root> & {
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
function Tooltip({
|
||||
followCursor = false,
|
||||
followCursorSpringOptions = { stiffness: 200, damping: 17 },
|
||||
...props
|
||||
}: TooltipProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
return (
|
||||
<LocalTooltipProvider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
x,
|
||||
y,
|
||||
followCursor,
|
||||
followCursorSpringOptions,
|
||||
}}
|
||||
>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</LocalTooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipTriggerProps = React.ComponentProps<
|
||||
typeof TooltipPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function TooltipTrigger({ onMouseMove, ...props }: TooltipTriggerProps) {
|
||||
const { x, y, followCursor } = useTooltip();
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onMouseMove?.(event);
|
||||
|
||||
const target = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
if (followCursor === 'x' || followCursor === true) {
|
||||
const eventOffsetX = event.clientX - target.left;
|
||||
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
|
||||
x.set(offsetXFromCenter);
|
||||
}
|
||||
|
||||
if (followCursor === 'y' || followCursor === true) {
|
||||
const eventOffsetY = event.clientY - target.top;
|
||||
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
|
||||
y.set(offsetYFromCenter);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
data-slot="tooltip-trigger"
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipPortalProps = Omit<
|
||||
React.ComponentProps<typeof TooltipPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function TooltipPortal(props: TooltipPortalProps) {
|
||||
const { isOpen } = useTooltip();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<TooltipPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="tooltip-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipContentProps = Omit<
|
||||
React.ComponentProps<typeof TooltipPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function TooltipContent({
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: TooltipContentProps) {
|
||||
const { x, y, followCursor, followCursorSpringOptions } = useTooltip();
|
||||
const translateX = useSpring(x, followCursorSpringOptions);
|
||||
const translateY = useSpring(y, followCursorSpringOptions);
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="popover-content"
|
||||
data-slot="popover-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
x:
|
||||
followCursor === 'x' || followCursor === true
|
||||
? translateX
|
||||
: undefined,
|
||||
y:
|
||||
followCursor === 'y' || followCursor === true
|
||||
? translateY
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipArrowProps = React.ComponentProps<typeof TooltipPrimitive.Arrow>;
|
||||
|
||||
function TooltipArrow(props: TooltipArrowProps) {
|
||||
return <TooltipPrimitive.Arrow data-slot="tooltip-arrow" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipPortal,
|
||||
TooltipContent,
|
||||
TooltipArrow,
|
||||
useTooltip,
|
||||
type TooltipProviderProps,
|
||||
type TooltipProps,
|
||||
type TooltipTriggerProps,
|
||||
type TooltipPortalProps,
|
||||
type TooltipContentProps,
|
||||
type TooltipArrowProps,
|
||||
type TooltipContextType,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
|
||||
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
|
||||
number: number;
|
||||
fromNumber?: number;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
transition?: SpringOptions;
|
||||
delay?: number;
|
||||
initiallyStable?: boolean;
|
||||
} & UseIsInViewOptions;
|
||||
|
||||
function CountingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber = 0,
|
||||
padStart = false,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
decimalSeparator = '.',
|
||||
transition = { stiffness: 90, damping: 50 },
|
||||
decimalPlaces = 0,
|
||||
delay = 0,
|
||||
initiallyStable = false,
|
||||
...props
|
||||
}: CountingNumberProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const numberStr = number.toString();
|
||||
const decimals =
|
||||
typeof decimalPlaces === 'number'
|
||||
? decimalPlaces
|
||||
: numberStr.includes('.')
|
||||
? (numberStr.split('.')[1]?.length ?? 0)
|
||||
: 0;
|
||||
|
||||
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
|
||||
const springVal = useSpring(motionVal, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isInView) motionVal.set(number);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isInView, number, motionVal, delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = springVal.on('change', (latest) => {
|
||||
if (localRef.current) {
|
||||
let formatted =
|
||||
decimals > 0
|
||||
? latest.toFixed(decimals)
|
||||
: Math.round(latest).toString();
|
||||
|
||||
if (decimals > 0) {
|
||||
formatted = formatted.replace('.', decimalSeparator);
|
||||
}
|
||||
|
||||
if (padStart) {
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
const [intPart, fracPart] = formatted.split(decimalSeparator);
|
||||
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
|
||||
formatted = fracPart
|
||||
? `${paddedInt}${decimalSeparator}${fracPart}`
|
||||
: paddedInt;
|
||||
}
|
||||
|
||||
localRef.current.textContent = formatted;
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [springVal, decimals, padStart, number, decimalSeparator, localRef]);
|
||||
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
|
||||
const formatValue = (val: number) => {
|
||||
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
|
||||
if (decimals > 0) out = out.replace('.', decimalSeparator);
|
||||
if (padStart) {
|
||||
const [intPart, fracPart] = out.split(decimalSeparator);
|
||||
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
|
||||
out = fracPart ? `${paddedInt}${decimalSeparator}${fracPart}` : paddedInt;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const zeroText = padStart
|
||||
? '0'.padStart(finalIntLength, '0') +
|
||||
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
|
||||
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
|
||||
|
||||
const initialText = initiallyStable ? formatValue(number) : zeroText;
|
||||
|
||||
return (
|
||||
<span ref={localRef} data-slot="counting-number" {...props}>
|
||||
{initialText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { CountingNumber, type CountingNumberProps };
|
||||
@@ -0,0 +1,354 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useSpring,
|
||||
useTransform,
|
||||
motion,
|
||||
useMotionValue,
|
||||
type MotionValue,
|
||||
type SpringOptions,
|
||||
type HTMLMotionProps,
|
||||
} from 'motion/react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
|
||||
type SlidingNumberRollerProps = {
|
||||
prevValue: number;
|
||||
value: number;
|
||||
place: number;
|
||||
transition: SpringOptions;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
function SlidingNumberRoller({
|
||||
prevValue,
|
||||
value,
|
||||
place,
|
||||
transition,
|
||||
delay = 0,
|
||||
}: SlidingNumberRollerProps) {
|
||||
const startNumber = Math.floor(prevValue / place) % 10;
|
||||
const targetNumber = Math.floor(value / place) % 10;
|
||||
const animatedValue = useSpring(startNumber, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
animatedValue.set(targetNumber);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [targetNumber, animatedValue, delay]);
|
||||
|
||||
const [measureRef, { height }] = useMeasure();
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={measureRef}
|
||||
data-slot="sliding-number-roller"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: '1ch',
|
||||
overflowX: 'visible',
|
||||
overflowY: 'clip',
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
<span style={{ visibility: 'hidden' }}>0</span>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SlidingNumberDisplay
|
||||
key={i}
|
||||
motionValue={animatedValue}
|
||||
number={i}
|
||||
height={height}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberDisplayProps = {
|
||||
motionValue: MotionValue<number>;
|
||||
number: number;
|
||||
height: number;
|
||||
transition: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumberDisplay({
|
||||
motionValue,
|
||||
number,
|
||||
height,
|
||||
transition,
|
||||
}: SlidingNumberDisplayProps) {
|
||||
const y = useTransform(motionValue, (latest) => {
|
||||
if (!height) return 0;
|
||||
const currentNumber = latest % 10;
|
||||
const offset = (10 + number - currentNumber) % 10;
|
||||
let translateY = offset * height;
|
||||
if (offset > 5) translateY -= 10 * height;
|
||||
return translateY;
|
||||
});
|
||||
|
||||
if (!height) {
|
||||
return (
|
||||
<span style={{ visibility: 'hidden', position: 'absolute' }}>
|
||||
{number}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="sliding-number-display"
|
||||
style={{
|
||||
y,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
transition={{ ...transition, type: 'spring' }}
|
||||
>
|
||||
{number}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberProps = Omit<HTMLMotionProps<'span'>, 'children'> & {
|
||||
number: number;
|
||||
fromNumber?: number;
|
||||
onNumberChange?: (number: number) => void;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
thousandSeparator?: string;
|
||||
transition?: SpringOptions;
|
||||
delay?: number;
|
||||
initiallyStable?: boolean;
|
||||
} & UseIsInViewOptions;
|
||||
|
||||
function SlidingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber,
|
||||
onNumberChange,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
padStart = false,
|
||||
decimalSeparator = '.',
|
||||
decimalPlaces = 0,
|
||||
thousandSeparator,
|
||||
transition = { stiffness: 200, damping: 20, mass: 0.4 },
|
||||
delay = 0,
|
||||
initiallyStable = false,
|
||||
...props
|
||||
}: SlidingNumberProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const initialNumeric = Math.abs(Number(number));
|
||||
const prevNumberRef = React.useRef<number>(
|
||||
initiallyStable ? initialNumeric : 0,
|
||||
);
|
||||
|
||||
const hasAnimated = fromNumber !== undefined;
|
||||
|
||||
const motionVal = useMotionValue(
|
||||
initiallyStable ? initialNumeric : (fromNumber ?? 0),
|
||||
);
|
||||
const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 });
|
||||
|
||||
const skippedInitialWhenStable = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasAnimated) return;
|
||||
if (initiallyStable && !skippedInitialWhenStable.current) {
|
||||
skippedInitialWhenStable.current = true;
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isInView) motionVal.set(number);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [hasAnimated, initiallyStable, isInView, number, motionVal, delay]);
|
||||
|
||||
const [effectiveNumber, setEffectiveNumber] = React.useState<number>(
|
||||
initiallyStable ? initialNumeric : 0,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasAnimated) {
|
||||
const inferredDecimals =
|
||||
typeof decimalPlaces === 'number' && decimalPlaces >= 0
|
||||
? decimalPlaces
|
||||
: (() => {
|
||||
const s = String(number);
|
||||
const idx = s.indexOf('.');
|
||||
return idx >= 0 ? s.length - idx - 1 : 0;
|
||||
})();
|
||||
|
||||
const factor = Math.pow(10, inferredDecimals);
|
||||
|
||||
const unsubscribe = springVal.on('change', (latest: number) => {
|
||||
const newValue =
|
||||
inferredDecimals > 0
|
||||
? Math.round(latest * factor) / factor
|
||||
: Math.round(latest);
|
||||
|
||||
if (effectiveNumber !== newValue) {
|
||||
setEffectiveNumber(newValue);
|
||||
onNumberChange?.(newValue);
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
} else {
|
||||
setEffectiveNumber(
|
||||
initiallyStable ? initialNumeric : !isInView ? 0 : initialNumeric,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
hasAnimated,
|
||||
springVal,
|
||||
isInView,
|
||||
number,
|
||||
decimalPlaces,
|
||||
onNumberChange,
|
||||
effectiveNumber,
|
||||
initiallyStable,
|
||||
initialNumeric,
|
||||
]);
|
||||
|
||||
const formatNumber = React.useCallback(
|
||||
(num: number) =>
|
||||
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
|
||||
[decimalPlaces],
|
||||
);
|
||||
|
||||
const numberStr = formatNumber(effectiveNumber);
|
||||
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
|
||||
|
||||
const finalIntLength = padStart
|
||||
? Math.max(
|
||||
Math.floor(Math.abs(number)).toString().length,
|
||||
newIntStrRaw.length,
|
||||
)
|
||||
: newIntStrRaw.length;
|
||||
|
||||
const newIntStr = padStart
|
||||
? newIntStrRaw.padStart(finalIntLength, '0')
|
||||
: newIntStrRaw;
|
||||
|
||||
const prevFormatted = formatNumber(prevNumberRef.current);
|
||||
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
|
||||
const prevIntStr = padStart
|
||||
? prevIntStrRaw.padStart(finalIntLength, '0')
|
||||
: prevIntStrRaw;
|
||||
|
||||
const adjustedPrevInt = React.useMemo(() => {
|
||||
return prevIntStr.length > finalIntLength
|
||||
? prevIntStr.slice(-finalIntLength)
|
||||
: prevIntStr.padStart(finalIntLength, '0');
|
||||
}, [prevIntStr, finalIntLength]);
|
||||
|
||||
const adjustedPrevDec = React.useMemo(() => {
|
||||
if (!newDecStrRaw) return '';
|
||||
return prevDecStrRaw.length > newDecStrRaw.length
|
||||
? prevDecStrRaw.slice(0, newDecStrRaw.length)
|
||||
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
|
||||
}, [prevDecStrRaw, newDecStrRaw]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView || initiallyStable) {
|
||||
prevNumberRef.current = effectiveNumber;
|
||||
}
|
||||
}, [effectiveNumber, isInView, initiallyStable]);
|
||||
|
||||
const intPlaces = React.useMemo(
|
||||
() =>
|
||||
Array.from({ length: finalIntLength }, (_, i) =>
|
||||
Math.pow(10, finalIntLength - i - 1),
|
||||
),
|
||||
[finalIntLength],
|
||||
);
|
||||
const decPlaces = React.useMemo(
|
||||
() =>
|
||||
newDecStrRaw
|
||||
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
|
||||
Math.pow(10, newDecStrRaw.length - i - 1),
|
||||
)
|
||||
: [],
|
||||
[newDecStrRaw],
|
||||
);
|
||||
|
||||
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
|
||||
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={localRef}
|
||||
data-slot="sliding-number"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{isInView && Number(number) < 0 && (
|
||||
<span style={{ marginRight: '0.25rem' }}>-</span>
|
||||
)}
|
||||
|
||||
{intPlaces.map((place, idx) => {
|
||||
const digitsToRight = intPlaces.length - idx - 1;
|
||||
const isSeparatorPosition =
|
||||
typeof thousandSeparator !== 'undefined' &&
|
||||
digitsToRight > 0 &&
|
||||
digitsToRight % 3 === 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`int-${place}`}>
|
||||
<SlidingNumberRoller
|
||||
prevValue={parseInt(adjustedPrevInt, 10)}
|
||||
value={parseInt(newIntStr ?? '0', 10)}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
{isSeparatorPosition && <span>{thousandSeparator}</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{newDecStrRaw && (
|
||||
<>
|
||||
<span>{decimalSeparator}</span>
|
||||
{decPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`dec-${place}`}
|
||||
prevValue={prevDecValue}
|
||||
value={newDecValue}
|
||||
place={place}
|
||||
transition={transition}
|
||||
delay={delay}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { SlidingNumber, type SlidingNumberProps };
|
||||
@@ -1,14 +1,12 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
@@ -16,60 +14,57 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
@@ -77,11 +72,11 @@ const AlertDialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
@@ -89,12 +84,11 @@ const AlertDialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
@@ -105,8 +99,8 @@ const AlertDialogAction = React.forwardRef<
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
@@ -114,15 +108,11 @@ const AlertDialogCancel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
@@ -136,4 +126,4 @@ export {
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,4 +33,5 @@ function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -54,4 +54,5 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
|
||||