diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c6f8e3a8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..9222df74 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/.gitignore b/.gitignore index 575c4e86..2adad6c2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,11 @@ Thumbs.db #PRD Product Requirements Document -plans/ \ No newline at end of file + +# Claude Code +.claude/ +CLAUDE.md +plans/ + +# Playwright MCP +.playwright-mcp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3cc12407 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/backend/package-lock.json b/backend/package-lock.json index 1e2dee12..458df2a9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" diff --git a/backend/package.json b/backend/package.json index e2795079..1f7a0ffc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 650cf525..c79ad906 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,8 @@ import crypto from 'crypto'; import composerize from 'composerize'; import si from 'systeminformation'; import http from 'http'; +import httpProxy from 'http-proxy'; +import { createProxyMiddleware } from 'http-proxy-middleware'; import { spawn, exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; @@ -19,19 +21,31 @@ import { HostTerminalService } from './services/HostTerminalService'; import { DatabaseService } from './services/DatabaseService'; import { NotificationService } from './services/NotificationService'; import { MonitorService } from './services/MonitorService'; +import { ImageUpdateService } from './services/ImageUpdateService'; +import { templateService } from './services/TemplateService'; +import { ErrorParser } from './utils/ErrorParser'; +import { NodeRegistry } from './services/NodeRegistry'; import YAML from 'yaml'; -import { promises as fsPromises } from 'fs'; +import fs, { promises as fsPromises } from 'fs'; const execAsync = promisify(exec); +// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls +// util._extend internally. The warning fires at runtime when createProxyServer() is +// first invoked (NOT at import time), so intercepting process.emitWarning here — +// before the proxy instances are created below — fully prevents it. +// http-proxy has no compatible update; this suppression is intentional and safe. +const _origEmitWarning = process.emitWarning.bind(process); +(process as any).emitWarning = (warning: any, ...args: any[]) => { + const code = typeof args[0] === 'object' ? args[0]?.code : args[1]; + if (code === 'DEP0060') return; + _origEmitWarning(warning, ...args); +}; + const app = express(); const PORT = 3000; -// FileSystemService for stack management -const fileSystemService = new FileSystemService(); - -// ComposeService for stack operations -const composeService = new ComposeService(); +// FileSystemService and ComposeService are instantiated per-request via .getInstance(nodeId) // Cookie settings const COOKIE_NAME = 'sencho_token'; @@ -57,16 +71,64 @@ app.use(cors({ app.use(express.json()); app.use(cookieParser()); -// Extend Express Request type for user -declare module 'express' { - interface Request { - user?: { username: string }; +// Node Context Middleware +const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) => { + const nodeIdHeader = req.headers['x-node-id'] as string; + const nodeIdQuery = req.query.nodeId as string; + if (nodeIdHeader) { + req.nodeId = parseInt(nodeIdHeader, 10); + } else if (nodeIdQuery) { + req.nodeId = parseInt(nodeIdQuery, 10); + } else { + req.nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + } + + // Intercept requests to deleted nodes to prevent downstream errors. + // /api/nodes is intentionally exempt: it must always be reachable so the + // frontend can re-sync after a node is deleted (otherwise a stale x-node-id + // in localStorage causes an unrecoverable 404 loop). + if ( + req.path.startsWith('/api/') && + !req.path.startsWith('/api/auth/') && + !req.path.startsWith('/api/nodes') + ) { + const node = DatabaseService.getInstance().getNode(req.nodeId); + if (!node) { + res.status(404).json({ error: `Node with id ${req.nodeId} not found or was deleted.` }); + return; + } + } + + next(); +}; + +app.use(nodeContextMiddleware); + +// Extend Express Request type for user and node +declare global { + namespace Express { + interface Request { + user?: { username: string }; + nodeId: number; + } } } +// WebSocket proxy server for forwarding remote node WS connections +const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true }); +wsProxyServer.on('error', (err, _req, socket: any) => { + console.error('[WS Proxy] Error:', err.message); + try { socket?.destroy(); } catch { } +}); + // Authentication Middleware +// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy) const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise => { - const token = req.cookies[COOKIE_NAME]; + const cookieToken = req.cookies[COOKIE_NAME]; + const bearerToken = req.headers.authorization?.startsWith('Bearer ') + ? req.headers.authorization.slice(7) + : null; + const token = cookieToken || bearerToken; if (!token) { res.status(401).json({ error: 'Authentication required' }); @@ -77,10 +139,12 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); - const decoded = jwt.verify(token, jwtSecret) as { username: string }; - req.user = { username: decoded.username }; + const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string }; + // Accept both user sessions and node proxy tokens + req.user = { username: decoded.username || 'node-proxy' }; next(); - } catch { + } catch (err) { + console.error('[Auth] Token validation failed:', (err as Error).message); res.status(401).json({ error: 'Invalid or expired token' }); return; } @@ -235,6 +299,23 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void = res.json({ authenticated: true, user: req.user }); }); +// Generate a long-lived node proxy token for Sencho-to-Sencho authentication +app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise => { + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) { + res.status(500).json({ error: 'No JWT secret configured on this instance.' }); + return; + } + // No expiry - this token is managed by the admin who pastes it into the main dashboard + const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret); + res.json({ token }); + } catch (error: any) { + res.status(500).json({ error: error.message || 'Failed to generate node token' }); + } +}); + // Apply authentication middleware to all /api/* routes except /api/auth/* app.use('/api', (req: Request, res: Response, next: NextFunction): void => { if (req.path.startsWith('/auth/')) { @@ -244,6 +325,85 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { authMiddleware(req, res, next); }); +// Remote Node HTTP Proxy - single global instance. +// Previously, createProxyMiddleware was called inside the request handler on every API +// call, spawning a new proxy instance (and http-proxy server) each time. This caused: +// - MaxListenersExceededWarning: repeated 'close' listeners added to [Server] +// - DEP0060: util._extend called on every http-proxy initialisation +// Fix: create ONE instance at startup; use the router option to resolve the +// target URL dynamically per request without constructing new listeners. +const remoteNodeProxy = createProxyMiddleware({ + target: 'http://localhost:0', // placeholder - overridden per-request by router + changeOrigin: true, + router: (req) => { + const node = NodeRegistry.getInstance().getNode(req.nodeId); + return node?.api_url?.replace(/\/$/, ''); + }, + // When mounted at app.use('/api/', ...), Express strips the '/api/' prefix from + // req.url before the middleware sees it. Re-add it so the remote Sencho instance + // receives the full path (e.g. '/stats' becomes '/api/stats'). + pathRewrite: (path) => '/api' + path, + on: { + proxyReq: (proxyReq, req) => { + const node = NodeRegistry.getInstance().getNode(req.nodeId); + // Strip headers that must not reach the remote instance: + // - x-node-id: remote Sencho treats all requests as local + // - cookie: the browser's sencho_token is signed with THIS instance's JWT secret; + // the remote would try to verify it with its own secret and return 401. + // Authentication is handled exclusively via the Bearer token below. + proxyReq.removeHeader('x-node-id'); + proxyReq.removeHeader('cookie'); + if (node?.api_token) { + proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`); + } + // Strip the ?nodeId= query param so the remote's nodeContextMiddleware + // doesn't reject the request with 404 ("Node X not found") — the remote + // has no record of the gateway's node IDs and should treat the request + // as local. This affects endpoints like EventSource /api/containers/:id/logs + // that pass nodeId as a query param rather than the x-node-id header. + if (proxyReq.path.includes('nodeId=')) { + const [pathname, qs] = proxyReq.path.split('?'); + const params = new URLSearchParams(qs || ''); + params.delete('nodeId'); + const newQs = params.toString(); + proxyReq.path = pathname + (newQs ? `?${newQs}` : ''); + } + }, + error: (err, _req, proxyRes) => { + console.error('[Proxy] Remote node error:', (err as Error).message); + if (!(proxyRes as Response).headersSent) { + (proxyRes as Response).status(502).json({ + error: 'Remote node is unreachable. Check the API URL and ensure Sencho is running on that host.' + }); + } + }, + }, +}); + +// Intercepts all /api/ requests for remote Distributed API nodes and forwards them +// to the target Sencho instance. Node management and auth routes always execute locally. +app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { + if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes')) { + next(); + return; + } + + const node = NodeRegistry.getInstance().getNode(req.nodeId); + if (!node || node.type !== 'remote') { + next(); + return; + } + + if (!node.api_url || !node.api_token) { + res.status(503).json({ + error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.` + }); + return; + } + + remoteNodeProxy(req, res, next); +}); + // Create HTTP server for WebSocket upgrade handling const server = http.createServer(app); @@ -260,7 +420,11 @@ server.on('upgrade', async (req, socket, head) => { cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v) ); - const token = cookies[COOKIE_NAME]; + // Accept either cookie auth (browser sessions) or Bearer token auth (node-to-node WS proxy) + const cookieToken = cookies[COOKIE_NAME]; + const authHeader = req.headers['authorization'] as string | undefined; + const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + const token = cookieToken || bearerToken; if (!token) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); @@ -274,10 +438,36 @@ server.on('upgrade', async (req, socket, head) => { if (!jwtSecret) throw new Error('No JWT secret'); jwt.verify(token, jwtSecret); - // Check if this is a stack logs WebSocket request const url = req.url || ''; - const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/); - const hostConsoleMatch = url.match(/^\/api\/system\/host-console/); + const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); + const pathname = parsedUrl.pathname; + + // Resolve node context from query param + const nodeIdParam = parsedUrl.searchParams.get('nodeId'); + const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); + const node = NodeRegistry.getInstance().getNode(nodeId); + + // Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance + if (node && node.type === 'remote' && node.api_url && node.api_token) { + const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); + req.headers['authorization'] = `Bearer ${node.api_token}`; + delete req.headers['x-node-id']; + // Strip the browser's session cookie — it is signed by this instance's JWT secret and + // would fail verification on the remote. Auth is handled exclusively via the Bearer token. + delete req.headers['cookie']; + // Strip nodeId from the forwarded URL so the remote treats the request as a local one. + // The remote has no record of the gateway's nodeId, so leaving it would cause unnecessary + // fallback logic. Removing it lets the remote default cleanly to its own local node. + const fwdUrl = new URL(req.url!, `http://${req.headers.host || 'localhost'}`); + fwdUrl.searchParams.delete('nodeId'); + req.url = fwdUrl.pathname + (fwdUrl.searchParams.toString() ? `?${fwdUrl.searchParams.toString()}` : ''); + wsProxyServer.ws(req, socket, head, { target: wsTarget }); + return; + } + + // Local node handling + const logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/); + const hostConsoleMatch = pathname.match(/^\/api\/system\/host-console/); if (logsMatch) { // Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs @@ -285,7 +475,7 @@ server.on('upgrade', async (req, socket, head) => { logsWss.handleUpgrade(req, socket, head, (ws) => { const stackName = decodeURIComponent(logsMatch[1]); try { - composeService.streamLogs(stackName, ws); + ComposeService.getInstance(nodeId).streamLogs(stackName, ws); } catch (error) { console.error('Failed to stream logs:', error); if (ws.readyState === WebSocket.OPEN) { @@ -296,15 +486,15 @@ server.on('upgrade', async (req, socket, head) => { } else if (hostConsoleMatch) { const hostConsoleWss = new WebSocket.Server({ noServer: true }); hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { - let targetDirectory = fileSystemService.getBaseDir(); + let targetDirectory = ''; try { - const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`); - const stackParam = reqUrl.searchParams.get('stack'); + targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir(); + const stackParam = parsedUrl.searchParams.get('stack'); if (stackParam) { targetDirectory = path.join(targetDirectory, stackParam); } } catch (e) { - // ignore parsing error, fallback to base dir + targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir(); } try { HostTerminalService.spawnTerminal(ws, targetDirectory); @@ -344,16 +534,22 @@ wss.on('connection', (ws) => { if (data.action === 'connectTerminal') { terminalWs = ws; } else if (data.action === 'streamStats') { - const dockerController = DockerController.getInstance(); - dockerController.streamStats(data.containerId, ws); + const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); + // When a WS is proxied from a gateway to this remote instance, the nodeId in the + // message belongs to the gateway's DB and won't resolve locally. Fall back to local. + let nodeId = requestedId; + try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } + DockerController.getInstance(nodeId).streamStats(data.containerId, ws); } else if (data.action === 'execContainer') { // Handle container exec for bash access // Input, resize, and cleanup are handled inside execContainer's closure - const dockerController = DockerController.getInstance(); - dockerController.execContainer(data.containerId, ws); + const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); + let nodeId = requestedId; + try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } + DockerController.getInstance(nodeId).execContainer(data.containerId, ws); } } catch (error) { - // Malformed JSON — ignore silently + // Malformed JSON - ignore silently } }); }); @@ -362,7 +558,7 @@ wss.on('connection', (ws) => { app.get('/api/containers', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getRunningContainers(); res.json(containers); } catch (error) { @@ -374,7 +570,7 @@ app.get('/api/containers', async (req: Request, res: Response) => { app.get('/api/stacks', async (req: Request, res: Response) => { try { - const stacks = await fileSystemService.getStacks(); + const stacks = await FileSystemService.getInstance(req.nodeId).getStacks(); res.json(stacks); } catch (error) { res.status(500).json({ error: 'Failed to fetch stacks' }); @@ -384,7 +580,7 @@ app.get('/api/stacks', async (req: Request, res: Response) => { app.get('/api/stacks/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const content = await fileSystemService.getStackContent(stackName); + const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName); res.send(content); } catch (error) { res.status(500).json({ error: 'Failed to read stack' }); @@ -403,7 +599,7 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { console.error('Content is not a string:', content); return res.status(400).json({ error: 'Content must be a string' }); } - await fileSystemService.saveStackContent(stackName, content); + await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); console.log('Stack saved successfully:', stackName); res.json({ message: 'Stack saved successfully' }); } catch (error) { @@ -413,8 +609,9 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { }); // Helper: resolve all env file paths dynamically from compose.yaml's env_file field -async function resolveAllEnvFilePaths(stackName: string): Promise { - const stackDir = path.join(fileSystemService.getBaseDir(), stackName); +async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise { + const fsService = FileSystemService.getInstance(nodeId); + const stackDir = path.join(fsService.getBaseDir(), stackName); const defaultEnvPath = path.join(stackDir, '.env'); try { @@ -424,7 +621,7 @@ async function resolveAllEnvFilePaths(stackName: string): Promise { for (const file of composeFiles) { try { - composeContent = await fsPromises.readFile(path.join(stackDir, file), 'utf-8'); + composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8'); break; } catch { // Try next file @@ -476,7 +673,7 @@ async function resolveAllEnvFilePaths(stackName: string): Promise { app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const envPaths = await resolveAllEnvFilePaths(stackName); + const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); res.json({ envFiles: envPaths }); } catch (error) { res.status(500).json({ error: 'Failed to resolve env files' }); @@ -487,7 +684,7 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; const requestedFile = req.query.file as string | undefined; - const envPaths = await resolveAllEnvFilePaths(stackName); + const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); let envPath = envPaths[0]; // Fallback to the first @@ -500,13 +697,15 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { } } + const fsService = FileSystemService.getInstance(req.nodeId); + try { - await fsPromises.access(envPath); + await fsService.access(envPath); } catch { return res.status(404).json({ error: 'Env file not found' }); } - const content = await fsPromises.readFile(envPath, 'utf-8'); + const content = await fsService.readFile(envPath, 'utf-8'); res.send(content); } catch (error) { console.error('Failed to read env file:', error); @@ -526,7 +725,7 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { } const requestedFile = req.query.file as string | undefined; - const envPaths = await resolveAllEnvFilePaths(stackName); + const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName); let envPath = envPaths[0]; // Fallback @@ -538,7 +737,8 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { } } - await fsPromises.writeFile(envPath, content, 'utf-8'); + const fsService = FileSystemService.getInstance(req.nodeId); + await fsService.writeFile(envPath, content, 'utf-8'); res.json({ message: 'Env file saved successfully' }); } catch (error) { console.error('Failed to save env file:', error); @@ -555,7 +755,7 @@ app.post('/api/stacks', async (req: Request, res: Response) => { if (!/^[a-zA-Z0-9-]+$/.test(stackName)) { return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters and hyphens' }); } - await fileSystemService.createStack(stackName); + await FileSystemService.getInstance(req.nodeId).createStack(stackName); res.json({ message: 'Stack created successfully', name: stackName }); } catch (error: any) { if (error.message && error.message.includes('already exists')) { @@ -566,31 +766,29 @@ app.post('/api/stacks', async (req: Request, res: Response) => { } }); -app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => { +app.delete('/api/stacks/:name', async (req: Request, res: Response) => { + const stackName = req.params.name as string; try { - const stackName = req.params.stackName as string; - - // Tear down the stack first to avoid ghost containers + // Stage 1: Tell Docker to clean up ghost networks/containers try { - console.log(`Tearing down stack: ${stackName}`); - // Send the down command synchronously before deleting the files - await composeService.runCommand(stackName, 'down', terminalWs || undefined); - } catch (downError) { - console.warn(`Failed to tear down stack ${stackName}, proceeding with file deletion.`, downError); + await ComposeService.getInstance(req.nodeId).downStack(stackName); + } catch (downErr) { + console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`); } - await fileSystemService.deleteStack(stackName); - res.json({ message: 'Stack deleted successfully' }); - } catch (error) { - console.error('Failed to delete stack:', error); - res.status(500).json({ error: 'Failed to delete stack' }); + // Stage 2: Obliterate the files + await FileSystemService.getInstance(req.nodeId).deleteStack(stackName); + + res.json({ success: true }); + } catch (error: any) { + res.status(500).json({ error: error.message || 'Failed to delete stack' }); } }); app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); res.json(containers); } catch (error) { @@ -598,10 +796,21 @@ app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) } }); +app.get('/api/containers/:id/logs', async (req: Request, res: Response) => { + try { + const id = req.params.id as string; + const dockerController = DockerController.getInstance(req.nodeId); + // Pass both req and res so we can listen for the client disconnect + await dockerController.streamContainerLogs(id, req, res); + } catch (error) { + res.status(500).json({ error: 'Failed to initialize log stream' }); + } +}); + app.post('/api/containers/:id/start', async (req: Request, res: Response) => { try { const id = req.params.id as string; - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); await dockerController.startContainer(id); res.json({ message: 'Container started' }); } catch (error) { @@ -612,7 +821,7 @@ app.post('/api/containers/:id/start', async (req: Request, res: Response) => { app.post('/api/containers/:id/stop', async (req: Request, res: Response) => { try { const id = req.params.id as string; - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); await dockerController.stopContainer(id); res.json({ message: 'Container stopped' }); } catch (error) { @@ -623,7 +832,7 @@ app.post('/api/containers/:id/stop', async (req: Request, res: Response) => { app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { try { const id = req.params.id as string; - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); await dockerController.restartContainer(id); res.json({ message: 'Container restarted' }); } catch (error) { @@ -635,7 +844,7 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - await composeService.deployStack(stackName, terminalWs || undefined); + await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined); res.json({ message: 'Deployed successfully' }); } catch (error: any) { console.error('Failed to deploy stack:', error); @@ -646,7 +855,7 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - await composeService.runCommand(stackName, 'down', terminalWs || undefined); + await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined); res.json({ status: 'Command started' }); } catch (error) { res.status(500).json({ error: 'Failed to start command' }); @@ -656,7 +865,7 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); if (!containers || containers.length === 0) { @@ -674,7 +883,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) = app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); if (!containers || containers.length === 0) { @@ -692,7 +901,7 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getContainersByStack(stackName); if (!containers || containers.length === 0) { @@ -712,7 +921,7 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => try { const stackName = req.params.stackName as string; // Await update completion - await composeService.updateStack(stackName, terminalWs || undefined); + await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined); res.json({ status: 'Update completed' }); } catch (error) { res.status(500).json({ error: 'Failed to update' }); @@ -737,7 +946,7 @@ app.post('/api/convert', async (req: Request, res: Response) => { // Get all containers stats for dashboard app.get('/api/stats', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const containers = await dockerController.getRunningContainers(); const allContainers = await dockerController.getAllContainers(); @@ -751,21 +960,221 @@ app.get('/api/stats', async (req: Request, res: Response) => { } }); +app.get('/api/metrics/historical', async (req: Request, res: Response) => { + try { + const metrics = DatabaseService.getInstance().getContainerMetrics(24); + res.json(metrics); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch metrics' }); + } +}); + +app.get('/api/logs/global', async (req: Request, res: Response) => { + try { + const dockerController = DockerController.getInstance(req.nodeId); + const containers = await dockerController.getRunningContainers(); + const allLogs: any[] = []; + + await Promise.all(containers.map(async (c) => { + const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; + let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); + + // Standardize naming: Strip stack name prefix if it exists + let containerName = rawName; + if (rawName.startsWith(`${stackName}-`)) { + containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, ''); + } else if (rawName.startsWith(`${stackName}_`)) { + containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, ''); + } + + try { + const container = dockerController.getDocker().getContainer(c.Id); + const inspect = await container.inspect(); + const isTty = inspect.Config.Tty; + const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true }) as Buffer; + + const parseAndPushLog = (line: string, source: string) => { + if (!line.trim()) return; + const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); + let cleanMessage = line; + let timestampMs = Date.now(); + + if (timeMatch) { + timestampMs = new Date(timeMatch[1]).getTime(); + cleanMessage = timeMatch[2]; + } + + // Default to INFO, or ERROR if coming from STDERR. + let level = source === 'STDERR' ? 'ERROR' : 'INFO'; + + // 1. Explicitly check for INFO/DEBUG indicators (Overrides STDERR defaults) + if (/level=["']?(info|debug|trace)["']?/i.test(cleanMessage) || + /\[\s*(info|inf|debug|dbg|trace)\s*\]/i.test(cleanMessage) || + /(?:\s|^)(info|inf|debug|trace)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { + level = 'INFO'; + } + // 2. Check for WARN indicators + else if (/level=["']?(warn|warning)["']?/i.test(cleanMessage) || + /\[\s*(warn|warning)\s*\]/i.test(cleanMessage) || + /(?:\s|^)(warn|warning)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { + level = 'WARN'; + } + // 3. Check for ERROR indicators + else if (/level=["']?(error|err|fatal|crit|critical|panic)["']?/i.test(cleanMessage) || + /\[\s*(error|err|fatal|crit|critical|panic)\s*\]/i.test(cleanMessage) || + /(?:\s|^)(error|err|fatal|crit|critical|panic)(?:\s|:|\(|\[|$)/i.test(cleanMessage) || + /Exception:/i.test(cleanMessage)) { + level = 'ERROR'; + } + + allLogs.push({ stackName, containerName, source, level, message: cleanMessage, timestampMs }); + }; + + if (isTty) { + // No multiplex headers. Just split by newline. + const payload = logsBuffer.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); + payload.split('\n').forEach(line => parseAndPushLog(line, 'STDOUT')); + } else { + // Parse 8-byte Docker multiplex header + let offset = 0; + while (offset < logsBuffer.length) { + const streamType = logsBuffer[offset]; + const length = logsBuffer.readUInt32BE(offset + 4); + offset += 8; + if (offset + length > logsBuffer.length) break; + + const payload = logsBuffer.slice(offset, offset + length).toString('utf-8'); + offset += length; + payload.split('\n').forEach(line => parseAndPushLog(line, streamType === 2 ? 'STDERR' : 'STDOUT')); + } + } + } catch (err) { + console.warn(`[GlobalLogs] Failed to fetch/parse logs for container ${containerName} (${c.Id.substring(0, 12)}):`, (err as Error).message); + } + })); + + // Sort globally by timestamp ascending (newest bottom) and limit to 2000 lines + allLogs.sort((a, b) => a.timestampMs - b.timestampMs); + res.json(allLogs.slice(-2000)); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch global logs' }); + } +}); + +app.get('/api/logs/global/stream', async (req: Request, res: Response) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const dockerController = DockerController.getInstance(req.nodeId); + const streams: NodeJS.ReadableStream[] = []; + + try { + const containers = await dockerController.getRunningContainers(); + + await Promise.all(containers.map(async (c) => { + const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; + let rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); + let containerName = rawName; + if (rawName.startsWith(`${stackName}-`)) containerName = rawName.replace(`${stackName}-`, '').replace(/-1$/, ''); + else if (rawName.startsWith(`${stackName}_`)) containerName = rawName.replace(`${stackName}_`, '').replace(/_1$/, ''); + + try { + const container = dockerController.getDocker().getContainer(c.Id); + const inspect = await container.inspect(); + const isTty = inspect.Config.Tty; + + // Dev mode gets a larger tail + const stream = await container.logs({ follow: true, stdout: true, stderr: true, tail: 500, timestamps: true }); + streams.push(stream); + + const processLine = (line: string, source: string) => { + if (!line.trim()) return; + const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); + let cleanMessage = line; + let timestampMs = Date.now(); + + if (timeMatch) { + timestampMs = new Date(timeMatch[1]).getTime(); + cleanMessage = timeMatch[2]; + } + + // Default to INFO, or ERROR if coming from STDERR. + let level = source === 'STDERR' ? 'ERROR' : 'INFO'; + + // 1. Explicitly check for INFO/DEBUG indicators (Overrides STDERR defaults) + if (/level=["']?(info|debug|trace)["']?/i.test(cleanMessage) || + /\[\s*(info|inf|debug|dbg|trace)\s*\]/i.test(cleanMessage) || + /(?:\s|^)(info|inf|debug|trace)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { + level = 'INFO'; + } + // 2. Check for WARN indicators + else if (/level=["']?(warn|warning)["']?/i.test(cleanMessage) || + /\[\s*(warn|warning)\s*\]/i.test(cleanMessage) || + /(?:\s|^)(warn|warning)(?:\s|:|\(|\[|$)/i.test(cleanMessage)) { + level = 'WARN'; + } + // 3. Check for ERROR indicators + else if (/level=["']?(error|err|fatal|crit|critical|panic)["']?/i.test(cleanMessage) || + /\[\s*(error|err|fatal|crit|critical|panic)\s*\]/i.test(cleanMessage) || + /(?:\s|^)(error|err|fatal|crit|critical|panic)(?:\s|:|\(|\[|$)/i.test(cleanMessage) || + /Exception:/i.test(cleanMessage)) { + level = 'ERROR'; + } + + res.write(`data: ${JSON.stringify({ stackName, containerName, source, level, message: cleanMessage, timestampMs })}\n\n`); + }; + + stream.on('data', (chunk: Buffer) => { + if (isTty) { + const payload = chunk.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); + payload.split('\n').forEach(line => processLine(line, 'STDOUT')); + } else { + let offset = 0; + while (offset < chunk.length) { + if (offset + 8 > chunk.length) break; + const streamType = chunk[offset]; + const length = chunk.readUInt32BE(offset + 4); + offset += 8; + if (offset + length > chunk.length) break; + + const payload = chunk.slice(offset, offset + length).toString('utf-8'); + offset += length; + payload.split('\n').forEach(line => processLine(line, streamType === 2 ? 'STDERR' : 'STDOUT')); + } + } + }); + } catch (err) { /* ignore */ } + })); + + // Cleanup when client closes the tab or switches views + req.on('close', () => { + streams.forEach(s => { + try { (s as any).destroy(); } catch (e) { } + }); + }); + + } catch (error) { + res.write(`data: ${JSON.stringify({ level: 'ERROR', message: '[Sencho] Failed to attach global log stream.', timestampMs: Date.now(), stackName: 'system', containerName: 'backend', source: 'STDERR' })}\n\n`); + res.end(); + } +}); + // Get host system stats app.get('/api/system/stats', async (req: Request, res: Response) => { try { + const rxSec = Math.max(0, globalDockerNetwork.rxSec); + const txSec = Math.max(0, globalDockerNetwork.txSec); + + // Remote node requests are intercepted and proxied by remoteNodeProxy before reaching here. + // This handler only runs for local nodes. const [currentLoad, mem, fsSize] = await Promise.all([ si.currentLoad(), si.mem(), si.fsSize() ]); - let rxSec = Math.max(0, globalDockerNetwork.rxSec); - let txSec = Math.max(0, globalDockerNetwork.txSec); - let rxBytes = 0; - let txBytes = 0; - - // Find the main mount (usually the largest or root mount) const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; res.json({ @@ -787,12 +1196,7 @@ app.get('/api/system/stats', async (req: Request, res: Response) => { free: mainDisk.available, usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', } : null, - network: { - rxBytes, - txBytes, - rxSec, - txSec - } + network: { rxBytes: 0, txBytes: 0, rxSec, txSec }, }); } catch (error) { console.error('Failed to fetch system stats:', error); @@ -923,8 +1327,8 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => { app.get('/api/system/orphans', async (req: Request, res: Response) => { try { - const knownStacks = await fileSystemService.getStacks(); - const dockerController = DockerController.getInstance(); + const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const dockerController = DockerController.getInstance(req.nodeId); const orphans = await dockerController.getOrphanContainers(knownStacks); res.json(orphans); } catch (error) { @@ -939,7 +1343,7 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { if (!Array.isArray(containerIds)) { return res.status(400).json({ error: 'containerIds must be an array' }); } - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const results = await dockerController.removeContainers(containerIds); res.json({ results }); } catch (error) { @@ -955,7 +1359,7 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Invalid prune target' }); } - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const result = await dockerController.pruneSystem(target); res.json({ message: 'Prune completed', ...result }); @@ -967,7 +1371,7 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => { app.get('/api/system/docker-df', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const df = await dockerController.getDiskUsage(); res.json(df); } catch (error) { @@ -978,7 +1382,7 @@ app.get('/api/system/docker-df', async (req: Request, res: Response) => { app.get('/api/system/images', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const images = await dockerController.getImages(); res.json(images); } catch (error) { @@ -989,7 +1393,7 @@ app.get('/api/system/images', async (req: Request, res: Response) => { app.get('/api/system/volumes', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const volumes = await dockerController.getVolumes(); res.json(volumes); } catch (error) { @@ -1000,7 +1404,7 @@ app.get('/api/system/volumes', async (req: Request, res: Response) => { app.get('/api/system/networks', async (req: Request, res: Response) => { try { - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); const networks = await dockerController.getNetworks(); res.json(networks); } catch (error) { @@ -1013,7 +1417,7 @@ app.post('/api/system/images/delete', async (req: Request, res: Response) => { try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeImage(id); res.json({ success: true, message: 'Image deleted' }); } catch (error: any) { @@ -1026,7 +1430,7 @@ app.post('/api/system/volumes/delete', async (req: Request, res: Response) => { try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeVolume(id); res.json({ success: true, message: 'Volume deleted' }); } catch (error: any) { @@ -1039,7 +1443,7 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => { try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); - const dockerController = DockerController.getInstance(); + const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeNetwork(id); res.json({ success: true, message: 'Network deleted' }); } catch (error: any) { @@ -1048,6 +1452,218 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => { } }); +// --- App Templates Routes --- + +app.get('/api/templates', async (req: Request, res: Response) => { + try { + const templates = await templateService.getTemplates(); + res.json(templates); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch templates' }); + } +}); + +app.post('/api/templates/deploy', async (req: Request, res: Response) => { + try { + const { stackName, template, envVars } = req.body; + + if (!stackName || !template) { + return res.status(400).json({ error: 'stackName and template are required' }); + } + + const stackPath = path.join(FileSystemService.getInstance(req.nodeId).getBaseDir(), stackName); + if (fs.existsSync(stackPath)) { + return res.status(409).json({ + error: `A stack directory named '${stackName}' already exists. Please choose a different Stack Name.`, + rolledBack: false + }); + } + + // 1. Create stack directory + await FileSystemService.getInstance(req.nodeId).createStack(stackName); + + // 2. Generate compose YAML and save + const composeYaml = templateService.generateComposeFromTemplate(template); + await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, composeYaml); + + // 3. Generate env string and save to default .env + if (envVars) { + const envString = templateService.generateEnvString(envVars); + const stackDir = path.join(FileSystemService.getInstance(req.nodeId).getBaseDir(), stackName); + const defaultEnvPath = path.join(stackDir, '.env'); + await fsPromises.writeFile(defaultEnvPath, envString, 'utf-8'); + } + + // 4. Deploy the stack with atomic rollback + try { + await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined); + res.json({ success: true, message: 'Template deployed successfully' }); + } catch (deployError: any) { + const rawError = deployError.message || String(deployError); + const parsed = ErrorParser.parse(rawError); + + const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true; + + if (shouldRollback) { + try { + // Stage 1: Tell Docker to clean up ghost networks/containers + await ComposeService.getInstance(req.nodeId).downStack(stackName); + } catch (downErr) { + console.error("Rollback Stage 1 (Docker down) failed:", downErr); + } + + try { + // Stage 2: Obliterate the files + await FileSystemService.getInstance(req.nodeId).deleteStack(stackName); + } catch (fsErr) { + console.error("Rollback Stage 2 (File deletion) failed:", fsErr); + } + } + + res.status(500).json({ + error: parsed.message, + rolledBack: shouldRollback, + ruleId: parsed.rule?.id || 'UNKNOWN' + }); + } + } catch (error: any) { + console.error('Failed to deploy template:', error); + res.status(500).json({ error: error.message || 'Failed to deploy template' }); + } +}); + +// ========================= +// Image Update Checker API +// ========================= + +app.get('/api/image-updates', authMiddleware, (_req: Request, res: Response) => { + try { + const updates = DatabaseService.getInstance().getStackUpdateStatus(); + res.json(updates); + } catch (error) { + console.error('Failed to fetch image update status:', error); + res.status(500).json({ error: 'Failed to fetch image update status' }); + } +}); + +app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Response) => { + try { + const triggered = ImageUpdateService.getInstance().triggerManualRefresh(); + if (!triggered) { + res.status(429).json({ error: 'Rate limited. Please wait at least 10 minutes between manual refreshes.' }); + return; + } + res.json({ success: true, message: 'Image update check started in background.' }); + } catch (error) { + console.error('Failed to trigger image update refresh:', error); + res.status(500).json({ error: 'Failed to trigger refresh' }); + } +}); + +// ========================= +// Node Management API +// ========================= + +// List all nodes +app.get('/api/nodes', async (req: Request, res: Response) => { + try { + const nodes = DatabaseService.getInstance().getNodes(); + res.json(nodes); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch nodes' }); + } +}); + +// Get a specific node +app.get('/api/nodes/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const node = DatabaseService.getInstance().getNode(id); + if (!node) { + return res.status(404).json({ error: 'Node not found' }); + } + res.json(node); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch node' }); + } +}); + +// Create a new node +app.post('/api/nodes', async (req: Request, res: Response) => { + try { + const { name, type, compose_dir, is_default, api_url, api_token } = req.body; + + if (!name || typeof name !== 'string') { + return res.status(400).json({ error: 'Node name is required' }); + } + if (!type || !['local', 'remote'].includes(type)) { + return res.status(400).json({ error: 'Node type must be "local" or "remote"' }); + } + if (type === 'remote' && (!api_url || typeof api_url !== 'string')) { + return res.status(400).json({ error: 'API URL is required for remote nodes' }); + } + + const id = DatabaseService.getInstance().addNode({ + name, + type, + compose_dir: compose_dir || '/app/compose', + is_default: is_default || false, + api_url: api_url || '', + api_token: api_token || '', + }); + + res.json({ success: true, id }); + } catch (error: any) { + if (error.message?.includes('UNIQUE constraint')) { + return res.status(409).json({ error: 'A node with that name already exists' }); + } + console.error('Failed to create node:', error); + res.status(500).json({ error: error.message || 'Failed to create node' }); + } +}); + +// Update a node +app.put('/api/nodes/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const updates = req.body; + + DatabaseService.getInstance().updateNode(id, updates); + + // Evict cached Docker connection so it reconnects with new config + NodeRegistry.getInstance().evictConnection(id); + + res.json({ success: true }); + } catch (error: any) { + console.error('Failed to update node:', error); + res.status(500).json({ error: error.message || 'Failed to update node' }); + } +}); + +// Delete a node +app.delete('/api/nodes/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + DatabaseService.getInstance().deleteNode(id); + NodeRegistry.getInstance().evictConnection(id); + res.json({ success: true }); + } catch (error: any) { + console.error('Failed to delete node:', error); + res.status(500).json({ error: error.message || 'Failed to delete node' }); + } +}); + +// Test connection to a node +app.post('/api/nodes/:id/test', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const result = await NodeRegistry.getInstance().testConnection(id); + res.json(result); + } catch (error: any) { + res.status(500).json({ success: false, error: error.message || 'Connection test failed' }); + } +}); + // Serve static files in production (for Docker deployment) if (process.env.NODE_ENV === 'production') { @@ -1076,7 +1692,8 @@ async function startServer() { try { // Run migration before starting server console.log('Running stack migration check...'); - await fileSystemService.migrateFlatToDirectory(); + const defaultFsService = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()); + await defaultFsService.migrateFlatToDirectory(); console.log('Migration check completed'); } catch (error) { console.error('Migration failed:', error); @@ -1086,6 +1703,9 @@ async function startServer() { // Start Background Watchdog MonitorService.getInstance().start(); + // Start Background Image Update Checker + ImageUpdateService.getInstance().start(); + server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 615c8d8f..7cbb2855 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -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 { - 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 { - 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 { 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 { + const stackDir = path.join(this.baseDir, stackName); + await this.execute('docker', ['compose', action], stackDir, ws); + } + + async deployStack(stackName: string, ws?: WebSocket): Promise { + 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[] = []; + let localProcesses: ReturnType[] = []; 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 { 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((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((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 { + 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}`); + } } } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 90fc1d8a..7125931f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): 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): 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>): 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 { + const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[]; + const result: Record = {}; + for (const row of rows) { + result[row.stack_name] = row.has_update === 1; + } + return result; + } } diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 6164d646..6d9467e6 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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(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(data); } public async getVolumes() { const data = await this.docker.listVolumes(); - return data.Volumes || []; + const validated = this.validateApiData(data); + return validated.Volumes || []; } public async getNetworks() { - return await this.docker.listNetworks(); + const data = await this.docker.listNetworks(); + return this.validateApiData(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(containers); } public async getAllContainers() { const containers = await this.docker.listContainers({ all: true }); - return containers; + return this.validateApiData(containers); } public async getContainersByStack(stackName: string) { @@ -281,6 +297,52 @@ class DockerController { } } + public async streamContainerLogs(containerId: string, req: any, res: any): Promise { + 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( diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 9be14689..91e06bca 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -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 { 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 { 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 { 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 { 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 { - 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 { - 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 { + return fsPromises.readFile(filePath, encoding); + } + + async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise { + return fsPromises.writeFile(filePath, content, encoding); + } + + async access(filePath: string): Promise { + return fsPromises.access(filePath); + } + async getEnvContent(stackName: string): Promise { 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 { 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 { - // 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 { + public async deleteStack(stackName: string): Promise { 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 { 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 } } -} \ No newline at end of file +} diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts new file mode 100644 index 00000000..06df6d33 --- /dev/null +++ b/backend/src/services/ImageUpdateService.ts @@ -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; + body: string; +} + +function httpGet(url: string, headers: Record = {}, timeoutMs = 10000): Promise { + 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, + 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 { + 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 { + try { + const token = await getAuthToken(registry, repo); + const headers: Record = { 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>(); + + 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(); + for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img); + + const imageUpdateMap = new Map(); + + 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index d076267c..3881a2c5 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -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) { diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts new file mode 100644 index 00000000..940f3714 --- /dev/null +++ b/backend/src/services/NodeRegistry.ts @@ -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 = 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'; + } +} diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts new file mode 100644 index 00000000..fd23c86c --- /dev/null +++ b/backend/src/services/TemplateService.ts @@ -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 { + 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(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 { + return Object.entries(envVars) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + } +} + +export const templateService = new TemplateService(); diff --git a/backend/src/utils/ErrorParser.ts b/backend/src/utils/ErrorParser.ts new file mode 100644 index 00000000..5be2bd85 --- /dev/null +++ b/backend/src/utils/ErrorParser.ts @@ -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 }; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd8e36aa..1b755fe0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0ce09878..f27a6570 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e944ede..d1c03e58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; + return ( + + + + ); } import { Toaster } from 'sonner'; diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx new file mode 100644 index 00000000..0fc6e1b9 --- /dev/null +++ b/frontend/src/components/AppStoreView.tsx @@ -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([]); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTemplate, setSelectedTemplate] = useState