Merge pull request #43 from AnsoCode/fix/remote-ws-stats-exec-openapp

fix(remote): strip cookie & nodeId from WS/HTTP proxy to remote nodes
This commit is contained in:
Anso
2026-03-20 00:51:08 -04:00
committed by GitHub
37 changed files with 5000 additions and 717 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Sencho CI
on:
pull_request:
branches: [ main, develop ]
push:
branches: [ develop ]
jobs:
build-and-test:
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: |
backend/package-lock.json
frontend/package-lock.json
- name: Install Backend Dependencies
working-directory: ./backend
run: npm ci
- name: Build Backend (TypeScript)
working-directory: ./backend
run: npm run build
- name: Install Frontend Dependencies
working-directory: ./frontend
run: npm ci
- name: Build Frontend (Vite/React)
working-directory: ./frontend
run: npm run build
+48
View File
@@ -0,0 +1,48 @@
name: Build and Publish Docker Image
on:
push:
branches:
- develop
release:
types: [published]
workflow_dispatch:
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: saelix/sencho
tags: |
# If pushing to develop, tag as 'dev'
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/develop' }}
# If a release is published, tag as 'latest' and the specific version
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=saelix/sencho:buildcache
cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max
+8 -1
View File
@@ -25,4 +25,11 @@ Thumbs.db
#PRD
Product Requirements Document
plans/
# Claude Code
.claude/
CLAUDE.md
plans/
# Playwright MCP
.playwright-mcp/
+120
View File
@@ -0,0 +1,120 @@
# Changelog
All notable changes to this project will be documented in this file.
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.
### 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.
### 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.
+222 -7
View File
@@ -12,7 +12,9 @@
"@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1",
"@types/express": "^5.0.6",
"@types/http-proxy": "^1.17.17",
"@types/ws": "^8.18.1",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"composerize": "^1.7.5",
@@ -20,6 +22,8 @@
"cors": "^2.8.6",
"dockerode": "^4.0.9",
"express": "^5.2.1",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"node-pty": "^1.1.0",
"systeminformation": "^5.31.1",
@@ -30,6 +34,7 @@
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^0.19.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@types/yaml": "^1.9.6",
@@ -345,6 +350,27 @@
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.17",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/http-proxy-middleware": {
"version": "0.19.3",
"resolved": "https://registry.npmjs.org/@types/http-proxy-middleware/-/http-proxy-middleware-0.19.3.tgz",
"integrity": "sha512-lnBTx6HCOUeIJMLbI/LaL5EmdKLhczJY5oeXZpX/cXE4rRqb3RmV7VcMpiEfYkmTjipv3h7IAyIINe4plEv7cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/http-proxy": "*",
"@types/node": "*"
}
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
@@ -566,6 +592,23 @@
"safer-buffer": "~2.1.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
@@ -707,7 +750,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -865,6 +907,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/composerize": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/composerize/-/composerize-1.7.5.tgz",
@@ -1067,6 +1121,15 @@
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1211,6 +1274,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1235,6 +1313,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1319,7 +1403,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -1349,6 +1432,63 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1496,6 +1636,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1528,6 +1683,37 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.15",
"debug": "^4.3.6",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.3",
"is-plain-object": "^5.0.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -1618,7 +1804,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -1637,7 +1822,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -1650,12 +1834,20 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1820,6 +2012,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2068,7 +2273,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -2141,6 +2345,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -2257,6 +2467,12 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2639,7 +2855,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
+6 -1
View File
@@ -17,6 +17,7 @@
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^0.19.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@types/yaml": "^1.9.6",
@@ -28,7 +29,9 @@
"@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1",
"@types/express": "^5.0.6",
"@types/http-proxy": "^1.17.17",
"@types/ws": "^8.18.1",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"composerize": "^1.7.5",
@@ -36,10 +39,12 @@
"cors": "^2.8.6",
"dockerode": "^4.0.9",
"express": "^5.2.1",
"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"
}
}
}
+711 -91
View File
File diff suppressed because it is too large Load Diff
+125 -225
View File
@@ -3,95 +3,37 @@ import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
/**
* ComposeService - local docker compose CLI execution.
*
* In the Distributed API model, remote node compose operations are handled
* by the remote Sencho instance. This service only executes commands locally.
*/
export class ComposeService {
private baseDir: string;
private nodeId: number;
constructor() {
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
constructor(nodeId?: number) {
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
this.baseDir = NodeRegistry.getInstance().getComposeDir(this.nodeId);
}
/**
* Run docker compose up or down command
* CRITICAL: cwd is set to the stack directory so relative paths in compose files
* resolve correctly inside the isolated stack folder
*/
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
// Run docker compose from within the stack directory
// This ensures relative paths (e.g., ./data:/config) resolve correctly
const args = ['compose', action];
return new Promise((resolve, reject) => {
const child = spawn('docker', args, {
cwd: stackDir, // CRITICAL: Set working directory to stack folder
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
if (ws) {
child.stdout.on('data', (data: Buffer) => {
ws.send(data.toString());
});
child.stderr.on('data', (data: Buffer) => {
ws.send(data.toString());
});
child.on('close', (code: number | null) => {
ws.send(`Command exited with code ${code}\n`);
if (code === 0) resolve();
else reject(new Error(`Command exited with code ${code}`));
});
child.on('error', (error: Error) => {
console.error(`Docker Compose Error for ${stackName}:`, error.message);
ws.send(`Error: ${error.message}\n`);
reject(error);
});
} else {
// Without WS, just wait for resolution
let stderr = '';
child.stdout.on('data', () => { }); // Drain stdout to prevent pipe buffer from blocking the process
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
child.on('close', (code: number | null) => {
if (code === 0) resolve();
else reject(new Error(`Command failed with code ${code}. Stderr: ${stderr}`));
});
child.on('error', (error: Error) => {
console.error(`Docker Compose Error for ${stackName}:`, error.message);
reject(error);
});
}
});
public static getInstance(nodeId?: number): ComposeService {
return new ComposeService(nodeId);
}
/**
* Deploy stack: executes up -d --remove-orphans and awaits completion.
*/
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
try {
const dockerController = DockerController.getInstance();
const legacyContainers = await dockerController.getContainersByStack(stackName);
if (legacyContainers && legacyContainers.length > 0) {
if (ws) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`);
await dockerController.removeContainers(legacyContainers.map(c => c.Id));
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
private execute(
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
return new Promise((resolve, reject) => {
const args = ['compose', 'up', '-d', '--remove-orphans'];
const child = spawn('docker', args, {
cwd: stackDir,
const child = spawn(command, args, {
cwd,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
@@ -100,57 +42,93 @@ export class ComposeService {
let errorLog = '';
if (ws) {
child.stdout.on('data', (data: Buffer) => ws.send(data.toString()));
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
errorLog += text;
const onData = (data: Buffer) => {
const text = data.toString();
errorLog += text;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(text);
});
child.on('close', (code: number | null) => {
}
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
child.on('close', (code: number | null) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Command exited with code ${code}\n`);
if (code === 0) resolve();
else reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
});
} else {
child.stdout.on('data', () => { }); // Drains stdout
child.stderr.on('data', (data: Buffer) => {
errorLog += data.toString();
});
child.on('close', (code: number | null) => {
if (code === 0) resolve();
else reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
});
}
}
if (code === 0) resolve();
else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
else resolve();
});
child.on('error', (error: Error) => {
console.error(`Docker Compose Deploy Error for ${stackName}:`, error.message);
if (ws) ws.send(`Error: ${error.message}\n`);
reject(error);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Error: ${error.message}\n`);
}
if (throwOnError) reject(error);
else resolve();
});
});
}
/**
* Stream docker logs for a stack via WebSocket.
* Supervisors: Fetches containers via DockerController and spawns `docker logs -f` for each.
* Automatically re-spawns on process exit if WebSocket is still OPEN, creating a persistent stream.
* Kills the child processes when the WebSocket closes.
*/
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
await this.execute('docker', ['compose', action], stackDir, ws);
}
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
try {
const dockerController = DockerController.getInstance(this.nodeId);
const legacyContainers = await dockerController.getContainersByStack(stackName);
if (legacyContainers && legacyContainers.length > 0) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`);
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
const dockerController = DockerController.getInstance(this.nodeId);
const containers = await dockerController.getDocker().listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${stackName}`] }
});
for (const containerInfo of containers) {
if (containerInfo.State === 'exited') {
const container = dockerController.getDocker().getContainer(containerInfo.Id);
const inspectData = await container.inspect();
const exitCode = inspectData.State.ExitCode;
if (exitCode !== 0) {
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
let logStr = logs.toString('utf-8');
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
}
}
}
}
streamLogs(stackName: string, ws: WebSocket) {
let isClosed = false;
let isFirstRun = true;
let isWaitingForActivity = false;
ws.on('close', () => {
isClosed = true;
});
ws.on('close', () => { isClosed = true; });
const startStream = async () => {
if (isClosed || ws.readyState !== WebSocket.OPEN) return;
try {
const dockerController = DockerController.getInstance();
const dockerController = DockerController.getInstance(this.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
@@ -162,9 +140,8 @@ export class ComposeService {
return;
}
const runningContainers = containers.filter(c => c.State === 'running');
const runningContainers = containers.filter((c: any) => c.State === 'running');
// If not first run and no containers are running, we poll to wait for activity
if (!isFirstRun && runningContainers.length === 0) {
if (!isWaitingForActivity) {
ws.send(`\r\n\x1b[33m[Sencho] Log stream ended. Waiting for container activity...\x1b[0m\r\n`);
@@ -174,20 +151,16 @@ export class ComposeService {
return;
}
// On first run, we stream all containers to dump history.
// On subsequent runs, we only attach to running containers to avoid immediate exit loop.
const containersToLog = isFirstRun ? containers : runningContainers;
isFirstRun = false;
isWaitingForActivity = false; // Reset since we are tracking active elements
isWaitingForActivity = false;
let activeProcesses = 0;
let streamEndedHandled = false;
const childProcesses: ReturnType<typeof spawn>[] = [];
let localProcesses: ReturnType<typeof spawn>[] = [];
const onWsClose = () => {
childProcesses.forEach(cp => {
try { cp.kill(); } catch { /* ignore */ }
});
localProcesses.forEach(cp => { try { cp.kill(); } catch { } });
};
ws.on('close', onWsClose);
@@ -197,7 +170,6 @@ export class ComposeService {
if (activeProcesses <= 0 && !streamEndedHandled) {
streamEndedHandled = true;
ws.removeListener('close', onWsClose);
if (!isClosed && ws.readyState === WebSocket.OPEN) {
setTimeout(startStream, 1000);
}
@@ -206,47 +178,42 @@ export class ComposeService {
for (const container of containersToLog) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
activeProcesses++;
childProcesses.push(child);
let lineBuffer = '';
const sendOutput = (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
lineBuffer += data.toString();
const lines = lineBuffer.split(/\r?\n/);
// The last element is either an incomplete line or empty string
lineBuffer = lines.pop() || '';
for (const line of lines) {
const formattedLine = LogFormatter.process(line);
ws.send(formattedLine + '\r\n');
ws.send(LogFormatter.process(line) + '\r\n');
}
}
};
const flushBuffer = () => {
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
ws.send(LogFormatter.process(lineBuffer) + '\r\n');
lineBuffer = '';
}
};
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
localProcesses.push(child);
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', () => {
// Flush any remaining partial line before ending
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
const formattedLine = LogFormatter.process(lineBuffer);
ws.send(formattedLine + '\r\n');
lineBuffer = '';
}
flushBuffer();
handleProcessEnd();
});
}
} catch (err) {
if (!isClosed && ws.readyState === WebSocket.OPEN) {
if (!isWaitingForActivity) {
@@ -261,104 +228,37 @@ export class ComposeService {
startStream();
}
/**
* Update stack: pull images first, then recreate containers
* CRITICAL: cwd is set to the stack directory so relative paths resolve correctly
*/
async updateStack(stackName: string, ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const sendOutput = (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
};
try {
const dockerController = DockerController.getInstance();
const dockerController = DockerController.getInstance(this.nodeId);
const legacyContainers = await dockerController.getContainersByStack(stackName);
if (legacyContainers && legacyContainers.length > 0) {
sendOutput(`=== Cleaning up existing containers for clean update ===\n`);
await dockerController.removeContainers(legacyContainers.map(c => c.Id));
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
// Step 1: Pull images
sendOutput('=== Pulling latest images ===\n');
await new Promise<void>((resolve, reject) => {
const pullProcess = spawn('docker', ['compose', 'pull'], {
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
pullProcess.stdout.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
pullProcess.stderr.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
pullProcess.on('close', (code: number | null) => {
if (code === 0) {
sendOutput('=== Images pulled successfully ===\n');
resolve();
} else {
sendOutput(`=== Pull failed with code ${code} ===\n`);
reject(new Error(`Pull failed with code ${code}`));
}
});
pullProcess.on('error', (error: Error) => {
console.error(`Docker Compose Pull Error for ${stackName}:`, error.message);
sendOutput(`Pull error: ${error.message}\n`);
reject(error);
});
});
// Step 2: Recreate containers with new images
sendOutput('=== Recreating containers ===\n');
await new Promise<void>((resolve, reject) => {
const upProcess = spawn('docker', ['compose', 'up', '-d', '--remove-orphans'], {
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Stack updated successfully ===\n');
}
let errorLog = '';
upProcess.stdout.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
upProcess.stderr.on('data', (data: Buffer) => {
const text = data.toString();
errorLog += text;
sendOutput(text);
});
upProcess.on('close', (code: number | null) => {
if (code === 0) {
sendOutput('=== Stack updated successfully ===\n');
resolve();
} else {
sendOutput(`=== Update failed with code ${code} ===\n`);
reject(new Error(errorLog.trim() || `Up failed with code ${code}`));
}
});
upProcess.on('error', (error: Error) => {
console.error(`Docker Compose Up Error for ${stackName}:`, error.message);
sendOutput(`Update error: ${error.message}\n`);
reject(error);
});
});
public async downStack(stackName: string): Promise<void> {
const stackPath = path.join(this.baseDir, stackName);
try {
await this.execute('docker', ['compose', 'down'], stackPath, undefined, false);
} catch (error) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}
}
}
+205 -6
View File
@@ -25,6 +25,18 @@ export interface StackAlert {
last_fired_at?: number;
}
export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
api_url?: string;
api_token?: string;
}
export interface NotificationHistory {
id?: number;
level: 'info' | 'warning' | 'error';
@@ -40,14 +52,12 @@ export class DatabaseService {
private constructor() {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'sencho.db');
this.db = new Database(dbPath);
// Default journal mode is safer for arbitrary Docker volume mounts than WAL
this.initSchema();
this.migrateJsonConfig(dataDir);
@@ -96,8 +106,58 @@ export class DatabaseService {
timestamp INTEGER NOT NULL,
is_read INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS container_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
container_id TEXT NOT NULL,
stack_name TEXT NOT NULL,
cpu_percent REAL NOT NULL,
memory_mb REAL NOT NULL,
net_rx_mb REAL NOT NULL,
net_tx_mb REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id);
CREATE TABLE IF NOT EXISTS stack_update_status (
stack_name TEXT PRIMARY KEY,
has_update INTEGER DEFAULT 0,
checked_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local',
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
created_at INTEGER NOT NULL
);
`);
// Apply migrations safely (ignore if columns already exist)
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
};
// Distributed API model columns
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
// Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written)
maybeAddCol('nodes', 'host', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375');
maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22');
maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'ssh_key', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'tls_ca', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'tls_cert', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'tls_key', "TEXT DEFAULT ''");
// Initialize default global settings if they don't exist
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
stmt.run('host_cpu_limit', '90');
@@ -105,6 +165,16 @@ export class DatabaseService {
stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1');
stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5');
stmt.run('developer_mode', '0');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
if (nodeCount === 0) {
this.db.prepare(
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
}
}
private migrateJsonConfig(dataDir: string) {
@@ -121,8 +191,6 @@ export class DatabaseService {
stmt.run('auth_jwt_secret', config.jwtSecret);
console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
// Delete the file after migrating
fs.unlinkSync(configPath);
}
} catch (err) {
@@ -228,9 +296,8 @@ export class DatabaseService {
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
stmt.run(notification.level, notification.message, notification.timestamp);
// Cleanup old notifications (keep last 100)
this.db.exec(`
DELETE FROM notification_history
DELETE FROM notification_history
WHERE id NOT IN (
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
)
@@ -251,4 +318,136 @@ export class DatabaseService {
const stmt = this.db.prepare('DELETE FROM notification_history');
stmt.run();
}
// --- Container Metrics ---
public addContainerMetric(metric: Omit<any, 'id'>): void {
const stmt = this.db.prepare(
'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
stmt.run(metric.container_id, metric.stack_name, metric.cpu_percent, metric.memory_mb, metric.net_rx_mb, metric.net_tx_mb, metric.timestamp);
}
public getContainerMetrics(hoursLookback = 24): any[] {
const cutoff = Date.now() - (hoursLookback * 60 * 60 * 1000);
const stmt = this.db.prepare(`
SELECT
container_id,
stack_name,
AVG(cpu_percent) as cpu_percent,
AVG(memory_mb) as memory_mb,
MAX(net_rx_mb) as net_rx_mb,
MAX(net_tx_mb) as net_tx_mb,
(timestamp / 60000) * 60000 as timestamp
FROM container_metrics
WHERE timestamp >= ?
GROUP BY container_id, stack_name, (timestamp / 60000)
ORDER BY timestamp ASC
`);
return stmt.all(cutoff);
}
public cleanupOldMetrics(hoursToKeep = 24): void {
const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000);
const stmt = this.db.prepare('DELETE FROM container_metrics WHERE timestamp < ?');
stmt.run(cutoff);
}
// --- Nodes ---
public getNodes(): Node[] {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC');
return stmt.all().map((row: any) => ({
...row,
is_default: row.is_default === 1
}));
}
public getNode(id: number): Node | undefined {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?');
const row = stmt.get(id) as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public getDefaultNode(): Node | undefined {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1');
const row = stmt.get() as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const stmt = this.db.prepare(
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
node.name,
node.type,
node.compose_dir || '/app/compose',
node.is_default ? 1 : 0,
'unknown',
Date.now(),
node.api_url || '',
node.api_token || ''
);
return result.lastInsertRowid as number;
}
public updateNode(id: number, updates: Partial<Omit<Node, 'id' | 'created_at'>>): void {
const node = this.getNode(id);
if (!node) throw new Error(`Node with id ${id} not found`);
if (updates.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const fields: string[] = [];
const values: any[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); }
if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE nodes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteNode(id: number): void {
const node = this.getNode(id);
if (node?.is_default) {
throw new Error('Cannot delete the default node');
}
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
}
public updateNodeStatus(id: number, status: 'online' | 'offline' | 'unknown'): void {
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
}
// --- Stack Update Status ---
public upsertStackUpdateStatus(stackName: string, hasUpdate: boolean, checkedAt: number): void {
this.db.prepare(
'INSERT OR REPLACE INTO stack_update_status (stack_name, has_update, checked_at) VALUES (?, ?, ?)'
).run(stackName, hasUpdate ? 1 : 0, checkedAt);
}
public getStackUpdateStatus(): Record<string, boolean> {
const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[];
const result: Record<string, boolean> = {};
for (const row of rows) {
result[row.stack_name] = row.has_update === 1;
}
return result;
}
}
+80 -17
View File
@@ -6,22 +6,35 @@ import path from 'path';
import fs from 'fs/promises';
import * as yaml from 'yaml';
import { NodeRegistry } from './NodeRegistry';
const execAsync = promisify(exec);
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
class DockerController {
private static instance: DockerController;
private docker: Docker;
private nodeId: number;
private constructor() {
this.docker = new Docker({ socketPath: '/var/run/docker.sock' });
private constructor(nodeId: number) {
this.nodeId = nodeId;
this.docker = NodeRegistry.getInstance().getDocker(nodeId);
}
public static getInstance(): DockerController {
if (!DockerController.instance) {
DockerController.instance = new DockerController();
public static getInstance(nodeId?: number): DockerController {
const id = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
return new DockerController(id);
}
public getDocker(): Docker {
return this.docker;
}
private validateApiData<T>(data: any): T {
// If the daemon port points to a web server (like Sencho UI), Dockerode receives HTML
if (typeof data === 'string') {
throw new Error("Invalid response from Docker API. Did you provide a web port instead of the Docker daemon port?");
}
return DockerController.instance;
return data as T;
}
public async getDiskUsage() {
@@ -84,16 +97,19 @@ class DockerController {
}
public async getImages() {
return await this.docker.listImages({ all: false });
const data = await this.docker.listImages({ all: false });
return this.validateApiData<any[]>(data);
}
public async getVolumes() {
const data = await this.docker.listVolumes();
return data.Volumes || [];
const validated = this.validateApiData<any>(data);
return validated.Volumes || [];
}
public async getNetworks() {
return await this.docker.listNetworks();
const data = await this.docker.listNetworks();
return this.validateApiData<any[]>(data);
}
public async removeImage(id: string) {
@@ -113,12 +129,12 @@ class DockerController {
public async getRunningContainers() {
const containers = await this.docker.listContainers({ all: false });
return containers;
return this.validateApiData<any[]>(containers);
}
public async getAllContainers() {
const containers = await this.docker.listContainers({ all: true });
return containers;
return this.validateApiData<any[]>(containers);
}
public async getContainersByStack(stackName: string) {
@@ -281,6 +297,52 @@ class DockerController {
}
}
public async streamContainerLogs(containerId: string, req: any, res: any): Promise<void> {
const container = this.docker.getContainer(containerId);
// 1. Set SSE Headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
try {
const logStream = await container.logs({
follow: true,
stdout: true,
stderr: true,
tail: 100 // Send the last 100 lines immediately for context
});
// 2. Process and forward the stream
logStream.on('data', (chunk: Buffer) => {
// Docker multiplexes stdout/stderr with an 8-byte header if TTY is false.
let data = chunk;
if (chunk.length > 8 && (chunk[0] === 1 || chunk[0] === 2)) {
data = chunk.slice(8);
}
const text = data.toString('utf-8');
const lines = text.split('\n');
lines.forEach(line => {
if (line.trim()) {
res.write(`data: ${JSON.stringify(line)}\n\n`);
}
});
});
// 3. Cleanup on disconnect
req.on('close', () => {
(logStream as any).destroy();
});
} catch (error: any) {
res.write(`data: ${JSON.stringify('[Sencho] Error fetching logs: ' + error.message)}\n\n`);
res.end();
}
}
// State-safe: silently ignores 304 "already started" errors
public async startContainer(containerId: string) {
try {
@@ -288,7 +350,7 @@ class DockerController {
await container.start();
} catch (error: any) {
if (error?.statusCode === 304) {
// Container already running not an error
// Container already running - not an error
return;
}
throw error;
@@ -302,7 +364,7 @@ class DockerController {
await container.stop();
} catch (error: any) {
if (error?.statusCode === 304) {
// Container already stopped not an error
// Container already stopped - not an error
return;
}
throw error;
@@ -383,7 +445,7 @@ class DockerController {
/**
* Exec into a container with full session isolation.
* All state (exec instance, stream) lives in this closure no singleton traps.
* All state (exec instance, stream) lives in this closure - no singleton traps.
* The WebSocket message handler is registered here to handle input, resize, and cleanup.
*/
public async execContainer(containerId: string, ws: WebSocket) {
@@ -454,7 +516,7 @@ class DockerController {
break;
}
} catch {
// Non-JSON or malformed message ignore
// Non-JSON or malformed message - ignore
}
});
@@ -482,7 +544,8 @@ let lastNetSum = { rx: 0, tx: 0, timestamp: Date.now() };
export const updateGlobalDockerNetwork = async () => {
try {
const dockerController = DockerController.getInstance();
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
const dockerController = DockerController.getInstance(nodeId);
const containers = await dockerController.getRunningContainers();
const statsResults = await Promise.allSettled(
+68 -106
View File
@@ -1,104 +1,93 @@
import { promises as fs, Dirent } from 'fs';
import path from 'path';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import { NodeRegistry } from './NodeRegistry';
/**
* FileSystemService - local-only file I/O for compose stack management.
*
* In the Distributed API model, remote node file operations are handled
* by the remote Sencho instance itself. This service only operates on
* the local filesystem.
*/
export class FileSystemService {
private baseDir: string;
constructor() {
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
constructor(nodeId?: number) {
this.baseDir = NodeRegistry.getInstance().getComposeDir(
nodeId ?? NodeRegistry.getInstance().getDefaultNodeId()
);
}
public static getInstance(nodeId?: number): FileSystemService {
return new FileSystemService(nodeId);
}
/**
* Check if a directory contains a valid compose file
*/
private async hasComposeFile(dir: string): Promise<boolean> {
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
try {
await fs.access(path.join(dir, file));
await fsPromises.access(path.join(dir, file));
return true;
} catch {
// Continue checking other options
// continue
}
}
return false;
}
/**
* Get the path to the compose file for a stack
* Throws if no compose file is found
*/
private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const filePath = path.join(stackDir, file);
try {
await fs.access(filePath);
await fsPromises.access(filePath);
return filePath;
} catch {
// Continue checking other options
// continue
}
}
throw new Error(`No compose file found for stack: ${stackName}`);
}
/**
* Get all stacks (directories containing compose files)
* Returns array of stack names (directory names)
*/
async getStacks(): Promise<string[]> {
try {
const items = await fs.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
const stackNames: string[] = [];
for (const item of items) {
if (!item.isDirectory()) continue;
if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name);
const hasCompose = await this.hasComposeFile(stackDir);
if (hasCompose) {
if (await this.hasComposeFile(stackDir)) {
stackNames.push(item.name);
}
}
return stackNames;
} catch (error) {
console.error('Error reading stacks:', error);
} catch (error: any) {
console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
return [];
}
}
/**
* Get the content of a stack's compose file
*/
async getStackContent(stackName: string): Promise<string> {
try {
const filePath = await this.getComposeFilePath(stackName);
return await fs.readFile(filePath, 'utf-8');
return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading stack content:', error);
throw new Error(`Failed to read stack: ${stackName}`);
}
}
/**
* Save content to a stack's compose file
* Always writes to compose.yaml (standardizing on this filename)
*/
async saveStackContent(stackName: string, content: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const filePath = path.join(stackDir, 'compose.yaml');
const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
console.log('Saving to path:', filePath);
try {
await fs.writeFile(filePath, content, 'utf-8');
await fsPromises.writeFile(filePath, content, 'utf-8');
console.log('File written successfully');
} catch (error) {
console.error('Error writing file:', error);
@@ -106,41 +95,42 @@ export class FileSystemService {
}
}
/**
* Check if a stack has an .env file
*/
async envExists(stackName: string): Promise<boolean> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
await fs.access(envPath);
await fsPromises.access(path.join(this.baseDir, stackName, '.env'));
return true;
} catch {
return false;
}
}
/**
* Get the content of a stack's .env file
*/
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
return fsPromises.readFile(filePath, encoding);
}
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
return fsPromises.writeFile(filePath, content, encoding);
}
async access(filePath: string): Promise<void> {
return fsPromises.access(filePath);
}
async getEnvContent(stackName: string): Promise<string> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
return await fs.readFile(envPath, 'utf-8');
return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) {
console.error('Error reading env file:', error);
throw new Error(`Failed to read env file for stack: ${stackName}`);
}
}
/**
* Save content to a stack's .env file
*/
async saveEnvContent(stackName: string, content: string): Promise<void> {
const envPath = path.join(this.baseDir, stackName, '.env');
console.log('Saving env to path:', envPath);
try {
await fs.writeFile(envPath, content, 'utf-8');
await fsPromises.writeFile(envPath, content, 'utf-8');
console.log('Env file written successfully');
} catch (error) {
console.error('Error writing env file:', error);
@@ -148,33 +138,22 @@ export class FileSystemService {
}
}
/**
* Create a new stack (directory with boilerplate compose.yaml)
*/
async createStack(stackName: string): Promise<void> {
// Validate stack name (no special characters, not empty)
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
}
const stackDir = path.join(this.baseDir, stackName);
// Check if directory already exists
try {
await fs.access(stackDir);
await fsPromises.access(stackDir);
throw new Error(`Stack "${stackName}" already exists`);
} catch (error: any) {
if (error.message.includes('already exists')) {
throw error;
}
// Directory doesn't exist, proceed
if (error.message.includes('already exists')) throw error;
}
// Create the directory
await fs.mkdir(stackDir, { recursive: true });
await fsPromises.mkdir(stackDir, { recursive: true });
// Write boilerplate compose.yaml
const composePath = path.join(stackDir, 'compose.yaml');
const boilerplate = `services:
app:
image: nginx:latest
@@ -183,7 +162,7 @@ export class FileSystemService {
restart: always
`;
try {
await fs.writeFile(composePath, boilerplate, 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
console.log('Stack created successfully:', stackName);
} catch (error) {
console.error('Error creating stack:', error);
@@ -191,88 +170,71 @@ export class FileSystemService {
}
}
/**
* Delete a stack (entire directory and its contents)
*/
async deleteStack(stackName: string): Promise<void> {
public async deleteStack(stackName: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
try {
await fs.rm(stackDir, { recursive: true, force: true });
await fsPromises.rm(stackDir, { recursive: true, force: true });
console.log('Stack deleted successfully:', stackName);
} catch (error) {
console.error('Error deleting stack:', error);
throw new Error(`Failed to delete stack: ${stackName}`);
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error('Error deleting stack directory:', error.message);
throw new Error(`Failed to delete stack directory: ${error.message}`);
}
}
}
/**
* Get the base directory path for stacks
*/
getBaseDir(): string {
return this.baseDir;
}
/**
* Migrate existing flat-file stacks to directory-based structure
* This runs automatically on server startup
*/
async migrateFlatToDirectory(): Promise<void> {
try {
// Ensure base directory exists
try {
await fs.access(this.baseDir);
await fsPromises.access(this.baseDir);
} catch {
console.log('Creating compose directory:', this.baseDir);
await fs.mkdir(this.baseDir, { recursive: true });
return; // No files to migrate in a new directory
await fsPromises.mkdir(this.baseDir, { recursive: true });
return;
}
const items = await fs.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
for (const item of items) {
// Only process .yml/.yaml files (skip directories and other files)
if (!item.isFile()) continue;
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
const stackDir = path.join(this.baseDir, stackName);
// Check if target directory already exists
try {
await fs.access(stackDir);
await fsPromises.access(stackDir);
console.log(`Skipping migration for "${stackName}": directory already exists`);
continue;
} catch {
// Directory doesn't exist, proceed with migration
// Directory doesn't exist, proceed
}
console.log(`Migrating stack: ${stackName}`);
await fsPromises.mkdir(stackDir, { recursive: true });
// Create the stack directory
await fs.mkdir(stackDir, { recursive: true });
// Move compose file to new location (standardize on compose.yaml)
const oldComposePath = path.join(this.baseDir, item.name);
const newComposePath = path.join(stackDir, 'compose.yaml');
await fs.rename(oldComposePath, newComposePath);
await fsPromises.rename(oldComposePath, newComposePath);
// Move env file if it exists (old pattern: stackname.env)
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
const newEnvPath = path.join(stackDir, '.env');
try {
await fs.access(oldEnvPath);
await fs.rename(oldEnvPath, newEnvPath);
await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath);
console.log(`Migrated env file for: ${stackName}`);
} catch {
// No env file to migrate, that's fine
// No env file to migrate
}
console.log(`Successfully migrated stack: ${stackName}`);
}
} catch (error) {
console.error('Migration error:', error);
// Don't throw - allow the server to start even if migration fails
}
}
}
}
+298
View File
@@ -0,0 +1,298 @@
import https from 'https';
import http from 'http';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
// ─── Image ref parsing ────────────────────────────────────────────────────────
interface ParsedRef {
registry: string; // e.g. "registry-1.docker.io", "lscr.io", "ghcr.io"
repo: string; // e.g. "library/nginx", "linuxserver/sonarr"
tag: string; // e.g. "latest", "1.25"
}
function parseImageRef(imageRef: string): ParsedRef | null {
if (imageRef.startsWith('sha256:')) return null;
// Strip digest pin (e.g. "nginx@sha256:abc" → "nginx")
const atIdx = imageRef.indexOf('@');
if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx);
let registry = 'registry-1.docker.io';
let rest = imageRef;
const slashIdx = imageRef.indexOf('/');
if (slashIdx !== -1) {
const firstPart = imageRef.slice(0, slashIdx);
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
registry = firstPart;
rest = imageRef.slice(slashIdx + 1);
}
}
// Extract tag
let tag = 'latest';
const colonIdx = rest.lastIndexOf(':');
if (colonIdx > 0) {
tag = rest.slice(colonIdx + 1);
rest = rest.slice(0, colonIdx);
}
// Docker Hub official images (no slash) → prepend "library/"
if (registry === 'registry-1.docker.io' && !rest.includes('/')) {
rest = `library/${rest}`;
}
return { registry, repo: rest, tag };
}
// ─── Minimal HTTP helper ──────────────────────────────────────────────────────
interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http;
const req = lib.get(url, { headers }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers as Record<string, string | string[] | undefined>,
body,
}));
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
});
}
// ─── Registry auth ────────────────────────────────────────────────────────────
async function getAuthToken(registry: string, repo: string): Promise<string | null> {
try {
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
// Ping /v2/ to get the WWW-Authenticate challenge
const ping = await httpGet(`https://${registry}/v2/`);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
const scopeMatch = wwwAuth.match(/scope="([^"]+)"/);
if (!realmMatch) return null;
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
params.set('scope', scopeMatch ? scopeMatch[1] : `repository:${repo}:pull`);
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl);
if (tokenRes.statusCode !== 200) return null;
const parsed = JSON.parse(tokenRes.body);
return parsed.token ?? parsed.access_token ?? null;
} catch {
return null;
}
}
// ─── 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.
const MANIFEST_ACCEPT = [
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.index.v1+json',
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
async function getRemoteDigest(registry: string, repo: string, tag: string): Promise<string | null> {
try {
const token = await getAuthToken(registry, repo);
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers);
if (res.statusCode !== 200) return null;
return (res.headers['docker-content-digest'] as string) ?? null;
} catch {
return null;
}
}
// ─── Service ──────────────────────────────────────────────────────────────────
export class ImageUpdateService {
private static instance: ImageUpdateService;
private intervalId: NodeJS.Timeout | null = null;
private isRunning = false;
private lastManualRefreshAt = 0;
private static readonly INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot
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() {}
public static getInstance(): ImageUpdateService {
if (!ImageUpdateService.instance) {
ImageUpdateService.instance = new ImageUpdateService();
}
return ImageUpdateService.instance;
}
public start() {
if (this.intervalId) return;
setTimeout(() => this.check(), ImageUpdateService.STARTUP_DELAY_MS);
this.intervalId = setInterval(() => this.check(), ImageUpdateService.INTERVAL_MS);
}
public stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
/**
* Triggers a check immediately, unless one is already running or the
* 10-minute manual cooldown has not elapsed.
* Returns false if rate-limited, true if a check was started.
*/
public triggerManualRefresh(): boolean {
const now = Date.now();
if (now - this.lastManualRefreshAt < ImageUpdateService.MANUAL_COOLDOWN_MS) {
return false;
}
this.lastManualRefreshAt = now;
this.check().catch(e => console.error('[ImageUpdateService] Manual refresh error:', e));
return true;
}
public isChecking(): boolean {
return this.isRunning;
}
// ─── Core check ──────────────────────────────────────────────────────────
private async check() {
if (this.isRunning) return;
this.isRunning = true;
console.log('[ImageUpdateService] Starting image update check...');
try {
const db = DatabaseService.getInstance();
// Only check local nodes — remote nodes run their own instance
for (const node of db.getNodes()) {
if (node.type !== 'local' || !node.id) continue;
try {
await this.checkNode(node.id, db);
} catch (e) {
console.error(`[ImageUpdateService] Error on node ${node.name}:`, e);
}
}
console.log('[ImageUpdateService] Image update check complete.');
} catch (e) {
console.error('[ImageUpdateService] Check failed:', e);
} finally {
this.isRunning = false;
}
}
private async checkNode(nodeId: number, db: DatabaseService) {
const docker = DockerController.getInstance(nodeId);
const containers = await docker.getAllContainers();
// stackName → set of image refs used by that stack
const stackImages = new Map<string, Set<string>>();
for (const c of containers) {
const stackName: string | undefined = c.Labels?.['com.docker.compose.project'];
if (!stackName) continue;
const imageRef: string = c.Image ?? '';
if (!imageRef || imageRef.startsWith('sha256:')) continue;
if (!stackImages.has(stackName)) stackImages.set(stackName, new Set());
stackImages.get(stackName)!.add(imageRef);
}
if (stackImages.size === 0) return;
// Deduplicate: each unique image is checked once regardless of how many stacks use it
const allImages = new Set<string>();
for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img);
const imageUpdateMap = new Map<string, boolean>();
for (const imageRef of allImages) {
try {
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
} catch (e) {
console.error(`[ImageUpdateService] Error checking ${imageRef}:`, e);
imageUpdateMap.set(imageRef, false);
}
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
}
const now = Date.now();
for (const [stackName, images] of stackImages) {
const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img) === true);
db.upsertStackUpdateStatus(stackName, hasUpdate, now);
}
}
private async checkImage(docker: DockerController, imageRef: string): Promise<boolean> {
const parsed = parseImageRef(imageRef);
if (!parsed) return false;
// Get local digest from RepoDigests
let localDigest: string | null = null;
try {
const inspect = await docker.getDocker().getImage(imageRef).inspect();
const repoDigests: string[] = inspect.RepoDigests ?? [];
for (const rd of repoDigests) {
if (!rd.includes('@sha256:')) continue;
const [, digest] = rd.split('@');
// Match: rd contains the repo path or this is the only digest entry
if (rd.includes(parsed.repo) || rd.includes(parsed.registry) || repoDigests.length === 1) {
localDigest = digest;
break;
}
}
} catch {
return false; // Image inspect failed (removed since container was started)
}
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
const hasUpdate = localDigest !== remoteDigest;
console.log(
`[ImageUpdateService] ${imageRef}: ` +
`local=${localDigest.slice(0, 27)}... remote=${remoteDigest.slice(0, 27)}... update=${hasUpdate}`
);
return hasUpdate;
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+58 -43
View File
@@ -118,31 +118,32 @@ export class MonitorService {
// 2. Global Crash Detect
if (settings['global_crash'] === '1') {
try {
const docker = DockerController.getInstance();
const containers = await docker.getAllContainers();
for (const c of containers) {
if (c.State === 'exited' || String(c.Status).includes('unhealthy')) {
// Basic deduplication could be added, but for now we just dispatch.
// Usually, users will restart or remove the container.
// To prevent massive spam, we check if it exited in the last 30 secs
if (c.State === 'exited') {
// Check if it exited recently
if (c.Status.includes('seconds ago')) {
// Extract the exit code from the status string (e.g., "Exited (143) 5 seconds ago")
const match = c.Status.match(/Exited \((\d+)\)/i);
const exitCode = match ? parseInt(match[1], 10) : null;
// 0: Success, 137: SIGKILL (Force Stop), 143: SIGTERM (Graceful Stop), 255: Docker Daemon Stop
const intentionalExitCodes = [0, 137, 143, 255];
// Only alert if we found a code AND it is not an intentional stop
if (exitCode !== null && !intentionalExitCodes.includes(exitCode)) {
await notifier.dispatchAlert('error', `Container Crash Detected: ${c.Names[0]} exited unexpectedly (Code: ${exitCode}).`);
const nodes = DatabaseService.getInstance().getNodes();
for (const node of nodes) {
if (!node.id) continue;
// Remote nodes run their own MonitorService locally - skip direct Docker access
if (node.type === 'remote') continue;
try {
const docker = DockerController.getInstance(node.id);
const containers = await docker.getAllContainers();
for (const c of containers) {
if (c.State === 'exited' || String(c.Status).includes('unhealthy')) {
if (c.State === 'exited') {
if (c.Status.includes('seconds ago')) {
const match = c.Status.match(/Exited \((\d+)\)/i);
const exitCode = match ? parseInt(match[1], 10) : null;
const intentionalExitCodes = [0, 137, 143, 255];
if (exitCode !== null && !intentionalExitCodes.includes(exitCode)) {
await notifier.dispatchAlert('error', `[Node: ${node.name}] Container Crash Detected: ${c.Names[0]} exited unexpectedly (Code: ${exitCode}).`);
}
}
} else if (String(c.Status).includes('unhealthy')) {
await notifier.dispatchAlert('error', `[Node: ${node.name}] Healthcheck Failed: Container ${c.Names[0]} is unhealthy.`);
}
}
} else if (String(c.Status).includes('unhealthy')) {
await notifier.dispatchAlert('error', `Healthcheck Failed: Container ${c.Names[0]} is unhealthy.`);
}
} catch (err) {
console.error(`Error checking crashes on node ${node.name}`, err);
}
}
} catch (e) {
@@ -203,24 +204,17 @@ export class MonitorService {
private async evaluateStackAlerts(db: DatabaseService) {
const alerts = db.getStackAlerts();
if (alerts.length === 0) return;
// Group alerts by stack so we only fetch stats for stacks we care about
const stacksToMonitor = new Set(alerts.map(a => a.stack_name));
const docker = DockerController.getInstance();
for (const stackName of stacksToMonitor) {
const stackAlerts = alerts.filter(a => a.stack_name === stackName);
if (stackAlerts.length === 0) continue;
const nodes = db.getNodes();
for (const node of nodes) {
if (!node.id) continue;
// Remote nodes are self-monitoring - skip direct Docker access
if (node.type === 'remote') continue;
try {
const containers = await docker.getContainersByStack(stackName);
if (containers.length === 0) continue;
// Fetch stats for all containers in this stack
const docker = DockerController.getInstance(node.id);
const containers = await docker.getRunningContainers();
for (const container of containers) {
if (container.State !== 'running') continue;
const stackName = container.Labels?.['com.docker.compose.project'] || 'system';
try {
const rawStats = await docker.getContainerStatsStream(container.Id);
@@ -230,11 +224,22 @@ export class MonitorService {
cpu_percent: this.calculateCpuPercent(stats),
memory_percent: this.calculateMemoryPercent(stats),
memory_mb: (stats.memory_stats?.usage || 0) / (1024 * 1024),
net_rx: this.calculateNetwork(stats, 'rx'), // KB/s (naive sum for now, proper net_rx requires time delta but we can do total MB for simple alert or just use what fits)
net_rx: this.calculateNetwork(stats, 'rx'),
net_tx: this.calculateNetwork(stats, 'tx'),
restart_count: container.RestartCount || 0
restart_count: 0 // Simplification since ContainerInfo doesn't have it natively
};
db.addContainerMetric({
container_id: container.Id,
stack_name: stackName,
cpu_percent: metrics.cpu_percent || 0,
memory_mb: metrics.memory_mb || 0,
net_rx_mb: metrics.net_rx || 0,
net_tx_mb: metrics.net_tx || 0,
timestamp: Date.now()
});
const stackAlerts = alerts.filter(a => a.stack_name === stackName);
for (const rule of stackAlerts) {
const ruleId = rule.id!;
const currentValue = metrics[rule.metric as keyof typeof metrics];
@@ -265,7 +270,7 @@ export class MonitorService {
const safeCurrent = typeof currentValue === 'number' ? Number(currentValue.toFixed(2)) : currentValue;
const safeThreshold = typeof rule.threshold === 'number' ? Number(rule.threshold.toFixed(2)) : rule.threshold;
const message = `The **${metricName}** for **${rule.stack_name}** ${operatorPhrase} **${safeThreshold}${unit}** (Currently: ${safeCurrent}${unit}).`;
const message = `[Node: ${node.name}] The **${metricName}** for **${rule.stack_name}** ${operatorPhrase} **${safeThreshold}${unit}** (Currently: ${safeCurrent}${unit}).`;
await NotificationService.getInstance().dispatchAlert(
'warning',
@@ -284,11 +289,17 @@ export class MonitorService {
}
}
} catch (e) {
console.error(`Error parsing stats for container ${container.Id}`, e);
console.error(`Error parsing stats for container ${container.Id} on node ${node.name}`, e);
}
}
} catch (e) { }
} catch (err) {
console.error(`Error fetching containers for node ${node.name}`, err);
}
}
try {
db.cleanupOldMetrics(24);
} catch (e) { }
}
private evaluateCondition(actual: number, operator: string, threshold: number): boolean {
@@ -304,8 +315,10 @@ export class MonitorService {
private calculateCpuPercent(stats: any): number {
let cpuPercent = 0.0;
if (!stats?.cpu_stats?.cpu_usage || !stats?.precpu_stats?.cpu_usage) return 0.0;
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const systemDelta = (stats.cpu_stats.system_cpu_usage || 0) - (stats.precpu_stats.system_cpu_usage || 0);
const numCpus = stats.cpu_stats.online_cpus || (stats.cpu_stats.cpu_usage.percpu_usage ? stats.cpu_stats.cpu_usage.percpu_usage.length : 1);
if (systemDelta > 0.0 && cpuDelta > 0.0) {
@@ -315,6 +328,8 @@ export class MonitorService {
}
private calculateMemoryPercent(stats: any): number {
if (!stats?.memory_stats?.usage || !stats?.memory_stats?.limit) return 0.0;
const used_memory = stats.memory_stats.usage - (stats.memory_stats.stats?.cache || 0);
const available_memory = stats.memory_stats.limit;
if (available_memory > 0) {
+223
View File
@@ -0,0 +1,223 @@
import Docker from 'dockerode';
import axios from 'axios';
import { DatabaseService, Node } from './DatabaseService';
/**
* NodeRegistry: Manages connections for multiple nodes.
*
* In the Distributed API model:
* - Local nodes: direct Docker socket connection via Dockerode (unchanged)
* - Remote nodes: HTTP/WS proxy to a remote Sencho instance (api_url + api_token)
* No direct Docker TCP connections are made for remote nodes.
*/
export class NodeRegistry {
private static instance: NodeRegistry;
private connections: Map<number, Docker> = new Map();
private constructor() { }
public static getInstance(): NodeRegistry {
if (!NodeRegistry.instance) {
NodeRegistry.instance = new NodeRegistry();
}
return NodeRegistry.instance;
}
/**
* Get a Docker client for a LOCAL node only.
* Remote nodes are never accessed via Dockerode - use the HTTP proxy instead.
*/
public getDocker(nodeId: number): Docker {
if (this.connections.has(nodeId)) {
return this.connections.get(nodeId)!;
}
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node) {
throw new Error(`Node with id ${nodeId} not found`);
}
if (node.type === 'remote') {
throw new Error(
`Node "${node.name}" is a remote Distributed API node. ` +
`Its Docker daemon is not directly accessible - all requests are proxied via HTTP.`
);
}
const docker = new Docker();
this.connections.set(nodeId, docker);
return docker;
}
/**
* Get the Docker client for the default node.
* Backward-compatible path for local-node code.
*/
public getDefaultDocker(): Docker {
const db = DatabaseService.getInstance();
const defaultNode = db.getDefaultNode();
if (!defaultNode || !defaultNode.id) {
return new Docker();
}
return this.getDocker(defaultNode.id);
}
/**
* Get the default node ID.
*/
public getDefaultNodeId(): number {
const db = DatabaseService.getInstance();
const defaultNode = db.getDefaultNode();
return defaultNode?.id || 1;
}
/**
* Get a node configuration by its ID.
*/
public getNode(nodeId: number): Node | undefined {
const db = DatabaseService.getInstance();
return db.getNode(nodeId);
}
/**
* Get the HTTP proxy target for a remote node.
* Returns { apiUrl, apiToken } for use by the HTTP proxy middleware.
*/
public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null {
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node || node.type !== 'remote' || !node.api_url || !node.api_token) {
return null;
}
return { apiUrl: node.api_url, apiToken: node.api_token };
}
/**
* Test connectivity to a specific node.
* - Local: pings the Docker daemon directly
* - Remote: makes a GET to /api/auth/check on the remote Sencho instance
*/
public async testConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node) {
return { success: false, error: 'Node not found' };
}
if (node.type === 'remote') {
return this.testRemoteConnection(node);
}
return this.testLocalConnection(nodeId);
}
private async testLocalConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
try {
const docker = new Docker();
const info = await docker.info();
if (!info || !info.OperatingSystem || typeof info.Containers !== 'number') {
throw new Error('Invalid response from Docker daemon.');
}
db.updateNodeStatus(nodeId, 'online');
return {
success: true,
info: {
name: info.Name,
serverVersion: info.ServerVersion,
os: info.OperatingSystem,
architecture: info.Architecture,
containers: info.Containers,
containersRunning: info.ContainersRunning,
images: info.Images,
memTotal: info.MemTotal,
cpus: info.NCPU,
}
};
} catch (error: any) {
db.updateNodeStatus(nodeId, 'offline');
return { success: false, error: error.message || 'Connection failed' };
}
}
private async testRemoteConnection(node: Node): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
if (!node.api_url || !node.api_token) {
return { success: false, error: 'Remote node is missing an API URL or token. Configure it in Settings → Nodes.' };
}
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
try {
// 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.
const [statsResult, sysResult, imagesResult] = await Promise.allSettled([
axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }),
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
axios.get(`${baseUrl}/api/system/images`, { headers, timeout: 8000 }),
]);
const stats = statsResult.status === 'fulfilled' ? statsResult.value.data : null;
const sys = sysResult.status === 'fulfilled' ? sysResult.value.data : null;
const images = imagesResult.status === 'fulfilled' ? imagesResult.value.data : null;
return {
success: true,
info: {
name: node.name,
serverVersion: 'Remote Sencho',
os: 'Remote',
architecture: 'Remote',
containers: stats?.total ?? '-',
containersRunning: stats?.active ?? '-',
images: Array.isArray(images) ? images.length : '-',
memTotal: sys?.memory?.total ?? 0,
cpus: sys?.cpu?.cores ?? '-',
}
};
} catch (error: any) {
db.updateNodeStatus(node.id, 'offline');
const msg = error.response?.status === 401
? 'Authentication failed - check the API token.'
: (error.message || 'Connection failed');
return { success: false, error: msg };
}
}
/**
* Evict a cached Docker connection (e.g., after node config change).
*/
public evictConnection(nodeId: number): void {
this.connections.delete(nodeId);
}
/**
* Flush all cached connections.
*/
public flushAll(): void {
this.connections.clear();
}
/**
* Get the compose directory for a local node.
*/
public getComposeDir(nodeId: number): string {
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
return node?.compose_dir || process.env.COMPOSE_DIR || '/app/compose';
}
}
+167
View File
@@ -0,0 +1,167 @@
import axios from 'axios';
import { DatabaseService } from './DatabaseService';
export interface TemplateEnv {
name: string;
label?: string;
default?: string;
}
export interface TemplateVolume {
container: string;
bind?: string;
readonly?: boolean;
}
export interface Template {
type?: number;
title: string;
description: string;
logo?: string;
image?: string;
ports?: string[];
volumes?: TemplateVolume[] | string[];
env?: TemplateEnv[];
categories?: string[];
platform?: string;
github_url?: string;
docs_url?: string;
architectures?: string[];
stars?: number;
repository?: {
url: string;
stackfile: string;
};
}
export interface TemplatesResponse {
version: string;
templates: Template[];
}
export class TemplateService {
private cachedTemplates: Template[] = [];
private lastFetchTime: number = 0;
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
public async getTemplates(): Promise<Template[]> {
const now = Date.now();
if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) {
return this.cachedTemplates;
}
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
// Default to a reliable LSIO Portainer v2 template registry if not set
const registryUrl = settings.template_registry_url || 'https://api.linuxserver.io/api/v1/images?include_config=true';
const response = await axios.get<any>(registryUrl);
if (registryUrl.includes('api.linuxserver.io')) {
// Official LSIO API Schema Mapping
const lsioApps = response.data?.data?.repositories?.linuxserver || [];
this.cachedTemplates = Object.values(lsioApps).map((app: any) => {
return {
type: 1,
title: app.name,
description: app.description || '',
logo: app.logo || `https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/${app.name}-logo.png`,
image: `lscr.io/linuxserver/${app.name}:latest`,
github_url: app.github,
docs_url: app.readme,
architectures: app.arch,
stars: app.stars,
// 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) => {
const folderName = v.path.split('/').filter(Boolean).pop() || 'data';
return {
container: v.path,
bind: `./${folderName}` // Proactively create a clean relative path
};
}),
env: (app.config?.environment || []).map((e: any) => ({
name: e.name,
label: e.desc || e.name,
default: e.default || ''
}))
};
});
} else {
// Legacy Portainer v2 Format (Fallback for custom registries)
this.cachedTemplates = (response.data.templates || []).filter((t: Template) => !!t.image && t.type === 1);
}
this.lastFetchTime = now;
return this.cachedTemplates;
} catch (error) {
console.error('Failed to fetch templates', error);
if (this.cachedTemplates.length > 0) {
return this.cachedTemplates;
}
throw new Error('Could not fetch templates from registry');
}
}
public generateComposeFromTemplate(template: Template): string {
let yaml = `services:\n app:\n`;
if (template.image) {
yaml += ` image: ${template.image}\n`;
}
yaml += ` restart: unless-stopped\n`;
if (template.ports && template.ports.length > 0) {
yaml += ` ports:\n`;
for (const port of template.ports) {
yaml += ` - "${port}"\n`;
}
}
if (template.volumes && template.volumes.length > 0) {
yaml += ` volumes:\n`;
for (const vol of template.volumes) {
let hostPath = '';
let containerPath = '';
let options = '';
if (typeof vol === 'string') {
const parts = vol.split(':');
if (parts.length === 1) {
yaml += ` - ${vol}\n`;
continue;
}
hostPath = parts[0];
containerPath = parts[1];
options = parts.slice(2).join(':');
if (options) options = `:${options}`;
} else if (vol.container) {
containerPath = vol.container;
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
options = vol.readonly ? ':ro' : '';
} else {
continue;
}
yaml += ` - ${hostPath}:${containerPath}${options}\n`;
}
}
yaml += ` env_file:\n - .env\n`;
return yaml;
}
public generateEnvString(envVars: Record<string, string>): string {
return Object.entries(envVars)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
}
}
export const templateService = new TemplateService();
+74
View File
@@ -0,0 +1,74 @@
export interface ErrorRule {
id: string;
type: 'startup' | 'runtime' | 'ambiguous';
pattern: RegExp;
getMessage: (matches: RegExpMatchArray) => string;
canSilentlyRollback: boolean;
}
export class ErrorParser {
private static rules: ErrorRule[] = [
{
id: 'PORT_CONFLICT',
type: 'startup',
pattern: /(?:bind: address already in use|ports are not available: exposing port TCP [^:]+:(\d+))/,
getMessage: (m) => `Port ${m[1] || 'conflict'} is already in use by another service on this server.`,
canSilentlyRollback: true
},
{
id: 'NAME_CONFLICT',
type: 'startup',
pattern: /Conflict\. The container name "\/?([^"]+)" is already in use/,
getMessage: (m) => `A container named '${m[1]}' already exists.`,
canSilentlyRollback: true
},
{
id: 'YAML_SYNTAX',
type: 'startup',
pattern: /(?:yaml: line (\d+):|mapping values are not allowed here)/,
getMessage: (m) => m[1] ? `Syntax error in compose.yaml near line ${m[1]}.` : `Syntax error in compose.yaml.`,
canSilentlyRollback: false
},
{
id: 'MISSING_ENV',
type: 'startup',
pattern: /(?:invalid interpolation format|required variable ([^\s]+) is missing)/,
getMessage: (m) => `Missing required environment variable: ${m[1] || 'Check configuration'}.`,
canSilentlyRollback: false
},
{
id: 'ARCH_MISMATCH',
type: 'runtime',
pattern: /(?:exec format error|does not match the specified platform)/i,
getMessage: () => `Architecture mismatch. This image is not compatible with this server's CPU architecture.`,
canSilentlyRollback: true
},
{
id: 'MISSING_NETWORK',
type: 'startup',
pattern: /network ([^\s]+) declared as external, but could not be found/,
getMessage: (m) => `The external network '${m[1]}' does not exist.`,
canSilentlyRollback: false
},
{
id: 'HOST_PORT_CONFLICT',
type: 'startup',
pattern: /host-mode networking can not work with published ports/,
getMessage: () => `Network mode 'host' cannot be combined with explicit port mappings.`,
canSilentlyRollback: false
}
];
public static parse(errorOutput: string): { message: string, rule: ErrorRule | null } {
for (const rule of this.rules) {
const match = errorOutput.match(rule.pattern);
if (match) {
return { message: rule.getMessage(match), rule };
}
}
// Fallback: Extract the last meaningful line, strip Docker progress bars
const lines = errorOutput.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.includes('Downloading') && !l.includes('Extracting') && !l.includes('Pulling') && !l.includes('Download complete'));
const lastLine = lines.length > 0 ? lines[lines.length - 1] : 'Unknown deployment error occurred.';
return { message: lastLine, rule: null };
}
}
+167 -163
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -34,7 +35,7 @@
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.7.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
@@ -293,6 +294,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1184,6 +1194,36 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -2248,42 +2288,6 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -2641,18 +2645,6 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -3087,12 +3079,6 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
@@ -3704,7 +3690,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -3875,6 +3860,16 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
@@ -3905,16 +3900,6 @@
"node": ">=10.13.0"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -4166,9 +4151,9 @@
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
@@ -4178,6 +4163,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4365,16 +4359,6 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4455,7 +4439,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4819,6 +4802,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4826,6 +4815,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4941,6 +4942,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5084,6 +5094,23 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5118,35 +5145,10 @@
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -5205,6 +5207,21 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -5227,58 +5244,54 @@
}
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=18"
"node": ">=14"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -5650,19 +5663,10 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
+2 -1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -36,7 +37,7 @@
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.7.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
+6 -1
View File
@@ -1,4 +1,5 @@
import { AuthProvider, useAuth } from './context/AuthContext';
import { NodeProvider } from './context/NodeContext';
import { Login } from './components/Login';
import { Setup } from './components/Setup';
import EditorLayout from './components/EditorLayout';
@@ -30,7 +31,11 @@ function AppContent() {
);
}
return <EditorLayout />;
return (
<NodeProvider>
<EditorLayout />
</NodeProvider>
);
}
import { Toaster } from 'sonner';
+459
View File
@@ -0,0 +1,459 @@
import { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Search, Rocket, Loader2, Info, ExternalLink, Github, Star } from "lucide-react";
import { toast } from "sonner";
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
export interface TemplateEnv {
name: string;
label?: string;
default?: string;
}
export interface Template {
type?: number;
title: string;
description: string;
logo?: string;
image?: string;
ports?: string[];
volumes?: any[];
env?: TemplateEnv[];
categories?: string[];
github_url?: string;
docs_url?: string;
architectures?: string[];
stars?: number;
}
interface AppStoreViewProps {
onDeploySuccess: (stackName: string) => void;
}
export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
const { activeNode } = useNodes();
const [templates, setTemplates] = useState<Template[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [stackName, setStackName] = useState('');
const [envVars, setEnvVars] = useState<Record<string, string>>({});
const [isDeploying, setIsDeploying] = useState(false);
const [loading, setLoading] = useState(true);
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
const [portVars, setPortVars] = useState<Record<string, string>>({});
const [isDescExpanded, setIsDescExpanded] = useState(false);
const [volVars, setVolVars] = useState<Record<string, string>>({});
const [customEnvs, setCustomEnvs] = useState<Array<{ key: string, value: string }>>([]);
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvVal, setNewEnvVal] = useState('');
useEffect(() => {
fetchTemplates();
}, []);
const fetchTemplates = async () => {
try {
const res = await apiFetch('/templates');
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");
} finally {
setLoading(false);
}
};
const handleSelectTemplate = (t: Template) => {
setSelectedTemplate(t);
const localEnvs = [...(t.env || [])];
// Inject LSIO standards if missing from the API
if (!localEnvs.find(e => e.name === 'PUID')) localEnvs.push({ name: 'PUID', label: 'User ID (PUID)', default: '1000' });
if (!localEnvs.find(e => e.name === 'PGID')) localEnvs.push({ name: 'PGID', label: 'Group ID (PGID)', default: '1000' });
if (!localEnvs.find(e => e.name === 'TZ')) {
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
localEnvs.push({ name: 'TZ', label: 'Timezone', default: browserTz });
}
const initEnvs: Record<string, string> = {};
localEnvs.forEach(e => {
initEnvs[e.name] = e.default || '';
});
setEnvVars(initEnvs);
t.env = localEnvs; // Mutate local template copy so they render
// Initialize Volumes
const initVols: Record<string, string> = {};
t.volumes?.forEach((v: any) => {
if (v.container) {
initVols[v.container] = v.bind || `./${v.container.split('/').filter(Boolean).pop() || 'data'}`;
}
});
setVolVars(initVols);
setCustomEnvs([]); // Reset custom envs
setNewEnvKey('');
setNewEnvVal('');
const initPorts: Record<string, string> = {};
t.ports?.forEach(p => {
const parts = p.split(':');
if (parts.length > 1) {
initPorts[p] = parts[0]; // Store just the host port for editing
}
});
setPortVars(initPorts);
setIsDescExpanded(false); // Reset description toggle
// Auto-generate stack name from title
const defaultName = t.title
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
setStackName(defaultName);
setIsSheetOpen(true);
};
const handleDeploy = async () => {
if (!stackName.trim()) {
toast.error("Stack name is required");
return;
}
setIsDeploying(true);
const modifiedTemplate = { ...selectedTemplate };
if (modifiedTemplate.ports) {
modifiedTemplate.ports = modifiedTemplate.ports.map(p => {
const parts = p.split(':');
if (parts.length > 1 && portVars[p]) {
return `${portVars[p]}:${parts[1]}`; // Stitch the edited host port back
}
return p;
});
}
// Process Volumes
if (modifiedTemplate.volumes) {
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v: any) => {
if (v.container && volVars[v.container] !== undefined) {
return { ...v, bind: volVars[v.container] };
}
return v;
});
}
// Merge envs
const finalEnvVars = { ...envVars };
customEnvs.forEach(ce => {
if (ce.key.trim()) finalEnvVars[ce.key.trim()] = ce.value;
});
try {
const res = await apiFetch('/templates/deploy', {
method: 'POST',
body: JSON.stringify({
stackName: stackName.trim(),
template: modifiedTemplate,
envVars: finalEnvVars
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to deploy template');
toast.success(`${selectedTemplate?.title} deployed successfully!`);
setIsSheetOpen(false);
onDeploySuccess(stackName.trim());
} catch (err: any) {
toast.error(err.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()))
);
return (
<div className="flex flex-col h-full space-y-6">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search App Store..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
{filtered.map((t, idx) => (
<Card
key={idx}
className="cursor-pointer hover:border-primary transition-colors flex flex-col overflow-hidden h-full"
onClick={() => handleSelectTemplate(t)}
>
<CardHeader className="flex flex-row items-start gap-4 space-y-0">
<div className="w-12 h-12 rounded bg-muted/50 p-1 flex-shrink-0 flex items-center justify-center bg-white overflow-hidden">
{!imgErrors[t.title] && t.logo ? (
<img src={t.logo} alt={t.title} className="w-full h-full object-contain" onError={() => setImgErrors(prev => ({ ...prev, [t.title]: true }))} />
) : (
<Rocket className="w-6 h-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">{t.title}</CardTitle>
<p className="text-sm text-muted-foreground line-clamp-2 mt-1 min-h-[40px]">
{t.description}
</p>
</div>
</CardHeader>
{t.categories && t.categories.length > 0 && (
<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">
{c}
</Badge>
))}
</div>
</CardContent>
)}
</Card>
))}
{filtered.length === 0 && (
<div className="col-span-full py-12 text-center text-muted-foreground">
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No apps found matching "{searchQuery}"</p>
</div>
)}
</div>
)}
</div>
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetContent className="w-full sm:max-w-md flex flex-col h-full" side="right">
{selectedTemplate && (
<div className="flex flex-col h-full">
<SheetHeader className="mb-6 text-left">
{activeNode?.type === 'remote' && (
<div className="mb-2">
<Badge variant="secondary" className="text-xs font-normal">
Deploying to: {activeNode.name}
</Badge>
</div>
)}
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded bg-white p-1 flex-shrink-0 flex items-center justify-center overflow-hidden border">
{!imgErrors[selectedTemplate.title] && selectedTemplate.logo ? (
<img src={selectedTemplate.logo} alt={selectedTemplate.title} className="w-full h-full object-contain" onError={() => setImgErrors(prev => ({ ...prev, [selectedTemplate.title]: true }))} />
) : (
<Rocket className="w-8 h-8 text-muted-foreground" />
)}
</div>
<div>
<SheetTitle className="text-xl">{selectedTemplate.title}</SheetTitle>
<div className="mt-1">
<SheetDescription className={isDescExpanded ? "" : "line-clamp-3 text-sm text-muted-foreground"}>
{selectedTemplate.description}
</SheetDescription>
<span className="text-xs text-primary cursor-pointer hover:underline mt-1 inline-block" onClick={() => setIsDescExpanded(!isDescExpanded)}>
{isDescExpanded ? 'Read less' : 'Read more'}
</span>
</div>
{(selectedTemplate.architectures || selectedTemplate.stars !== undefined || selectedTemplate.github_url || selectedTemplate.docs_url) && (
<div className="mt-3 space-y-2">
{selectedTemplate.architectures && selectedTemplate.architectures.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedTemplate.architectures.map(arch => (
<Badge variant="outline" key={arch} className="text-[10px] px-1.5 py-0">
{arch}
</Badge>
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
{selectedTemplate.stars !== undefined && (
<div className="flex items-center gap-1">
<Star className="w-3 h-3 fill-muted-foreground" />
<span>{selectedTemplate.stars}</span>
</div>
)}
{selectedTemplate.github_url && (
<a href={selectedTemplate.github_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground transition-colors">
<Github className="w-3 h-3" />
<span>Source</span>
</a>
)}
{selectedTemplate.docs_url && (
<a href={selectedTemplate.docs_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground transition-colors">
<ExternalLink className="w-3 h-3" />
<span>Docs</span>
</a>
)}
</div>
</div>
)}
</div>
</div>
</SheetHeader>
<ScrollArea className="flex-1 pr-4 -mx-4 px-4">
<div className="space-y-6 pb-8">
<div className="space-y-2">
<Label htmlFor="stackName" className="font-semibold">
Stack Name <span className="text-red-500">*</span>
</Label>
<Input
id="stackName"
value={stackName}
onChange={(e) => setStackName(e.target.value)}
placeholder="e.g. my-app"
/>
<p className="text-xs text-muted-foreground">
This determines the directory name and docker project name.
</p>
</div>
{selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
<div className="space-y-4 pt-4 border-t">
<h4 className="font-semibold">Ports (Host : Container)</h4>
{selectedTemplate.ports.map((p, idx) => {
const parts = p.split(':');
if (parts.length < 2) return null;
return (
<div key={idx} className="flex items-center space-x-2">
<Input
value={portVars[p] || ''}
onChange={(e) => setPortVars(prev => ({ ...prev, [p]: e.target.value }))}
className="w-24 text-center"
/>
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
</div>
);
})}
</div>
)}
{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;
return (
<div key={idx} className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">Container: {v.container}</Label>
<Input
value={volVars[v.container] !== undefined ? volVars[v.container] : ''}
onChange={(e) => setVolVars(prev => ({ ...prev, [v.container]: e.target.value }))}
placeholder={`/path/to/host/dir`}
/>
</div>
);
})}
</div>
)}
{selectedTemplate.env && selectedTemplate.env.length > 0 && (
<div className="space-y-4 pt-4 border-t">
<h4 className="font-semibold">Environment Variables</h4>
{selectedTemplate.env.map((e, idx) => (
<div key={idx} className="space-y-1.5">
<Label htmlFor={`env-${e.name}`} className="text-sm">
{e.label || e.name}
</Label>
<Input
id={`env-${e.name}`}
value={envVars[e.name] !== undefined ? envVars[e.name] : ''}
onChange={(ev) => setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))}
placeholder={e.default || `Enter value for ${e.name}`}
/>
<p className="text-[10px] text-muted-foreground font-mono">
{e.name}
</p>
</div>
))}
</div>
)}
<div className="space-y-4 pt-4 border-t">
<h4 className="font-semibold text-sm">Add Custom Variable</h4>
{customEnvs.map((ce, idx) => (
<div key={idx} className="flex gap-2">
<Input value={ce.key} readOnly className="w-1/3 bg-muted font-mono text-xs" />
<Input value={ce.value} readOnly className="flex-1 bg-muted font-mono text-xs" />
<Button variant="destructive" size="icon" onClick={() => setCustomEnvs(prev => prev.filter((_, i) => i !== idx))}>-</Button>
</div>
))}
<div className="flex gap-2">
<Input placeholder="KEY" value={newEnvKey} onChange={e => setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" />
<Input placeholder="VALUE" value={newEnvVal} onChange={e => setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" />
<Button variant="secondary" onClick={() => {
if (newEnvKey.trim()) {
setCustomEnvs(prev => [...prev, { key: newEnvKey, value: newEnvVal }]);
setNewEnvKey('');
setNewEnvVal('');
}
}}>+</Button>
</div>
</div>
</div>
</ScrollArea>
<SheetFooter className="pt-4 mt-auto border-t sm:justify-start">
<div className="flex flex-col w-full gap-2">
<Button
onClick={handleDeploy}
disabled={isDeploying || !stackName.trim()}
className="w-full"
size="lg"
>
{isDeploying ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Deploying Stack...
</>
) : (
<>
<Rocket className="w-5 h-5 mr-2" />
Deploy {selectedTemplate.title}
</>
)}
</Button>
{isDeploying && (
<p className="text-center text-xs text-muted-foreground animate-pulse">
This may take a few minutes for large images.
</p>
)}
</div>
</SheetFooter>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
+7 -6
View File
@@ -75,7 +75,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
return;
}
// Node exists and has layout safe to initialize xterm!
// Node exists and has layout - safe to initialize xterm!
initTerminal(container);
};
@@ -114,14 +114,15 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
// Connect to WebSocket for bash exec
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`);
wsRef.current = ws;
ws.onopen = () => {
// Kick off the exec session
ws.send(JSON.stringify({
action: 'execContainer',
containerId: containerId,
nodeId: localStorage.getItem('sencho-active-node') || undefined
}));
setIsConnected(true);
@@ -160,7 +161,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
setIsConnected(false);
};
// Handle user input JSON up
// Handle user input - JSON up
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
@@ -224,9 +225,9 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
Interactive bash terminal session for {containerName}
</DialogDescription>
</DialogHeader>
{/* Styling wrapper padding and rounded corners go here */}
{/* Styling wrapper - padding and rounded corners go here */}
<div className="flex-1 rounded-lg bg-[#1e1e1e] p-1 min-h-0" style={{ overflow: 'hidden' }}>
{/* Clean xterm container NO padding, NO overflow-hidden, explicit dimensions */}
{/* Clean xterm container - NO padding, NO overflow-hidden, explicit dimensions */}
<div
ref={terminalRef}
style={{ width: '100%', height: '100%' }}
+183 -12
View File
@@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
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 } from 'lucide-react';
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 { toast } from 'sonner';
@@ -28,6 +28,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SettingsModal } from './SettingsModal';
import { StackAlertSheet } from './StackAlertSheet';
import { AppStoreView } from './AppStoreView';
import { LogViewer } from './LogViewer';
import { GlobalObservabilityView } from './GlobalObservabilityView';
import { useNodes } from '@/context/NodeContext';
interface ContainerInfo {
Id: string;
Names: string[];
@@ -50,6 +55,7 @@ const formatBytes = (bytes: number) => {
export default function EditorLayout() {
const { logout } = useAuth();
const { nodes, activeNode, setActiveNode } = useNodes();
const [files, setFiles] = useState<string[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [content, setContent] = useState<string>('');
@@ -76,7 +82,7 @@ export default function EditorLayout() {
}
return true; // Default to dark mode
});
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources'>('dashboard');
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard');
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
@@ -85,6 +91,13 @@ export default function EditorLayout() {
const [bashModalOpen, setBashModalOpen] = useState(false);
const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null);
// LogViewer state
const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null);
// Image update checker state
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
// Notifications & Settings state
const [notifications, setNotifications] = useState<any[]>([]);
@@ -113,12 +126,17 @@ export default function EditorLayout() {
if (!background) setIsLoading(true);
try {
const res = await apiFetch('/stacks');
const stacks = await res.json();
setFiles(Array.isArray(stacks) ? stacks : []);
if (!res.ok) {
setFiles([]);
return;
}
const data = await res.json();
const fileList: string[] = Array.isArray(data) ? data : [];
setFiles(fileList);
// Fetch status for each stack
const statuses: StackStatus = {};
for (const file of stacks) {
for (const file of fileList) {
try {
const containersRes = await apiFetch(`/stacks/${file}/containers`);
const containers = await containersRes.json();
@@ -137,12 +155,28 @@ export default function EditorLayout() {
}
};
// Notification polling - independent of active node, runs once on mount
useEffect(() => {
refreshStacks();
fetchNotifications();
const notificationInterval = setInterval(fetchNotifications, 5000);
return () => clearInterval(notificationInterval);
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 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.
useEffect(() => {
if (!activeNode) return;
setSelectedFile(null);
setContent('');
setOriginalContent('');
setEnvContent('');
setOriginalEnvContent('');
setContainers([]);
setIsEditing(false);
setActiveView('dashboard');
refreshStacks();
fetchImageUpdates();
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchNotifications = async () => {
try {
@@ -154,6 +188,16 @@ export default function EditorLayout() {
} catch (e) { }
};
const fetchImageUpdates = async () => {
try {
const res = await apiFetch('/image-updates');
if (res.ok) {
const data = await res.json();
setStackUpdates(data);
}
} catch (e) { }
};
const markAllRead = async () => {
try {
await apiFetch('/notifications/read', { method: 'POST' });
@@ -181,9 +225,14 @@ export default function EditorLayout() {
if (!container?.Id) return;
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`);
wsMap[container.Id] = ws;
ws.onopen = () => ws.send(JSON.stringify({ action: 'streamStats', containerId: container.Id }));
ws.onopen = () => ws.send(JSON.stringify({
action: 'streamStats',
containerId: container.Id,
nodeId: activeNodeId || undefined
}));
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
@@ -567,6 +616,16 @@ export default function EditorLayout() {
setSelectedContainer(null);
};
const openLogViewer = (containerId: string, containerName: string) => {
setLogContainer({ id: containerId, name: containerName });
setLogViewerOpen(true);
};
const closeLogViewer = () => {
setLogViewerOpen(false);
setLogContainer(null);
};
// Safe container list with fallback
const safeContainers = containers || [];
// Safe content strings with fallback
@@ -629,6 +688,38 @@ export default function EditorLayout() {
</TooltipProvider>
</div>
{/* Node Switcher */}
{nodes.length > 1 && (
<div className="px-4 pt-2 pb-0">
<Select
value={activeNode?.id?.toString() || ''}
onValueChange={(val) => {
const node = nodes.find(n => n.id === parseInt(val));
if (node) setActiveNode(node);
}}
>
<SelectTrigger className="w-full h-9 text-sm">
<div className="flex items-center gap-2">
<Server className="w-3.5 h-3.5 text-muted-foreground" />
<SelectValue placeholder="Select node" />
</div>
</SelectTrigger>
<SelectContent>
{nodes.map(node => (
<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.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Create Stack Button */}
<div className="p-4">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
@@ -693,6 +784,13 @@ export default function EditorLayout() {
/>
<span className="flex-1 truncate">{getDisplayName(file)}</span>
{stackUpdates[file] && (
<span
className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0"
title="Update available"
/>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -720,7 +818,22 @@ export default function EditorLayout() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header Bar */}
<div className="h-16 flex items-center justify-end px-6 border-b border-border gap-4">
<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 */}
<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">
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0" />
{activeNode.name}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/50 border border-border text-muted-foreground text-sm">
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
{activeNode?.name ?? 'Local'}
</div>
)}
</div>
<div className="flex items-center gap-4">
{/* Home Button */}
<Button
variant="outline"
@@ -765,6 +878,28 @@ export default function EditorLayout() {
<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>
{/* Settings Modal Toggle */}
<Button
@@ -844,11 +979,14 @@ export default function EditorLayout() {
</ScrollArea>
</PopoverContent>
</Popover>
</div>{/* end right-side buttons */}
</div>
{/* Main Workspace */}
<div className="flex-1 overflow-y-auto p-6">
{activeView === 'resources' ? (
{activeView === 'templates' ? (
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
) : activeView === 'resources' ? (
<ResourcesView />
) : activeView === 'host-console' ? (
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
@@ -962,7 +1100,12 @@ export default function EditorLayout() {
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => window.open(`http://${window.location.hostname}:${mainPort}`, '_blank')}
onClick={() => {
const host = activeNode?.type === 'remote' && activeNode?.api_url
? new URL(activeNode.api_url).hostname
: window.location.hostname;
window.open(`http://${host}:${mainPort}`, '_blank');
}}
>
<ExternalLink className="w-4 h-4" />
</Button>
@@ -971,6 +1114,22 @@ export default function EditorLayout() {
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => openLogViewer(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
disabled={container?.State !== 'running'}
>
<ScrollText className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Live Logs</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -1100,6 +1259,8 @@ export default function EditorLayout() {
</Card>
</div>
</ErrorBoundary>
) : activeView === 'global-observability' ? (
<GlobalObservabilityView />
) : (
<HomeDashboard />
)}
@@ -1132,6 +1293,16 @@ export default function EditorLayout() {
/>
)}
{/* LogViewer Modal */}
{logContainer && (
<LogViewer
isOpen={logViewerOpen}
onClose={closeLogViewer}
containerId={logContainer.id}
containerName={logContainer.name}
/>
)}
{/* Settings Modal */}
<SettingsModal
@@ -0,0 +1,279 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
interface LogEntry {
stackName: string;
containerName: string;
source: string;
level: string;
message: string;
timestampMs: number;
}
export function GlobalObservabilityView() {
const { activeNode } = useNodes();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [allStacks, setAllStacks] = useState<string[]>([]);
// Settings state
const [devMode, setDevMode] = useState(false);
const [pollRate, setPollRate] = useState(5);
// Filters
const [searchQuery, setSearchQuery] = useState('');
const [selectedStacks, setSelectedStacks] = useState<string[]>([]);
const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL');
const [clearedAt, setClearedAt] = useState<number>(0);
const bottomRef = useRef<HTMLDivElement>(null);
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
// SSE throttle buffer
const bufferRef = useRef<LogEntry[]>([]);
// Fetch settings on mount
useEffect(() => {
const fetchSettings = async () => {
try {
const res = await apiFetch('/settings');
if (res.ok) {
const data = await res.json();
setDevMode(data.developer_mode === '1');
setPollRate(parseInt(data.global_logs_refresh || '5', 10));
}
} catch (e) {
console.error('Failed to fetch settings:', e);
}
};
fetchSettings();
}, []);
// Fetch definitive stack list from the filesystem, independent of log data
useEffect(() => {
const fetchStacks = async () => {
try {
const res = await apiFetch('/stacks');
if (res.ok) {
const stacks: string[] = await res.json();
setAllStacks(stacks.sort());
}
} catch (err) {
console.error('Failed to fetch stacks:', err);
}
};
fetchStacks();
}, []);
// Data fetching: Polling (standard) vs SSE (dev mode)
useEffect(() => {
if (devMode) {
// SSE mode
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${activeNodeId}`);
eventSource.onmessage = (event) => {
try {
const entry: LogEntry = JSON.parse(event.data);
bufferRef.current.push(entry);
} catch (e) { /* ignore parse errors */ }
};
eventSource.onerror = () => {
// SSE will auto-reconnect, no action needed
};
// 500ms throttle: flush buffer into React state
const flushInterval = setInterval(() => {
if (bufferRef.current.length > 0) {
const batch = bufferRef.current.splice(0);
setLogs(prev => {
const merged = [...prev, ...batch];
merged.sort((a, b) => a.timestampMs - b.timestampMs);
return merged.slice(-10000);
});
}
}, 500);
setLoading(false);
return () => {
eventSource.close();
clearInterval(flushInterval);
bufferRef.current = [];
};
} else {
// Standard polling mode
const fetchData = async () => {
setLoading(true);
try {
const logsRes = await apiFetch('/logs/global');
if (logsRes.ok) {
setLogs(await logsRes.json());
}
} catch (error) {
console.error('Failed to fetch global logs:', error);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, pollRate * 1000);
return () => clearInterval(interval);
}
}, [devMode, pollRate]);
const handleStackToggle = (stack: string) => {
setSelectedStacks(prev =>
prev.includes(stack) ? prev.filter(s => s !== stack) : [...prev, stack]
);
};
const handleClearLogs = () => {
setClearedAt(Date.now());
};
const filteredLogs = useMemo(() => {
return logs.filter(log => {
if (log.timestampMs < clearedAt) return false;
if (selectedStacks.length > 0 && !selectedStacks.includes(log.stackName)) return false;
if (streamFilter !== 'ALL' && log.source !== streamFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
return log.message.toLowerCase().includes(query) ||
log.containerName.toLowerCase().includes(query) ||
log.stackName.toLowerCase().includes(query);
}
return true;
});
}, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]);
useEffect(() => {
if (isAutoScrollEnabled) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [filteredLogs, isAutoScrollEnabled]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50;
setIsAutoScrollEnabled(isAtBottom);
}, []);
const handleDownload = () => {
if (filteredLogs.length === 0) return;
const blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${l.containerName}] ${l.level}: ${l.message}`).join('\n')], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `sencho-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
{/* Node Context Indicator */}
{activeNode?.type === 'remote' && (
<div className="absolute top-2 left-4 z-10 flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md px-2.5 py-1 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
{activeNode.name}
</div>
)}
{/* Floating Action Bar */}
<div className="absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 opacity-0 group-hover:opacity-100 focus-within:opacity-100 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md p-1 pr-1">
<div className="relative flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm w-48 bg-transparent"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-sm">
<Filter className="w-3.5 h-3.5 mr-2" />
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{allStacks.map(stack => (
<DropdownMenuCheckboxItem
key={stack}
checked={selectedStacks.includes(stack)}
onCheckedChange={() => handleStackToggle(stack)}
>
{stack}
</DropdownMenuCheckboxItem>
))}
{allStacks.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No stacks found</div>
)}
</DropdownMenuContent>
</DropdownMenu>
<Select value={streamFilter} onValueChange={(val: any) => setStreamFilter(val)}>
<SelectTrigger className="w-[110px] h-8 text-sm">
<SelectValue placeholder="Stream" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Streams</SelectItem>
<SelectItem value="STDOUT">STDOUT</SelectItem>
<SelectItem value="STDERR">STDERR</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
<Download className="w-3.5 h-3.5" />
</Button>
{devMode && (
<div className="flex items-center px-2 text-xs text-emerald-400 font-mono animate-pulse">
LIVE
</div>
)}
</div>
{loading && logs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
</div>
)}
<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">
<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>
<span className={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
</div>
))}
<div ref={bottomRef} />
</>
) : (
<div className="text-gray-500 italic p-4 text-center mt-10">
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
</div>
)}
</div>
</div>
);
}
+119 -8
View File
@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { useState, useEffect, useMemo } from 'react';
import { useNodes } from '@/context/NodeContext';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
@@ -51,6 +54,7 @@ const formatBytes = (bytes: number) => {
};
export default function HomeDashboard() {
const { activeNode } = useNodes();
const [dockerRunInput, setDockerRunInput] = useState('');
const [isConverting, setIsConverting] = useState(false);
const [convertedYaml, setConvertedYaml] = useState('');
@@ -58,12 +62,15 @@ export default function HomeDashboard() {
const [newStackName, setNewStackName] = useState('');
const [stats, setStats] = useState<Stats>({ active: 0, exited: 0, total: 0, inactive: 0 });
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<any[]>([]);
// Fetch stats from backend
// 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 });
const fetchStats = async () => {
try {
const res = await apiFetch('/stats');
if (!res.ok) return;
const data = await res.json();
setStats(data);
} catch (error) {
@@ -73,15 +80,15 @@ export default function HomeDashboard() {
fetchStats();
const interval = setInterval(fetchStats, 5000);
return () => clearInterval(interval);
}, []);
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch system stats from backend
// Fetch system stats (CPU/RAM/Disk/Network) - 5s polling, re-runs on node switch
useEffect(() => {
setSystemStats(null);
const fetchSystemStats = async () => {
try {
const res = await apiFetch('/system/stats');
const data = await res.json();
setSystemStats(data);
if (res.ok) setSystemStats(await res.json());
} catch (error) {
console.error('Failed to fetch system stats:', error);
}
@@ -89,7 +96,54 @@ export default function HomeDashboard() {
fetchSystemStats();
const interval = setInterval(fetchSystemStats, 5000);
return () => clearInterval(interval);
}, []);
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch historical metrics - intentionally slow 60s poll to prevent OOM.
// The backend now returns 1-minute buckets (down from raw 5s points), so
// re-fetching more often than 60s would return the same data anyway.
useEffect(() => {
setMetrics([]);
const fetchMetrics = async () => {
try {
const res = await apiFetch('/metrics/historical');
if (res.ok) setMetrics(await res.json());
} catch (error) {
console.error('Failed to fetch historical metrics:', error);
}
};
fetchMetrics();
const interval = setInterval(fetchMetrics, 60000);
return () => clearInterval(interval);
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const chartData = useMemo(() => {
const buckets: Record<string, { time: string; timestamp: number; cpu: number; ram: number }> = {};
const cores = systemStats?.cpu.cores || 1;
metrics.forEach(m => {
const date = new Date(m.timestamp);
date.setSeconds(0, 0);
const key = date.getTime() + '';
if (!buckets[key]) {
buckets[key] = {
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
timestamp: date.getTime(),
cpu: 0,
ram: 0
};
}
buckets[key].cpu += (m.cpu_percent / cores);
buckets[key].ram += (m.memory_mb / 1024);
});
return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp);
}, [metrics, systemStats]);
const chartConfig = {
cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' },
ram: { label: 'RAM Usage (GB)', color: 'var(--chart-2)' },
};
const handleConvert = async () => {
if (!dockerRunInput.trim()) return;
@@ -242,6 +296,63 @@ export default function HomeDashboard() {
</Card>
</div>
{/* Historical Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="rounded-xl border-muted bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
<Activity className="w-4 h-4 text-primary" />
<span>Normalized CPU Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total CPU percentage over total host cores.</CardDescription>
</CardHeader>
<CardContent className="h-[250px]">
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, 100]} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No historical CPU data.
</div>
)}
</CardContent>
</Card>
<Card className="rounded-xl border-muted bg-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
<Activity className="w-4 h-4 text-primary" />
<span>Normalized RAM Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total RAM allocation in GB.</CardDescription>
</CardHeader>
<CardContent className="h-[250px]">
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="ram" stroke="var(--color-ram)" fill="var(--color-ram)" fillOpacity={0.4} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No historical RAM data.
</div>
)}
</CardContent>
</Card>
</div>
{/* Docker Run Converter */}
<Card className="rounded-xl border-muted bg-card">
<CardHeader>
+12 -1
View File
@@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
import { Terminal as TerminalIcon, X } from 'lucide-react';
import { Button } from './ui/button';
import '@xterm/xterm/css/xterm.css';
import { useNodes } from '@/context/NodeContext';
interface HostConsoleProps {
stackName?: string | null;
@@ -11,6 +12,7 @@ interface HostConsoleProps {
}
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
const { activeNode } = useNodes();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
@@ -65,7 +67,11 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
});
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${stackName ? `?stack=${encodeURIComponent(stackName)}` : ''}`;
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : '';
const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : '';
const queryString = [nodeParam, stackParam].filter(Boolean).join('&');
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -153,6 +159,11 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
<div className="flex items-center gap-2 font-medium">
<TerminalIcon className="w-4 h-4 text-muted-foreground" />
<span>Host Console</span>
{activeNode && (
<span className="text-muted-foreground font-normal text-sm">
{activeNode.name}
</span>
)}
{stackName && (
<span className="text-muted-foreground font-normal text-sm">
({stackName})
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useState, useRef } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2, Terminal } from "lucide-react";
interface LogViewerProps {
containerId: string | null;
containerName: string;
isOpen: boolean;
onClose: () => void;
}
export function LogViewer({ containerId, containerName, isOpen, onClose }: LogViewerProps) {
const [logs, setLogs] = useState<string[]>([]);
const [isConnected, setIsConnected] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
useEffect(() => {
if (!isOpen || !containerId) return;
setLogs([]);
setIsConnected(false);
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
const eventSource = new EventSource(`/api/containers/${containerId}/logs?nodeId=${activeNodeId}`);
eventSource.onopen = () => setIsConnected(true);
eventSource.onmessage = (event) => {
try {
const newLog = JSON.parse(event.data);
setLogs(prev => {
const updated = [...prev, newLog];
return updated.length > 1000 ? updated.slice(updated.length - 1000) : updated;
});
} catch (err) {
console.error("Failed to parse log line", err);
}
};
eventSource.onerror = () => {
setIsConnected(false);
eventSource.close();
};
return () => {
eventSource.close();
};
}, [isOpen, containerId]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col bg-background border-border">
<DialogHeader className="flex flex-row items-center gap-2 pb-2 border-b">
<Terminal className="w-5 h-5" />
<DialogTitle className="flex-1 text-left font-mono text-sm">
{containerName} {isConnected ? <span className="text-green-500 text-xs ml-2">(connected)</span> : <Loader2 className="inline w-3 h-3 ml-2 animate-spin" />}
</DialogTitle>
</DialogHeader>
<div
ref={scrollRef}
className="flex-1 w-full bg-[#0c0c0c] text-green-400 p-4 rounded-md overflow-y-auto font-mono text-xs mt-2"
>
{logs.length === 0 && !isConnected ? (
<div className="text-muted-foreground">Connecting to container stream...</div>
) : (
logs.map((log, i) => (
<div key={i} className="break-all whitespace-pre-wrap leading-tight mb-1">
{log}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}
+544
View File
@@ -0,0 +1,544 @@
import { useState } from 'react';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
import { Separator } from './ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check } from 'lucide-react';
interface NodeFormData {
name: string;
type: 'local' | 'remote';
api_url: string;
api_token: string;
compose_dir: string;
is_default: boolean;
}
const defaultFormData: NodeFormData = {
name: '',
type: 'remote',
api_url: '',
api_token: '',
compose_dir: '/app/compose',
is_default: false,
};
export function NodeManager() {
const { nodes, refreshNodes } = useNodes();
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [formData, setFormData] = useState<NodeFormData>(defaultFormData);
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);
// Node token generation state
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [generatingToken, setGeneratingToken] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const handleCreate = async () => {
try {
const res = await apiFetch('/nodes', {
method: 'POST',
body: JSON.stringify(formData),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to create node');
}
const { id: newNodeId } = await res.json();
toast.success(`Node "${formData.name}" created successfully`);
setCreateOpen(false);
setFormData(defaultFormData);
// Auto-test the new node connection immediately
if (newNodeId && formData.type === 'remote') {
setTesting(newNodeId);
try {
const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' });
const testData = await testRes.json();
if (testData.success) {
toast.success(`Connected to "${formData.name}" successfully`);
setTestResult({ nodeId: newNodeId, info: testData.info });
} else {
toast.warning(`Node saved, but connection test failed: ${testData.error}`);
}
} catch {
// Non-fatal
} finally {
setTesting(null);
}
}
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Failed to create node');
}
};
const handleEdit = async () => {
if (!editingNodeId) return;
try {
const res = await apiFetch(`/nodes/${editingNodeId}`, {
method: 'PUT',
body: JSON.stringify(formData),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to update node');
}
toast.success(`Node "${formData.name}" updated`);
setEditOpen(false);
setEditingNodeId(null);
setFormData(defaultFormData);
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Failed to update node');
}
};
const openEditDialog = (node: Node) => {
setFormData({
name: node.name,
type: node.type,
api_url: node.api_url || '',
api_token: node.api_token || '',
compose_dir: node.compose_dir,
is_default: node.is_default,
});
setEditingNodeId(node.id);
setEditOpen(true);
};
const handleDelete = async () => {
if (!deletingNode) return;
try {
const res = await apiFetch(`/nodes/${deletingNode.id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to delete node');
}
toast.success(`Node "${deletingNode.name}" deleted`);
setDeleteOpen(false);
setDeletingNode(null);
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Failed to delete node');
}
};
const testConnection = async (node: Node) => {
setTesting(node.id);
setTestResult(null);
try {
const res = await apiFetch(`/nodes/${node.id}/test`, { method: 'POST' });
const result = await res.json();
if (result.success) {
toast.success(`Connected to "${node.name}" successfully`);
setTestResult({ nodeId: node.id, info: result.info });
} else {
toast.error(`Failed to connect: ${result.error}`);
}
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Connection test failed');
} finally {
setTesting(null);
}
};
const generateNodeToken = async () => {
setGeneratingToken(true);
setGeneratedToken(null);
try {
const res = await apiFetch('/auth/generate-node-token', { method: 'POST' });
if (!res.ok) throw new Error('Failed to generate token');
const { token } = await res.json();
setGeneratedToken(token);
toast.success('Node token generated');
} catch (error: any) {
toast.error(error.message || 'Failed to generate token');
} finally {
setGeneratingToken(false);
}
};
const copyToken = async () => {
if (!generatedToken) return;
try {
// Clipboard API requires a secure context (HTTPS or localhost)
await navigator.clipboard.writeText(generatedToken);
setTokenCopied(true);
toast.success('Token copied to clipboard');
setTimeout(() => setTokenCopied(false), 2000);
} catch {
// Fallback for HTTP / non-localhost deployments where Clipboard API is unavailable
try {
const ta = document.createElement('textarea');
ta.value = generatedToken;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setTokenCopied(true);
toast.success('Token copied to clipboard');
setTimeout(() => setTokenCopied(false), 2000);
} catch {
toast.error('Could not copy automatically - please select and copy the token manually.');
}
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'online':
return <Badge variant="default" className="bg-green-600 text-white gap-1"><Wifi className="w-3 h-3" /> Online</Badge>;
case 'offline':
return <Badge variant="destructive" className="gap-1"><WifiOff className="w-3 h-3" /> Offline</Badge>;
default:
return <Badge variant="secondary" className="gap-1">Unknown</Badge>;
}
};
const getNodeIcon = (type: string) => {
return type === 'local'
? <Monitor className="w-4 h-4 text-muted-foreground" />
: <Globe className="w-4 h-4 text-muted-foreground" />;
};
const renderFormFields = () => (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="node-name">Name</Label>
<Input
id="node-name"
placeholder="e.g., Production VPS"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-type">Type</Label>
<Select
value={formData.type}
onValueChange={(val) => setFormData({ ...formData, type: val as 'local' | 'remote', api_url: '', api_token: '' })}
>
<SelectTrigger id="node-type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
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
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{formData.type === 'remote' && (
<>
<div className="space-y-2">
<Label htmlFor="node-api-url">Sencho API URL</Label>
<Input
id="node-api-url"
placeholder="http://192.168.1.50:3000"
value={formData.api_url}
onChange={(e) => setFormData({ ...formData, api_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
The base URL of the Sencho instance running on the remote machine.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="node-api-token">API Token</Label>
<Input
id="node-api-token"
type="password"
placeholder="Paste token from remote Sencho → Settings → Nodes → Generate Token"
value={formData.api_token}
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Generate this token on the <strong>remote</strong> Sencho instance using the "Generate Node Token" button in its Settings Nodes panel.
</p>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="node-compose-dir">Compose Directory</Label>
<Input
id="node-compose-dir"
placeholder="/app/compose"
value={formData.compose_dir}
onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
The root directory where compose stack folders live on this node.
</p>
</div>
</div>
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between pr-8">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Server className="w-5 h-5" />
Nodes
</h2>
<p className="text-sm text-muted-foreground">
Manage connections to local and remote Sencho instances
</p>
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
// Reset form to defaults every time the dialog opens
if (open) setFormData(defaultFormData);
}}
>
<DialogTrigger asChild>
<Button size="sm" className="gap-1 shrink-0">
<Plus className="w-4 h-4" />
Add Node
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader className="pr-8">
<DialogTitle>Add {formData.type === 'local' ? 'Local' : 'Remote'} Node</DialogTitle>
</DialogHeader>
{renderFormFields()}
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={!formData.name || (formData.type === 'remote' && (!formData.api_url || !formData.api_token))}
>
Add Node
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Separator />
{/* Generate Node Token - for use on THIS instance as a remote target */}
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
Generate Node Token
</p>
<p className="text-xs text-muted-foreground mt-1">
Create a long-lived token that allows another Sencho instance to use <strong>this</strong> instance as a remote node. Copy it and paste it into the other Sencho instance's "Add Node" form.
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={generateNodeToken}
disabled={generatingToken}
className="shrink-0"
>
{generatingToken ? 'Generating...' : 'Generate Token'}
</Button>
</div>
{generatedToken && (
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
<code className="flex-1 text-xs font-mono truncate text-muted-foreground">{generatedToken}</code>
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={copyToken}>
{tokenCopied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</Button>
</div>
)}
</div>
{/* Nodes Table */}
<div className="rounded-md border overflow-x-auto w-full">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => (
<TableRow key={node.id}>
<TableCell>
{node.is_default && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
</TooltipTrigger>
<TooltipContent>Default Node</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{getNodeIcon(node.type)}
{node.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{node.type === 'local' ? 'Local' : 'Remote'}</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm font-mono">
{node.type === 'local' ? 'docker.sock' : (node.api_url || '-')}
</TableCell>
<TableCell>{getStatusBadge(node.status)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => testConnection(node)}
disabled={testing === node.id}
>
<Wifi className={`w-4 h-4 ${testing === node.id ? 'animate-pulse' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Test Connection</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEditDialog(node)}
>
<Pencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit Node</TooltipContent>
</Tooltip>
</TooltipProvider>
{!node.is_default && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => { setDeletingNode(node); setDeleteOpen(true); }}
>
<Trash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete Node</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Connection Test Result */}
{testResult && (
<div className="rounded-md border p-4 bg-muted/30 space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Wifi className="w-4 h-4 text-green-500" />
Connection Details - {nodes.find(n => n.id === testResult.nodeId)?.name}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div><span className="text-muted-foreground">Instance:</span> {testResult.info.serverVersion}</div>
<div><span className="text-muted-foreground">OS:</span> {testResult.info.os}</div>
<div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</div>
<div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers}</div>
<div><span className="text-muted-foreground">Images:</span> {testResult.info.images}</div>
<div><span className="text-muted-foreground">CPUs:</span> {testResult.info.cpus}</div>
</div>
</div>
)}
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg">
<DialogHeader className="pr-8">
<DialogTitle>Edit Node</DialogTitle>
</DialogHeader>
{renderFormFields()}
<DialogFooter>
<Button variant="outline" onClick={() => { setEditOpen(false); setEditingNodeId(null); }}>Cancel</Button>
<Button
onClick={handleEdit}
disabled={!formData.name || (formData.type === 'remote' && !formData.api_url)}
>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Node</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove <strong>{deletingNode?.name}</strong>? This will only remove the node from Sencho - it will not affect the remote instance or any running containers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -8,6 +8,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, PieChart as ChartIcon } from 'lucide-react';
import { useNodes } from '@/context/NodeContext';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
import { formatBytes } from '@/lib/utils';
@@ -42,6 +43,7 @@ interface OrphanContainer {
}
export default function ResourcesView() {
const { activeNode } = useNodes();
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
@@ -188,6 +190,9 @@ export default function ResourcesView() {
<div className="flex items-center gap-2">
<HardDrive className="w-6 h-6" />
<h1 className="text-2xl font-bold">Resources Hub</h1>
{activeNode?.type === 'remote' && (
<span className="text-sm font-normal text-muted-foreground"> {activeNode.name}</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
+120 -25
View File
@@ -11,7 +11,10 @@ import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Shield, Activity, Bell, Palette, Moon, Sun } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server } from 'lucide-react';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
interface Agent {
type: 'discord' | 'slack' | 'webhook';
@@ -27,7 +30,16 @@ interface SettingsModalProps {
}
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance'>('account');
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes'>('account');
// When switching to a remote node, reset to a node-scoped section if on a global-only one
useEffect(() => {
if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes')) {
setActiveSection('system');
}
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
// Auth State
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
@@ -45,7 +57,9 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
host_ram_limit: '90',
host_disk_limit: '90',
global_crash: '1',
docker_janitor_gb: '5'
docker_janitor_gb: '5',
global_logs_refresh: '5',
developer_mode: '0'
});
const [isLoading, setIsLoading] = useState(false);
@@ -218,19 +232,25 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[800px] h-[600px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
<DialogContent className="sm:max-w-[900px] h-[650px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
{/* Sidebar */}
<div className="w-[200px] bg-muted/20 border-r border-border flex flex-col p-4 shrink-0">
<div className="font-semibold text-lg mb-6 text-foreground tracking-tight">Settings Hub</div>
<div className="font-semibold text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
{isRemote && (
<div className="text-xs text-muted-foreground mb-5 truncate">{activeNode!.name}</div>
)}
{!isRemote && <div className="mb-5" />}
<nav className="space-y-1.5 flex flex-col">
<Button
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('account')}
>
<Shield className="w-4 h-4 mr-2" />
Account
</Button>
{!isRemote && (
<Button
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('account')}
>
<Shield className="w-4 h-4 mr-2" />
Account
</Button>
)}
<Button
variant={activeSection === 'system' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
@@ -239,22 +259,44 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<Activity className="w-4 h-4 mr-2" />
System Limits
</Button>
{!isRemote && (
<Button
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('notifications')}
>
<Bell className="w-4 h-4 mr-2" />
Notifications
</Button>
)}
{!isRemote && (
<Button
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('appearance')}
>
<Palette className="w-4 h-4 mr-2" />
Appearance
</Button>
)}
<Button
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
variant={activeSection === 'developer' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('notifications')}
onClick={() => setActiveSection('developer')}
>
<Bell className="w-4 h-4 mr-2" />
Notifications
</Button>
<Button
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('appearance')}
>
<Palette className="w-4 h-4 mr-2" />
Appearance
<Code className="w-4 h-4 mr-2" />
Developer
</Button>
{!isRemote && (
<Button
variant={activeSection === 'nodes' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('nodes')}
>
<Server className="w-4 h-4 mr-2" />
Nodes
</Button>
)}
</nav>
</div>
@@ -418,6 +460,59 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
</div>
)}
{activeSection === 'developer' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold tracking-tight">Developer</h3>
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and extended diagnostics.</p>
</div>
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
</div>
<Switch
id="developer_mode"
checked={settings.developer_mode === '1'}
onCheckedChange={(c) => handleSettingChange('developer_mode', c ? '1' : '0')}
/>
</div>
<div className="space-y-2 pt-4 border-t border-border">
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>Standard Log Polling Rate</Label>
<Select
value={settings.global_logs_refresh}
onValueChange={(val) => handleSettingChange('global_logs_refresh', val)}
disabled={settings.developer_mode === '1'}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="Select rate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 second</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
<SelectItem value="10">10 seconds</SelectItem>
</SelectContent>
</Select>
{settings.developer_mode === '1' && (
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden by real-time streaming.</p>
)}
</div>
</div>
<div className="flex justify-end mt-4">
<Button onClick={saveSettings} disabled={isLoading}>Save Developer Settings</Button>
</div>
</div>
)}
{activeSection === 'nodes' && (
<NodeManager />
)}
</div>
</DialogContent>
</Dialog>
+2 -1
View File
@@ -125,11 +125,12 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const cleanStackName = stackName?.replace(/\.(yml|yaml)$/, '');
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
// If a stackName is provided, connect to the dedicated logs WebSocket
// Otherwise, fall back to the generic terminal WebSocket
const wsUrl = cleanStackName
? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs`
? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`
: `${wsProtocol}//${window.location.host}`;
const ws = new WebSocket(wsUrl);
+367
View File
@@ -0,0 +1,367 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+3
View File
@@ -47,6 +47,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
checkAuth();
const handleUnauthorized = () => setAppStatus('notAuthenticated');
window.addEventListener('sencho-unauthorized', handleUnauthorized);
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
}, []);
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
+107
View File
@@ -0,0 +1,107 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch } from '@/lib/api';
export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
api_url?: string;
api_token?: string;
}
interface NodeContextType {
nodes: Node[];
activeNode: Node | null;
setActiveNode: (node: Node) => void;
refreshNodes: () => Promise<void>;
isLoading: boolean;
}
const NodeContext = createContext<NodeContextType | undefined>(undefined);
export function NodeProvider({ children }: { children: React.ReactNode }) {
const [nodes, setNodes] = useState<Node[]>([]);
const [activeNode, setActiveNodeState] = useState<Node | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Ref lets refreshNodes read current activeNode without being a dep (breaks infinite loop)
const activeNodeRef = useRef<Node | null>(null);
activeNodeRef.current = activeNode;
const refreshNodes = useCallback(async () => {
try {
const res = await apiFetch('/nodes');
if (res.ok) {
const data = await res.json();
setNodes(data);
const currentActive = activeNodeRef.current;
if (!currentActive) {
// On initial load, restore from localStorage before falling back to default.
// This keeps the UI dropdown in sync with the node ID that apiFetch is already
// injecting via x-node-id (read directly from localStorage on every request).
const storedId = localStorage.getItem('sencho-active-node');
const storedNode = storedId ? data.find((n: Node) => n.id === parseInt(storedId, 10)) : null;
const nodeToActivate = storedNode ?? data.find((n: Node) => n.is_default) ?? data[0] ?? null;
if (nodeToActivate) {
setActiveNodeState(nodeToActivate);
localStorage.setItem('sencho-active-node', String(nodeToActivate.id));
}
} else {
const updatedActive = data.find((n: Node) => n.id === currentActive.id);
if (updatedActive) {
setActiveNodeState(updatedActive);
localStorage.setItem('sencho-active-node', String(updatedActive.id));
} else {
const fallback = data.find((n: Node) => n.is_default) ?? data[0] ?? null;
if (fallback) {
setActiveNodeState(fallback);
localStorage.setItem('sencho-active-node', String(fallback.id));
} else {
setActiveNodeState(null);
localStorage.removeItem('sencho-active-node');
}
}
}
}
} catch (error) {
console.error('Failed to fetch nodes:', error);
} finally {
setIsLoading(false);
}
}, []); // stable - reads activeNode via ref, not closure capture
const setActiveNode = useCallback((node: Node) => {
setActiveNodeState(node);
localStorage.setItem('sencho-active-node', String(node.id));
}, []);
useEffect(() => {
refreshNodes();
const handleNodeNotFound = () => {
console.warn('[NodeContext] Active node is unreachable or deleted. Forcing sync...');
refreshNodes();
};
window.addEventListener('node-not-found', handleNodeNotFound);
return () => window.removeEventListener('node-not-found', handleNodeNotFound);
}, [refreshNodes]);
return (
<NodeContext.Provider value={{ nodes, activeNode, setActiveNode, refreshNodes, isLoading }}>
{children}
</NodeContext.Provider>
);
}
export function useNodes() {
const context = useContext(NodeContext);
if (!context) {
throw new Error('useNodes must be used within a NodeProvider');
}
return context;
}
+20
View File
@@ -166,6 +166,10 @@
}
@layer base {
:root {
color-scheme: dark;
}
* {
@apply border-border outline-ring/50;
}
@@ -175,6 +179,22 @@
}
}
/* Custom Dark Mode Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted) / 0.8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
html,
body,
#root {
+18 -2
View File
@@ -5,10 +5,13 @@ export async function apiFetch(
options: RequestInit = {}
): Promise<Response> {
const url = `${API_BASE}${endpoint}`;
const activeNodeId = localStorage.getItem('sencho-active-node');
const defaultOptions: RequestInit = {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(activeNodeId ? { 'x-node-id': activeNodeId } : {}),
...options.headers,
},
};
@@ -16,11 +19,24 @@ export async function apiFetch(
const response = await fetch(url, { ...defaultOptions, ...options });
if (response.status === 401) {
// Clear auth state and redirect to login
window.location.href = '/';
// Signal auth failure to AuthContext without a hard page reload
window.dispatchEvent(new Event('sencho-unauthorized'));
throw new Error('Unauthorized');
}
// Intercept 404 Node Not Found responses and force context refresh
if (response.status === 404) {
try {
const clone = response.clone();
const errData = await clone.json();
if (errData.error && errData.error.includes('not found') && errData.error.includes('Node')) {
window.dispatchEvent(new Event('node-not-found'));
}
} catch (e) {
// Ignore JSON parse errors, caller handles standard 404s
}
}
return response;
}
+14
View File
@@ -11,4 +11,18 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: true,
},
'/ws': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: true,
},
},
},
})