mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 12:18:59 +00:00
Merge pull request #43 from AnsoCode/fix/remote-ws-stats-exec-openapp
fix(remote): strip cookie & nodeId from WS/HTTP proxy to remote nodes
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: saelix/sencho
|
||||
tags: |
|
||||
# If pushing to develop, tag as 'dev'
|
||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/develop' }}
|
||||
# If a release is published, tag as 'latest' and the specific version
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=saelix/sencho:buildcache
|
||||
cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max
|
||||
+8
-1
@@ -25,4 +25,11 @@ Thumbs.db
|
||||
|
||||
#PRD
|
||||
Product Requirements Document
|
||||
plans/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
plans/
|
||||
|
||||
# Playwright MCP
|
||||
.playwright-mcp/
|
||||
|
||||
+120
@@ -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.
|
||||
Generated
+222
-7
@@ -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"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/http-proxy-middleware": "^0.19.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/yaml": "^1.9.6",
|
||||
@@ -28,7 +29,9 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/http-proxy": "^1.17.17",
|
||||
"@types/ws": "^8.18.1",
|
||||
"axios": "^1.13.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"composerize": "^1.7.5",
|
||||
@@ -36,10 +39,12 @@
|
||||
"cors": "^2.8.6",
|
||||
"dockerode": "^4.0.9",
|
||||
"express": "^5.2.1",
|
||||
"http-proxy": "^1.18.1",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-pty": "^1.1.0",
|
||||
"systeminformation": "^5.31.1",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+711
-91
File diff suppressed because it is too large
Load Diff
@@ -3,95 +3,37 @@ import path from 'path';
|
||||
import WebSocket from 'ws';
|
||||
import DockerController from './DockerController';
|
||||
import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* ComposeService - local docker compose CLI execution.
|
||||
*
|
||||
* In the Distributed API model, remote node compose operations are handled
|
||||
* by the remote Sencho instance. This service only executes commands locally.
|
||||
*/
|
||||
export class ComposeService {
|
||||
private baseDir: string;
|
||||
private nodeId: number;
|
||||
|
||||
constructor() {
|
||||
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
|
||||
constructor(nodeId?: number) {
|
||||
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||
this.baseDir = NodeRegistry.getInstance().getComposeDir(this.nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run docker compose up or down command
|
||||
* CRITICAL: cwd is set to the stack directory so relative paths in compose files
|
||||
* resolve correctly inside the isolated stack folder
|
||||
*/
|
||||
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
|
||||
// Run docker compose from within the stack directory
|
||||
// This ensures relative paths (e.g., ./data:/config) resolve correctly
|
||||
const args = ['compose', action];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('docker', args, {
|
||||
cwd: stackDir, // CRITICAL: Set working directory to stack folder
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
});
|
||||
|
||||
if (ws) {
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
ws.send(data.toString());
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
ws.send(data.toString());
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
ws.send(`Command exited with code ${code}\n`);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Command exited with code ${code}`));
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
console.error(`Docker Compose Error for ${stackName}:`, error.message);
|
||||
ws.send(`Error: ${error.message}\n`);
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
// Without WS, just wait for resolution
|
||||
let stderr = '';
|
||||
child.stdout.on('data', () => { }); // Drain stdout to prevent pipe buffer from blocking the process
|
||||
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Command failed with code ${code}. Stderr: ${stderr}`));
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
console.error(`Docker Compose Error for ${stackName}:`, error.message);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
public static getInstance(nodeId?: number): ComposeService {
|
||||
return new ComposeService(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy stack: executes up -d --remove-orphans and awaits completion.
|
||||
*/
|
||||
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
|
||||
try {
|
||||
const dockerController = DockerController.getInstance();
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
if (ws) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map(c => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
private execute(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
ws?: WebSocket,
|
||||
throwOnError = true
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = ['compose', 'up', '-d', '--remove-orphans'];
|
||||
const child = spawn('docker', args, {
|
||||
cwd: stackDir,
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
@@ -100,57 +42,93 @@ export class ComposeService {
|
||||
|
||||
let errorLog = '';
|
||||
|
||||
if (ws) {
|
||||
child.stdout.on('data', (data: Buffer) => ws.send(data.toString()));
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
errorLog += text;
|
||||
const onData = (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
errorLog += text;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(text);
|
||||
});
|
||||
child.on('close', (code: number | null) => {
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', onData);
|
||||
child.stderr.on('data', onData);
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`Command exited with code ${code}\n`);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
|
||||
});
|
||||
} else {
|
||||
child.stdout.on('data', () => { }); // Drains stdout
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
errorLog += data.toString();
|
||||
});
|
||||
child.on('close', (code: number | null) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
|
||||
});
|
||||
}
|
||||
}
|
||||
if (code === 0) resolve();
|
||||
else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
|
||||
else resolve();
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
console.error(`Docker Compose Deploy Error for ${stackName}:`, error.message);
|
||||
if (ws) ws.send(`Error: ${error.message}\n`);
|
||||
reject(error);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`Error: ${error.message}\n`);
|
||||
}
|
||||
if (throwOnError) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream docker logs for a stack via WebSocket.
|
||||
* Supervisors: Fetches containers via DockerController and spawns `docker logs -f` for each.
|
||||
* Automatically re-spawns on process exit if WebSocket is still OPEN, creating a persistent stream.
|
||||
* Kills the child processes when the WebSocket closes.
|
||||
*/
|
||||
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
await this.execute('docker', ['compose', action], stackDir, ws);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
|
||||
// Post-Deploy Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const containers = await dockerController.getDocker().listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] }
|
||||
});
|
||||
|
||||
for (const containerInfo of containers) {
|
||||
if (containerInfo.State === 'exited') {
|
||||
const container = dockerController.getDocker().getContainer(containerInfo.Id);
|
||||
const inspectData = await container.inspect();
|
||||
const exitCode = inspectData.State.ExitCode;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const logs = await container.logs({ stdout: true, stderr: true, tail: 50 });
|
||||
let logStr = logs.toString('utf-8');
|
||||
throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streamLogs(stackName: string, ws: WebSocket) {
|
||||
let isClosed = false;
|
||||
let isFirstRun = true;
|
||||
let isWaitingForActivity = false;
|
||||
|
||||
ws.on('close', () => {
|
||||
isClosed = true;
|
||||
});
|
||||
ws.on('close', () => { isClosed = true; });
|
||||
|
||||
const startStream = async () => {
|
||||
if (isClosed || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
try {
|
||||
const dockerController = DockerController.getInstance();
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
if (!containers || containers.length === 0) {
|
||||
@@ -162,9 +140,8 @@ export class ComposeService {
|
||||
return;
|
||||
}
|
||||
|
||||
const runningContainers = containers.filter(c => c.State === 'running');
|
||||
const runningContainers = containers.filter((c: any) => c.State === 'running');
|
||||
|
||||
// If not first run and no containers are running, we poll to wait for activity
|
||||
if (!isFirstRun && runningContainers.length === 0) {
|
||||
if (!isWaitingForActivity) {
|
||||
ws.send(`\r\n\x1b[33m[Sencho] Log stream ended. Waiting for container activity...\x1b[0m\r\n`);
|
||||
@@ -174,20 +151,16 @@ export class ComposeService {
|
||||
return;
|
||||
}
|
||||
|
||||
// On first run, we stream all containers to dump history.
|
||||
// On subsequent runs, we only attach to running containers to avoid immediate exit loop.
|
||||
const containersToLog = isFirstRun ? containers : runningContainers;
|
||||
isFirstRun = false;
|
||||
isWaitingForActivity = false; // Reset since we are tracking active elements
|
||||
isWaitingForActivity = false;
|
||||
|
||||
let activeProcesses = 0;
|
||||
let streamEndedHandled = false;
|
||||
const childProcesses: ReturnType<typeof spawn>[] = [];
|
||||
let localProcesses: ReturnType<typeof spawn>[] = [];
|
||||
|
||||
const onWsClose = () => {
|
||||
childProcesses.forEach(cp => {
|
||||
try { cp.kill(); } catch { /* ignore */ }
|
||||
});
|
||||
localProcesses.forEach(cp => { try { cp.kill(); } catch { } });
|
||||
};
|
||||
|
||||
ws.on('close', onWsClose);
|
||||
@@ -197,7 +170,6 @@ export class ComposeService {
|
||||
if (activeProcesses <= 0 && !streamEndedHandled) {
|
||||
streamEndedHandled = true;
|
||||
ws.removeListener('close', onWsClose);
|
||||
|
||||
if (!isClosed && ws.readyState === WebSocket.OPEN) {
|
||||
setTimeout(startStream, 1000);
|
||||
}
|
||||
@@ -206,47 +178,42 @@ export class ComposeService {
|
||||
|
||||
for (const container of containersToLog) {
|
||||
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
|
||||
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
});
|
||||
|
||||
activeProcesses++;
|
||||
childProcesses.push(child);
|
||||
|
||||
let lineBuffer = '';
|
||||
|
||||
const sendOutput = (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
lineBuffer += data.toString();
|
||||
const lines = lineBuffer.split(/\r?\n/);
|
||||
|
||||
// The last element is either an incomplete line or empty string
|
||||
lineBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const formattedLine = LogFormatter.process(line);
|
||||
ws.send(formattedLine + '\r\n');
|
||||
ws.send(LogFormatter.process(line) + '\r\n');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(LogFormatter.process(lineBuffer) + '\r\n');
|
||||
lineBuffer = '';
|
||||
}
|
||||
};
|
||||
|
||||
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
});
|
||||
localProcesses.push(child);
|
||||
child.stdout.on('data', sendOutput);
|
||||
child.stderr.on('data', sendOutput);
|
||||
child.on('error', handleProcessEnd);
|
||||
child.on('close', () => {
|
||||
// Flush any remaining partial line before ending
|
||||
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
|
||||
const formattedLine = LogFormatter.process(lineBuffer);
|
||||
ws.send(formattedLine + '\r\n');
|
||||
lineBuffer = '';
|
||||
}
|
||||
flushBuffer();
|
||||
handleProcessEnd();
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (!isClosed && ws.readyState === WebSocket.OPEN) {
|
||||
if (!isWaitingForActivity) {
|
||||
@@ -261,104 +228,37 @@ export class ComposeService {
|
||||
startStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stack: pull images first, then recreate containers
|
||||
* CRITICAL: cwd is set to the stack directory so relative paths resolve correctly
|
||||
*/
|
||||
async updateStack(stackName: string, ws?: WebSocket): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
|
||||
const sendOutput = (data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||
};
|
||||
|
||||
try {
|
||||
const dockerController = DockerController.getInstance();
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
sendOutput(`=== Cleaning up existing containers for clean update ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map(c => c.Id));
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
// Step 1: Pull images
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pullProcess = spawn('docker', ['compose', 'pull'], {
|
||||
cwd: stackDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
});
|
||||
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
|
||||
|
||||
pullProcess.stdout.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
pullProcess.stderr.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
pullProcess.on('close', (code: number | null) => {
|
||||
if (code === 0) {
|
||||
sendOutput('=== Images pulled successfully ===\n');
|
||||
resolve();
|
||||
} else {
|
||||
sendOutput(`=== Pull failed with code ${code} ===\n`);
|
||||
reject(new Error(`Pull failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
pullProcess.on('error', (error: Error) => {
|
||||
console.error(`Docker Compose Pull Error for ${stackName}:`, error.message);
|
||||
sendOutput(`Pull error: ${error.message}\n`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Recreate containers with new images
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const upProcess = spawn('docker', ['compose', 'up', '-d', '--remove-orphans'], {
|
||||
cwd: stackDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
});
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
sendOutput('=== Stack updated successfully ===\n');
|
||||
}
|
||||
|
||||
let errorLog = '';
|
||||
|
||||
upProcess.stdout.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
upProcess.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
errorLog += text;
|
||||
sendOutput(text);
|
||||
});
|
||||
|
||||
upProcess.on('close', (code: number | null) => {
|
||||
if (code === 0) {
|
||||
sendOutput('=== Stack updated successfully ===\n');
|
||||
resolve();
|
||||
} else {
|
||||
sendOutput(`=== Update failed with code ${code} ===\n`);
|
||||
reject(new Error(errorLog.trim() || `Up failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
upProcess.on('error', (error: Error) => {
|
||||
console.error(`Docker Compose Up Error for ${stackName}:`, error.message);
|
||||
sendOutput(`Update error: ${error.message}\n`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
public async downStack(stackName: string): Promise<void> {
|
||||
const stackPath = path.join(this.baseDir, stackName);
|
||||
try {
|
||||
await this.execute('docker', ['compose', 'down'], stackPath, undefined, false);
|
||||
} catch (error) {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ export interface StackAlert {
|
||||
last_fired_at?: number;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
compose_dir: string;
|
||||
is_default: boolean;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
created_at: number;
|
||||
api_url?: string;
|
||||
api_token?: string;
|
||||
}
|
||||
|
||||
export interface NotificationHistory {
|
||||
id?: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
@@ -40,14 +52,12 @@ export class DatabaseService {
|
||||
private constructor() {
|
||||
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'sencho.db');
|
||||
this.db = new Database(dbPath);
|
||||
// Default journal mode is safer for arbitrary Docker volume mounts than WAL
|
||||
|
||||
this.initSchema();
|
||||
this.migrateJsonConfig(dataDir);
|
||||
@@ -96,8 +106,58 @@ export class DatabaseService {
|
||||
timestamp INTEGER NOT NULL,
|
||||
is_read INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS container_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
container_id TEXT NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
cpu_percent REAL NOT NULL,
|
||||
memory_mb REAL NOT NULL,
|
||||
net_rx_mb REAL NOT NULL,
|
||||
net_tx_mb REAL NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_update_status (
|
||||
stack_name TEXT PRIMARY KEY,
|
||||
has_update INTEGER DEFAULT 0,
|
||||
checked_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL DEFAULT 'local',
|
||||
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
|
||||
is_default INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
const maybeAddCol = (table: string, col: string, def: string) => {
|
||||
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Distributed API model columns
|
||||
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
|
||||
|
||||
// Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written)
|
||||
maybeAddCol('nodes', 'host', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375');
|
||||
maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22');
|
||||
maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'ssh_key', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'tls_ca', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'tls_cert', "TEXT DEFAULT ''");
|
||||
maybeAddCol('nodes', 'tls_key', "TEXT DEFAULT ''");
|
||||
|
||||
// Initialize default global settings if they don't exist
|
||||
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
|
||||
stmt.run('host_cpu_limit', '90');
|
||||
@@ -105,6 +165,16 @@ export class DatabaseService {
|
||||
stmt.run('host_disk_limit', '90');
|
||||
stmt.run('global_crash', '1');
|
||||
stmt.run('docker_janitor_gb', '5');
|
||||
stmt.run('global_logs_refresh', '5');
|
||||
stmt.run('developer_mode', '0');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
|
||||
if (nodeCount === 0) {
|
||||
this.db.prepare(
|
||||
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
private migrateJsonConfig(dataDir: string) {
|
||||
@@ -121,8 +191,6 @@ export class DatabaseService {
|
||||
stmt.run('auth_jwt_secret', config.jwtSecret);
|
||||
|
||||
console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
|
||||
|
||||
// Delete the file after migrating
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -228,9 +296,8 @@ export class DatabaseService {
|
||||
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
|
||||
stmt.run(notification.level, notification.message, notification.timestamp);
|
||||
|
||||
// Cleanup old notifications (keep last 100)
|
||||
this.db.exec(`
|
||||
DELETE FROM notification_history
|
||||
DELETE FROM notification_history
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
|
||||
)
|
||||
@@ -251,4 +318,136 @@ export class DatabaseService {
|
||||
const stmt = this.db.prepare('DELETE FROM notification_history');
|
||||
stmt.run();
|
||||
}
|
||||
|
||||
// --- Container Metrics ---
|
||||
|
||||
public addContainerMetric(metric: Omit<any, 'id'>): void {
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
stmt.run(metric.container_id, metric.stack_name, metric.cpu_percent, metric.memory_mb, metric.net_rx_mb, metric.net_tx_mb, metric.timestamp);
|
||||
}
|
||||
|
||||
public getContainerMetrics(hoursLookback = 24): any[] {
|
||||
const cutoff = Date.now() - (hoursLookback * 60 * 60 * 1000);
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
container_id,
|
||||
stack_name,
|
||||
AVG(cpu_percent) as cpu_percent,
|
||||
AVG(memory_mb) as memory_mb,
|
||||
MAX(net_rx_mb) as net_rx_mb,
|
||||
MAX(net_tx_mb) as net_tx_mb,
|
||||
(timestamp / 60000) * 60000 as timestamp
|
||||
FROM container_metrics
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY container_id, stack_name, (timestamp / 60000)
|
||||
ORDER BY timestamp ASC
|
||||
`);
|
||||
return stmt.all(cutoff);
|
||||
}
|
||||
|
||||
public cleanupOldMetrics(hoursToKeep = 24): void {
|
||||
const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000);
|
||||
const stmt = this.db.prepare('DELETE FROM container_metrics WHERE timestamp < ?');
|
||||
stmt.run(cutoff);
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
public getNodes(): Node[] {
|
||||
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC');
|
||||
return stmt.all().map((row: any) => ({
|
||||
...row,
|
||||
is_default: row.is_default === 1
|
||||
}));
|
||||
}
|
||||
|
||||
public getNode(id: number): Node | undefined {
|
||||
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?');
|
||||
const row = stmt.get(id) as any;
|
||||
if (!row) return undefined;
|
||||
return { ...row, is_default: row.is_default === 1 };
|
||||
}
|
||||
|
||||
public getDefaultNode(): Node | undefined {
|
||||
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1');
|
||||
const row = stmt.get() as any;
|
||||
if (!row) return undefined;
|
||||
return { ...row, is_default: row.is_default === 1 };
|
||||
}
|
||||
|
||||
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
|
||||
if (node.is_default) {
|
||||
this.db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(
|
||||
node.name,
|
||||
node.type,
|
||||
node.compose_dir || '/app/compose',
|
||||
node.is_default ? 1 : 0,
|
||||
'unknown',
|
||||
Date.now(),
|
||||
node.api_url || '',
|
||||
node.api_token || ''
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public updateNode(id: number, updates: Partial<Omit<Node, 'id' | 'created_at'>>): void {
|
||||
const node = this.getNode(id);
|
||||
if (!node) throw new Error(`Node with id ${id} not found`);
|
||||
|
||||
if (updates.is_default) {
|
||||
this.db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
|
||||
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
|
||||
if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); }
|
||||
if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
|
||||
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
|
||||
if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
|
||||
if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
this.db.prepare(`UPDATE nodes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
public deleteNode(id: number): void {
|
||||
const node = this.getNode(id);
|
||||
if (node?.is_default) {
|
||||
throw new Error('Cannot delete the default node');
|
||||
}
|
||||
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
public updateNodeStatus(id: number, status: 'online' | 'offline' | 'unknown'): void {
|
||||
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
|
||||
}
|
||||
|
||||
// --- Stack Update Status ---
|
||||
|
||||
public upsertStackUpdateStatus(stackName: string, hasUpdate: boolean, checkedAt: number): void {
|
||||
this.db.prepare(
|
||||
'INSERT OR REPLACE INTO stack_update_status (stack_name, has_update, checked_at) VALUES (?, ?, ?)'
|
||||
).run(stackName, hasUpdate ? 1 : 0, checkedAt);
|
||||
}
|
||||
|
||||
public getStackUpdateStatus(): Record<string, boolean> {
|
||||
const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[];
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const row of rows) {
|
||||
result[row.stack_name] = row.has_update === 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,35 @@ import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
||||
|
||||
class DockerController {
|
||||
private static instance: DockerController;
|
||||
private docker: Docker;
|
||||
private nodeId: number;
|
||||
|
||||
private constructor() {
|
||||
this.docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
private constructor(nodeId: number) {
|
||||
this.nodeId = nodeId;
|
||||
this.docker = NodeRegistry.getInstance().getDocker(nodeId);
|
||||
}
|
||||
|
||||
public static getInstance(): DockerController {
|
||||
if (!DockerController.instance) {
|
||||
DockerController.instance = new DockerController();
|
||||
public static getInstance(nodeId?: number): DockerController {
|
||||
const id = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||
return new DockerController(id);
|
||||
}
|
||||
|
||||
public getDocker(): Docker {
|
||||
return this.docker;
|
||||
}
|
||||
|
||||
private validateApiData<T>(data: any): T {
|
||||
// If the daemon port points to a web server (like Sencho UI), Dockerode receives HTML
|
||||
if (typeof data === 'string') {
|
||||
throw new Error("Invalid response from Docker API. Did you provide a web port instead of the Docker daemon port?");
|
||||
}
|
||||
return DockerController.instance;
|
||||
return data as T;
|
||||
}
|
||||
|
||||
public async getDiskUsage() {
|
||||
@@ -84,16 +97,19 @@ class DockerController {
|
||||
}
|
||||
|
||||
public async getImages() {
|
||||
return await this.docker.listImages({ all: false });
|
||||
const data = await this.docker.listImages({ all: false });
|
||||
return this.validateApiData<any[]>(data);
|
||||
}
|
||||
|
||||
public async getVolumes() {
|
||||
const data = await this.docker.listVolumes();
|
||||
return data.Volumes || [];
|
||||
const validated = this.validateApiData<any>(data);
|
||||
return validated.Volumes || [];
|
||||
}
|
||||
|
||||
public async getNetworks() {
|
||||
return await this.docker.listNetworks();
|
||||
const data = await this.docker.listNetworks();
|
||||
return this.validateApiData<any[]>(data);
|
||||
}
|
||||
|
||||
public async removeImage(id: string) {
|
||||
@@ -113,12 +129,12 @@ class DockerController {
|
||||
|
||||
public async getRunningContainers() {
|
||||
const containers = await this.docker.listContainers({ all: false });
|
||||
return containers;
|
||||
return this.validateApiData<any[]>(containers);
|
||||
}
|
||||
|
||||
public async getAllContainers() {
|
||||
const containers = await this.docker.listContainers({ all: true });
|
||||
return containers;
|
||||
return this.validateApiData<any[]>(containers);
|
||||
}
|
||||
|
||||
public async getContainersByStack(stackName: string) {
|
||||
@@ -281,6 +297,52 @@ class DockerController {
|
||||
}
|
||||
}
|
||||
|
||||
public async streamContainerLogs(containerId: string, req: any, res: any): Promise<void> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
|
||||
// 1. Set SSE Headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
try {
|
||||
const logStream = await container.logs({
|
||||
follow: true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: 100 // Send the last 100 lines immediately for context
|
||||
});
|
||||
|
||||
// 2. Process and forward the stream
|
||||
logStream.on('data', (chunk: Buffer) => {
|
||||
// Docker multiplexes stdout/stderr with an 8-byte header if TTY is false.
|
||||
let data = chunk;
|
||||
if (chunk.length > 8 && (chunk[0] === 1 || chunk[0] === 2)) {
|
||||
data = chunk.slice(8);
|
||||
}
|
||||
|
||||
const text = data.toString('utf-8');
|
||||
const lines = text.split('\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.trim()) {
|
||||
res.write(`data: ${JSON.stringify(line)}\n\n`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Cleanup on disconnect
|
||||
req.on('close', () => {
|
||||
(logStream as any).destroy();
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
res.write(`data: ${JSON.stringify('[Sencho] Error fetching logs: ' + error.message)}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
// State-safe: silently ignores 304 "already started" errors
|
||||
public async startContainer(containerId: string) {
|
||||
try {
|
||||
@@ -288,7 +350,7 @@ class DockerController {
|
||||
await container.start();
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 304) {
|
||||
// Container already running — not an error
|
||||
// Container already running - not an error
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
@@ -302,7 +364,7 @@ class DockerController {
|
||||
await container.stop();
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 304) {
|
||||
// Container already stopped — not an error
|
||||
// Container already stopped - not an error
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
@@ -383,7 +445,7 @@ class DockerController {
|
||||
|
||||
/**
|
||||
* Exec into a container with full session isolation.
|
||||
* All state (exec instance, stream) lives in this closure — no singleton traps.
|
||||
* All state (exec instance, stream) lives in this closure - no singleton traps.
|
||||
* The WebSocket message handler is registered here to handle input, resize, and cleanup.
|
||||
*/
|
||||
public async execContainer(containerId: string, ws: WebSocket) {
|
||||
@@ -454,7 +516,7 @@ class DockerController {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON or malformed message — ignore
|
||||
// Non-JSON or malformed message - ignore
|
||||
}
|
||||
});
|
||||
|
||||
@@ -482,7 +544,8 @@ let lastNetSum = { rx: 0, tx: 0, timestamp: Date.now() };
|
||||
|
||||
export const updateGlobalDockerNetwork = async () => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance();
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const dockerController = DockerController.getInstance(nodeId);
|
||||
const containers = await dockerController.getRunningContainers();
|
||||
|
||||
const statsResults = await Promise.allSettled(
|
||||
|
||||
@@ -1,104 +1,93 @@
|
||||
import { promises as fs, Dirent } from 'fs';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* FileSystemService - local-only file I/O for compose stack management.
|
||||
*
|
||||
* In the Distributed API model, remote node file operations are handled
|
||||
* by the remote Sencho instance itself. This service only operates on
|
||||
* the local filesystem.
|
||||
*/
|
||||
export class FileSystemService {
|
||||
private baseDir: string;
|
||||
|
||||
constructor() {
|
||||
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
|
||||
constructor(nodeId?: number) {
|
||||
this.baseDir = NodeRegistry.getInstance().getComposeDir(
|
||||
nodeId ?? NodeRegistry.getInstance().getDefaultNodeId()
|
||||
);
|
||||
}
|
||||
|
||||
public static getInstance(nodeId?: number): FileSystemService {
|
||||
return new FileSystemService(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory contains a valid compose file
|
||||
*/
|
||||
private async hasComposeFile(dir: string): Promise<boolean> {
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
|
||||
for (const file of composeFiles) {
|
||||
try {
|
||||
await fs.access(path.join(dir, file));
|
||||
await fsPromises.access(path.join(dir, file));
|
||||
return true;
|
||||
} catch {
|
||||
// Continue checking other options
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the compose file for a stack
|
||||
* Throws if no compose file is found
|
||||
*/
|
||||
private async getComposeFilePath(stackName: string): Promise<string> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
|
||||
for (const file of composeFiles) {
|
||||
const filePath = path.join(stackDir, file);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await fsPromises.access(filePath);
|
||||
return filePath;
|
||||
} catch {
|
||||
// Continue checking other options
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No compose file found for stack: ${stackName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stacks (directories containing compose files)
|
||||
* Returns array of stack names (directory names)
|
||||
*/
|
||||
async getStacks(): Promise<string[]> {
|
||||
try {
|
||||
const items = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
|
||||
const stackNames: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) continue;
|
||||
if (!item.name || typeof item.name !== 'string') continue;
|
||||
|
||||
const stackDir = path.join(this.baseDir, item.name);
|
||||
const hasCompose = await this.hasComposeFile(stackDir);
|
||||
|
||||
if (hasCompose) {
|
||||
if (await this.hasComposeFile(stackDir)) {
|
||||
stackNames.push(item.name);
|
||||
}
|
||||
}
|
||||
|
||||
return stackNames;
|
||||
} catch (error) {
|
||||
console.error('Error reading stacks:', error);
|
||||
} catch (error: any) {
|
||||
console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of a stack's compose file
|
||||
*/
|
||||
async getStackContent(stackName: string): Promise<string> {
|
||||
try {
|
||||
const filePath = await this.getComposeFilePath(stackName);
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
return await fsPromises.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error reading stack content:', error);
|
||||
throw new Error(`Failed to read stack: ${stackName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save content to a stack's compose file
|
||||
* Always writes to compose.yaml (standardizing on this filename)
|
||||
*/
|
||||
async saveStackContent(stackName: string, content: string): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const filePath = path.join(stackDir, 'compose.yaml');
|
||||
|
||||
const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
|
||||
console.log('Saving to path:', filePath);
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
await fsPromises.writeFile(filePath, content, 'utf-8');
|
||||
console.log('File written successfully');
|
||||
} catch (error) {
|
||||
console.error('Error writing file:', error);
|
||||
@@ -106,41 +95,42 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stack has an .env file
|
||||
*/
|
||||
async envExists(stackName: string): Promise<boolean> {
|
||||
const envPath = path.join(this.baseDir, stackName, '.env');
|
||||
try {
|
||||
await fs.access(envPath);
|
||||
await fsPromises.access(path.join(this.baseDir, stackName, '.env'));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of a stack's .env file
|
||||
*/
|
||||
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
|
||||
return fsPromises.readFile(filePath, encoding);
|
||||
}
|
||||
|
||||
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
|
||||
return fsPromises.writeFile(filePath, content, encoding);
|
||||
}
|
||||
|
||||
async access(filePath: string): Promise<void> {
|
||||
return fsPromises.access(filePath);
|
||||
}
|
||||
|
||||
async getEnvContent(stackName: string): Promise<string> {
|
||||
const envPath = path.join(this.baseDir, stackName, '.env');
|
||||
try {
|
||||
return await fs.readFile(envPath, 'utf-8');
|
||||
return await fsPromises.readFile(envPath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error reading env file:', error);
|
||||
throw new Error(`Failed to read env file for stack: ${stackName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save content to a stack's .env file
|
||||
*/
|
||||
async saveEnvContent(stackName: string, content: string): Promise<void> {
|
||||
const envPath = path.join(this.baseDir, stackName, '.env');
|
||||
console.log('Saving env to path:', envPath);
|
||||
|
||||
try {
|
||||
await fs.writeFile(envPath, content, 'utf-8');
|
||||
await fsPromises.writeFile(envPath, content, 'utf-8');
|
||||
console.log('Env file written successfully');
|
||||
} catch (error) {
|
||||
console.error('Error writing env file:', error);
|
||||
@@ -148,33 +138,22 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new stack (directory with boilerplate compose.yaml)
|
||||
*/
|
||||
async createStack(stackName: string): Promise<void> {
|
||||
// Validate stack name (no special characters, not empty)
|
||||
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
|
||||
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
|
||||
}
|
||||
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
|
||||
// Check if directory already exists
|
||||
try {
|
||||
await fs.access(stackDir);
|
||||
await fsPromises.access(stackDir);
|
||||
throw new Error(`Stack "${stackName}" already exists`);
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('already exists')) {
|
||||
throw error;
|
||||
}
|
||||
// Directory doesn't exist, proceed
|
||||
if (error.message.includes('already exists')) throw error;
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
await fs.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
|
||||
// Write boilerplate compose.yaml
|
||||
const composePath = path.join(stackDir, 'compose.yaml');
|
||||
const boilerplate = `services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
@@ -183,7 +162,7 @@ export class FileSystemService {
|
||||
restart: always
|
||||
`;
|
||||
try {
|
||||
await fs.writeFile(composePath, boilerplate, 'utf-8');
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
|
||||
console.log('Stack created successfully:', stackName);
|
||||
} catch (error) {
|
||||
console.error('Error creating stack:', error);
|
||||
@@ -191,88 +170,71 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a stack (entire directory and its contents)
|
||||
*/
|
||||
async deleteStack(stackName: string): Promise<void> {
|
||||
public async deleteStack(stackName: string): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
|
||||
try {
|
||||
await fs.rm(stackDir, { recursive: true, force: true });
|
||||
await fsPromises.rm(stackDir, { recursive: true, force: true });
|
||||
console.log('Stack deleted successfully:', stackName);
|
||||
} catch (error) {
|
||||
console.error('Error deleting stack:', error);
|
||||
throw new Error(`Failed to delete stack: ${stackName}`);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error deleting stack directory:', error.message);
|
||||
throw new Error(`Failed to delete stack directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory path for stacks
|
||||
*/
|
||||
getBaseDir(): string {
|
||||
return this.baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate existing flat-file stacks to directory-based structure
|
||||
* This runs automatically on server startup
|
||||
*/
|
||||
async migrateFlatToDirectory(): Promise<void> {
|
||||
try {
|
||||
// Ensure base directory exists
|
||||
try {
|
||||
await fs.access(this.baseDir);
|
||||
await fsPromises.access(this.baseDir);
|
||||
} catch {
|
||||
console.log('Creating compose directory:', this.baseDir);
|
||||
await fs.mkdir(this.baseDir, { recursive: true });
|
||||
return; // No files to migrate in a new directory
|
||||
await fsPromises.mkdir(this.baseDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
// Only process .yml/.yaml files (skip directories and other files)
|
||||
if (!item.isFile()) continue;
|
||||
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
|
||||
|
||||
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
|
||||
// Check if target directory already exists
|
||||
try {
|
||||
await fs.access(stackDir);
|
||||
await fsPromises.access(stackDir);
|
||||
console.log(`Skipping migration for "${stackName}": directory already exists`);
|
||||
continue;
|
||||
} catch {
|
||||
// Directory doesn't exist, proceed with migration
|
||||
// Directory doesn't exist, proceed
|
||||
}
|
||||
|
||||
console.log(`Migrating stack: ${stackName}`);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
|
||||
// Create the stack directory
|
||||
await fs.mkdir(stackDir, { recursive: true });
|
||||
|
||||
// Move compose file to new location (standardize on compose.yaml)
|
||||
const oldComposePath = path.join(this.baseDir, item.name);
|
||||
const newComposePath = path.join(stackDir, 'compose.yaml');
|
||||
await fs.rename(oldComposePath, newComposePath);
|
||||
await fsPromises.rename(oldComposePath, newComposePath);
|
||||
|
||||
// Move env file if it exists (old pattern: stackname.env)
|
||||
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
|
||||
const newEnvPath = path.join(stackDir, '.env');
|
||||
try {
|
||||
await fs.access(oldEnvPath);
|
||||
await fs.rename(oldEnvPath, newEnvPath);
|
||||
await fsPromises.access(oldEnvPath);
|
||||
await fsPromises.rename(oldEnvPath, newEnvPath);
|
||||
console.log(`Migrated env file for: ${stackName}`);
|
||||
} catch {
|
||||
// No env file to migrate, that's fine
|
||||
// No env file to migrate
|
||||
}
|
||||
|
||||
console.log(`Successfully migrated stack: ${stackName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
// Don't throw - allow the server to start even if migration fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import DockerController from './DockerController';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
// ─── Image ref parsing ────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedRef {
|
||||
registry: string; // e.g. "registry-1.docker.io", "lscr.io", "ghcr.io"
|
||||
repo: string; // e.g. "library/nginx", "linuxserver/sonarr"
|
||||
tag: string; // e.g. "latest", "1.25"
|
||||
}
|
||||
|
||||
function parseImageRef(imageRef: string): ParsedRef | null {
|
||||
if (imageRef.startsWith('sha256:')) return null;
|
||||
|
||||
// Strip digest pin (e.g. "nginx@sha256:abc" → "nginx")
|
||||
const atIdx = imageRef.indexOf('@');
|
||||
if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx);
|
||||
|
||||
let registry = 'registry-1.docker.io';
|
||||
let rest = imageRef;
|
||||
|
||||
const slashIdx = imageRef.indexOf('/');
|
||||
if (slashIdx !== -1) {
|
||||
const firstPart = imageRef.slice(0, slashIdx);
|
||||
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
|
||||
registry = firstPart;
|
||||
rest = imageRef.slice(slashIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tag
|
||||
let tag = 'latest';
|
||||
const colonIdx = rest.lastIndexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
tag = rest.slice(colonIdx + 1);
|
||||
rest = rest.slice(0, colonIdx);
|
||||
}
|
||||
|
||||
// Docker Hub official images (no slash) → prepend "library/"
|
||||
if (registry === 'registry-1.docker.io' && !rest.includes('/')) {
|
||||
rest = `library/${rest}`;
|
||||
}
|
||||
|
||||
return { registry, repo: rest, tag };
|
||||
}
|
||||
|
||||
// ─── Minimal HTTP helper ──────────────────────────────────────────────────────
|
||||
|
||||
interface HttpResult {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith('https:') ? https : http;
|
||||
const req = lib.get(url, { headers }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
||||
res.on('end', () => resolve({
|
||||
statusCode: res.statusCode ?? 0,
|
||||
headers: res.headers as Record<string, string | string[] | undefined>,
|
||||
body,
|
||||
}));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Registry auth ────────────────────────────────────────────────────────────
|
||||
|
||||
async function getAuthToken(registry: string, repo: string): Promise<string | null> {
|
||||
try {
|
||||
let tokenUrl: string;
|
||||
|
||||
if (registry === 'registry-1.docker.io') {
|
||||
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
|
||||
} else {
|
||||
// Ping /v2/ to get the WWW-Authenticate challenge
|
||||
const ping = await httpGet(`https://${registry}/v2/`);
|
||||
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
|
||||
if (!wwwAuth) return null;
|
||||
|
||||
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
|
||||
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
|
||||
const scopeMatch = wwwAuth.match(/scope="([^"]+)"/);
|
||||
if (!realmMatch) return null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (serviceMatch) params.set('service', serviceMatch[1]);
|
||||
params.set('scope', scopeMatch ? scopeMatch[1] : `repository:${repo}:pull`);
|
||||
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const tokenRes = await httpGet(tokenUrl);
|
||||
if (tokenRes.statusCode !== 200) return null;
|
||||
|
||||
const parsed = JSON.parse(tokenRes.body);
|
||||
return parsed.token ?? parsed.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Remote digest lookup ─────────────────────────────────────────────────────
|
||||
|
||||
// Include manifest list types so we get the fat-manifest digest for multi-arch
|
||||
// images — this matches what Docker stores in local RepoDigests.
|
||||
const MANIFEST_ACCEPT = [
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
].join(', ');
|
||||
|
||||
async function getRemoteDigest(registry: string, repo: string, tag: string): Promise<string | null> {
|
||||
try {
|
||||
const token = await getAuthToken(registry, repo);
|
||||
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers);
|
||||
if (res.statusCode !== 200) return null;
|
||||
|
||||
return (res.headers['docker-content-digest'] as string) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ImageUpdateService {
|
||||
private static instance: ImageUpdateService;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
private lastManualRefreshAt = 0;
|
||||
|
||||
private static readonly INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot
|
||||
private static readonly MANUAL_COOLDOWN_MS = 10 * 60 * 1000; // 10 min between manual triggers
|
||||
private static readonly INTER_IMAGE_DELAY_MS = 300; // be polite to registries
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ImageUpdateService {
|
||||
if (!ImageUpdateService.instance) {
|
||||
ImageUpdateService.instance = new ImageUpdateService();
|
||||
}
|
||||
return ImageUpdateService.instance;
|
||||
}
|
||||
|
||||
public start() {
|
||||
if (this.intervalId) return;
|
||||
setTimeout(() => this.check(), ImageUpdateService.STARTUP_DELAY_MS);
|
||||
this.intervalId = setInterval(() => this.check(), ImageUpdateService.INTERVAL_MS);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a check immediately, unless one is already running or the
|
||||
* 10-minute manual cooldown has not elapsed.
|
||||
* Returns false if rate-limited, true if a check was started.
|
||||
*/
|
||||
public triggerManualRefresh(): boolean {
|
||||
const now = Date.now();
|
||||
if (now - this.lastManualRefreshAt < ImageUpdateService.MANUAL_COOLDOWN_MS) {
|
||||
return false;
|
||||
}
|
||||
this.lastManualRefreshAt = now;
|
||||
this.check().catch(e => console.error('[ImageUpdateService] Manual refresh error:', e));
|
||||
return true;
|
||||
}
|
||||
|
||||
public isChecking(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
// ─── Core check ──────────────────────────────────────────────────────────
|
||||
|
||||
private async check() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
console.log('[ImageUpdateService] Starting image update check...');
|
||||
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
// Only check local nodes — remote nodes run their own instance
|
||||
for (const node of db.getNodes()) {
|
||||
if (node.type !== 'local' || !node.id) continue;
|
||||
try {
|
||||
await this.checkNode(node.id, db);
|
||||
} catch (e) {
|
||||
console.error(`[ImageUpdateService] Error on node ${node.name}:`, e);
|
||||
}
|
||||
}
|
||||
console.log('[ImageUpdateService] Image update check complete.');
|
||||
} catch (e) {
|
||||
console.error('[ImageUpdateService] Check failed:', e);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkNode(nodeId: number, db: DatabaseService) {
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
const containers = await docker.getAllContainers();
|
||||
|
||||
// stackName → set of image refs used by that stack
|
||||
const stackImages = new Map<string, Set<string>>();
|
||||
|
||||
for (const c of containers) {
|
||||
const stackName: string | undefined = c.Labels?.['com.docker.compose.project'];
|
||||
if (!stackName) continue;
|
||||
|
||||
const imageRef: string = c.Image ?? '';
|
||||
if (!imageRef || imageRef.startsWith('sha256:')) continue;
|
||||
|
||||
if (!stackImages.has(stackName)) stackImages.set(stackName, new Set());
|
||||
stackImages.get(stackName)!.add(imageRef);
|
||||
}
|
||||
|
||||
if (stackImages.size === 0) return;
|
||||
|
||||
// Deduplicate: each unique image is checked once regardless of how many stacks use it
|
||||
const allImages = new Set<string>();
|
||||
for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img);
|
||||
|
||||
const imageUpdateMap = new Map<string, boolean>();
|
||||
|
||||
for (const imageRef of allImages) {
|
||||
try {
|
||||
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
|
||||
} catch (e) {
|
||||
console.error(`[ImageUpdateService] Error checking ${imageRef}:`, e);
|
||||
imageUpdateMap.set(imageRef, false);
|
||||
}
|
||||
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const [stackName, images] of stackImages) {
|
||||
const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img) === true);
|
||||
db.upsertStackUpdateStatus(stackName, hasUpdate, now);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkImage(docker: DockerController, imageRef: string): Promise<boolean> {
|
||||
const parsed = parseImageRef(imageRef);
|
||||
if (!parsed) return false;
|
||||
|
||||
// Get local digest from RepoDigests
|
||||
let localDigest: string | null = null;
|
||||
try {
|
||||
const inspect = await docker.getDocker().getImage(imageRef).inspect();
|
||||
const repoDigests: string[] = inspect.RepoDigests ?? [];
|
||||
|
||||
for (const rd of repoDigests) {
|
||||
if (!rd.includes('@sha256:')) continue;
|
||||
const [, digest] = rd.split('@');
|
||||
|
||||
// Match: rd contains the repo path or this is the only digest entry
|
||||
if (rd.includes(parsed.repo) || rd.includes(parsed.registry) || repoDigests.length === 1) {
|
||||
localDigest = digest;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false; // Image inspect failed (removed since container was started)
|
||||
}
|
||||
|
||||
if (!localDigest) return false; // Locally built or never pulled with a digest
|
||||
|
||||
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag);
|
||||
if (!remoteDigest) return false; // Registry unreachable — no false positives
|
||||
|
||||
const hasUpdate = localDigest !== remoteDigest;
|
||||
console.log(
|
||||
`[ImageUpdateService] ${imageRef}: ` +
|
||||
`local=${localDigest.slice(0, 27)}... remote=${remoteDigest.slice(0, 27)}... update=${hasUpdate}`
|
||||
);
|
||||
return hasUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import Docker from 'dockerode';
|
||||
import axios from 'axios';
|
||||
import { DatabaseService, Node } from './DatabaseService';
|
||||
|
||||
/**
|
||||
* NodeRegistry: Manages connections for multiple nodes.
|
||||
*
|
||||
* In the Distributed API model:
|
||||
* - Local nodes: direct Docker socket connection via Dockerode (unchanged)
|
||||
* - Remote nodes: HTTP/WS proxy to a remote Sencho instance (api_url + api_token)
|
||||
* No direct Docker TCP connections are made for remote nodes.
|
||||
*/
|
||||
export class NodeRegistry {
|
||||
private static instance: NodeRegistry;
|
||||
private connections: Map<number, Docker> = new Map();
|
||||
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): NodeRegistry {
|
||||
if (!NodeRegistry.instance) {
|
||||
NodeRegistry.instance = new NodeRegistry();
|
||||
}
|
||||
return NodeRegistry.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Docker client for a LOCAL node only.
|
||||
* Remote nodes are never accessed via Dockerode - use the HTTP proxy instead.
|
||||
*/
|
||||
public getDocker(nodeId: number): Docker {
|
||||
if (this.connections.has(nodeId)) {
|
||||
return this.connections.get(nodeId)!;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNode(nodeId);
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`Node with id ${nodeId} not found`);
|
||||
}
|
||||
|
||||
if (node.type === 'remote') {
|
||||
throw new Error(
|
||||
`Node "${node.name}" is a remote Distributed API node. ` +
|
||||
`Its Docker daemon is not directly accessible - all requests are proxied via HTTP.`
|
||||
);
|
||||
}
|
||||
|
||||
const docker = new Docker();
|
||||
this.connections.set(nodeId, docker);
|
||||
return docker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Docker client for the default node.
|
||||
* Backward-compatible path for local-node code.
|
||||
*/
|
||||
public getDefaultDocker(): Docker {
|
||||
const db = DatabaseService.getInstance();
|
||||
const defaultNode = db.getDefaultNode();
|
||||
|
||||
if (!defaultNode || !defaultNode.id) {
|
||||
return new Docker();
|
||||
}
|
||||
|
||||
return this.getDocker(defaultNode.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default node ID.
|
||||
*/
|
||||
public getDefaultNodeId(): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
const defaultNode = db.getDefaultNode();
|
||||
return defaultNode?.id || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node configuration by its ID.
|
||||
*/
|
||||
public getNode(nodeId: number): Node | undefined {
|
||||
const db = DatabaseService.getInstance();
|
||||
return db.getNode(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP proxy target for a remote node.
|
||||
* Returns { apiUrl, apiToken } for use by the HTTP proxy middleware.
|
||||
*/
|
||||
public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null {
|
||||
const node = DatabaseService.getInstance().getNode(nodeId);
|
||||
if (!node || node.type !== 'remote' || !node.api_url || !node.api_token) {
|
||||
return null;
|
||||
}
|
||||
return { apiUrl: node.api_url, apiToken: node.api_token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity to a specific node.
|
||||
* - Local: pings the Docker daemon directly
|
||||
* - Remote: makes a GET to /api/auth/check on the remote Sencho instance
|
||||
*/
|
||||
public async testConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNode(nodeId);
|
||||
|
||||
if (!node) {
|
||||
return { success: false, error: 'Node not found' };
|
||||
}
|
||||
|
||||
if (node.type === 'remote') {
|
||||
return this.testRemoteConnection(node);
|
||||
}
|
||||
|
||||
return this.testLocalConnection(nodeId);
|
||||
}
|
||||
|
||||
private async testLocalConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
try {
|
||||
const docker = new Docker();
|
||||
const info = await docker.info();
|
||||
|
||||
if (!info || !info.OperatingSystem || typeof info.Containers !== 'number') {
|
||||
throw new Error('Invalid response from Docker daemon.');
|
||||
}
|
||||
|
||||
db.updateNodeStatus(nodeId, 'online');
|
||||
return {
|
||||
success: true,
|
||||
info: {
|
||||
name: info.Name,
|
||||
serverVersion: info.ServerVersion,
|
||||
os: info.OperatingSystem,
|
||||
architecture: info.Architecture,
|
||||
containers: info.Containers,
|
||||
containersRunning: info.ContainersRunning,
|
||||
images: info.Images,
|
||||
memTotal: info.MemTotal,
|
||||
cpus: info.NCPU,
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
db.updateNodeStatus(nodeId, 'offline');
|
||||
return { success: false, error: error.message || 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private async testRemoteConnection(node: Node): Promise<{ success: boolean; error?: string; info?: any }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
if (!node.api_url || !node.api_token) {
|
||||
return { success: false, error: 'Remote node is missing an API URL or token. Configure it in Settings → Nodes.' };
|
||||
}
|
||||
|
||||
const baseUrl = node.api_url.replace(/\/$/, '');
|
||||
const headers = { Authorization: `Bearer ${node.api_token}` };
|
||||
|
||||
try {
|
||||
// Step 1: Verify auth. A 401 here means wrong token — surface that clearly.
|
||||
const authRes = await axios.get(`${baseUrl}/api/auth/check`, { headers, timeout: 8000 });
|
||||
if (authRes.status !== 200) throw new Error(`Unexpected status ${authRes.status}`);
|
||||
|
||||
db.updateNodeStatus(node.id, 'online');
|
||||
|
||||
// Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing
|
||||
// endpoint doesn't fail the whole test — each field falls back to '-' gracefully.
|
||||
const [statsResult, sysResult, imagesResult] = await Promise.allSettled([
|
||||
axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }),
|
||||
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
|
||||
axios.get(`${baseUrl}/api/system/images`, { headers, timeout: 8000 }),
|
||||
]);
|
||||
|
||||
const stats = statsResult.status === 'fulfilled' ? statsResult.value.data : null;
|
||||
const sys = sysResult.status === 'fulfilled' ? sysResult.value.data : null;
|
||||
const images = imagesResult.status === 'fulfilled' ? imagesResult.value.data : null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
info: {
|
||||
name: node.name,
|
||||
serverVersion: 'Remote Sencho',
|
||||
os: 'Remote',
|
||||
architecture: 'Remote',
|
||||
containers: stats?.total ?? '-',
|
||||
containersRunning: stats?.active ?? '-',
|
||||
images: Array.isArray(images) ? images.length : '-',
|
||||
memTotal: sys?.memory?.total ?? 0,
|
||||
cpus: sys?.cpu?.cores ?? '-',
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
db.updateNodeStatus(node.id, 'offline');
|
||||
const msg = error.response?.status === 401
|
||||
? 'Authentication failed - check the API token.'
|
||||
: (error.message || 'Connection failed');
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict a cached Docker connection (e.g., after node config change).
|
||||
*/
|
||||
public evictConnection(nodeId: number): void {
|
||||
this.connections.delete(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all cached connections.
|
||||
*/
|
||||
public flushAll(): void {
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the compose directory for a local node.
|
||||
*/
|
||||
public getComposeDir(nodeId: number): string {
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNode(nodeId);
|
||||
return node?.compose_dir || process.env.COMPOSE_DIR || '/app/compose';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import axios from 'axios';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
|
||||
export interface TemplateEnv {
|
||||
name: string;
|
||||
label?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface TemplateVolume {
|
||||
container: string;
|
||||
bind?: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
type?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
logo?: string;
|
||||
image?: string;
|
||||
ports?: string[];
|
||||
volumes?: TemplateVolume[] | string[];
|
||||
env?: TemplateEnv[];
|
||||
categories?: string[];
|
||||
platform?: string;
|
||||
github_url?: string;
|
||||
docs_url?: string;
|
||||
architectures?: string[];
|
||||
stars?: number;
|
||||
repository?: {
|
||||
url: string;
|
||||
stackfile: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TemplatesResponse {
|
||||
version: string;
|
||||
templates: Template[];
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
private cachedTemplates: Template[] = [];
|
||||
private lastFetchTime: number = 0;
|
||||
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
public async getTemplates(): Promise<Template[]> {
|
||||
const now = Date.now();
|
||||
if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) {
|
||||
return this.cachedTemplates;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
// Default to a reliable LSIO Portainer v2 template registry if not set
|
||||
const registryUrl = settings.template_registry_url || 'https://api.linuxserver.io/api/v1/images?include_config=true';
|
||||
|
||||
const response = await axios.get<any>(registryUrl);
|
||||
|
||||
if (registryUrl.includes('api.linuxserver.io')) {
|
||||
// Official LSIO API Schema Mapping
|
||||
const lsioApps = response.data?.data?.repositories?.linuxserver || [];
|
||||
|
||||
this.cachedTemplates = Object.values(lsioApps).map((app: any) => {
|
||||
return {
|
||||
type: 1,
|
||||
title: app.name,
|
||||
description: app.description || '',
|
||||
logo: app.logo || `https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/${app.name}-logo.png`,
|
||||
image: `lscr.io/linuxserver/${app.name}:latest`,
|
||||
github_url: app.github,
|
||||
docs_url: app.readme,
|
||||
architectures: app.arch,
|
||||
stars: app.stars,
|
||||
// Map configs if available, otherwise default to empty arrays
|
||||
ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`),
|
||||
volumes: (app.config?.volumes || []).map((v: any) => {
|
||||
const folderName = v.path.split('/').filter(Boolean).pop() || 'data';
|
||||
return {
|
||||
container: v.path,
|
||||
bind: `./${folderName}` // Proactively create a clean relative path
|
||||
};
|
||||
}),
|
||||
env: (app.config?.environment || []).map((e: any) => ({
|
||||
name: e.name,
|
||||
label: e.desc || e.name,
|
||||
default: e.default || ''
|
||||
}))
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Legacy Portainer v2 Format (Fallback for custom registries)
|
||||
this.cachedTemplates = (response.data.templates || []).filter((t: Template) => !!t.image && t.type === 1);
|
||||
}
|
||||
|
||||
this.lastFetchTime = now;
|
||||
return this.cachedTemplates;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch templates', error);
|
||||
if (this.cachedTemplates.length > 0) {
|
||||
return this.cachedTemplates;
|
||||
}
|
||||
throw new Error('Could not fetch templates from registry');
|
||||
}
|
||||
}
|
||||
|
||||
public generateComposeFromTemplate(template: Template): string {
|
||||
let yaml = `services:\n app:\n`;
|
||||
|
||||
if (template.image) {
|
||||
yaml += ` image: ${template.image}\n`;
|
||||
}
|
||||
|
||||
yaml += ` restart: unless-stopped\n`;
|
||||
|
||||
if (template.ports && template.ports.length > 0) {
|
||||
yaml += ` ports:\n`;
|
||||
for (const port of template.ports) {
|
||||
yaml += ` - "${port}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (template.volumes && template.volumes.length > 0) {
|
||||
yaml += ` volumes:\n`;
|
||||
for (const vol of template.volumes) {
|
||||
let hostPath = '';
|
||||
let containerPath = '';
|
||||
let options = '';
|
||||
|
||||
if (typeof vol === 'string') {
|
||||
const parts = vol.split(':');
|
||||
if (parts.length === 1) {
|
||||
yaml += ` - ${vol}\n`;
|
||||
continue;
|
||||
}
|
||||
hostPath = parts[0];
|
||||
containerPath = parts[1];
|
||||
options = parts.slice(2).join(':');
|
||||
if (options) options = `:${options}`;
|
||||
} else if (vol.container) {
|
||||
containerPath = vol.container;
|
||||
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
|
||||
hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
|
||||
options = vol.readonly ? ':ro' : '';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
yaml += ` - ${hostPath}:${containerPath}${options}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
yaml += ` env_file:\n - .env\n`;
|
||||
|
||||
return yaml;
|
||||
}
|
||||
|
||||
public generateEnvString(envVars: Record<string, string>): string {
|
||||
return Object.entries(envVars)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export const templateService = new TemplateService();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Generated
+167
-163
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { NodeProvider } from './context/NodeContext';
|
||||
import { Login } from './components/Login';
|
||||
import { Setup } from './components/Setup';
|
||||
import EditorLayout from './components/EditorLayout';
|
||||
@@ -30,7 +31,11 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
return <EditorLayout />;
|
||||
return (
|
||||
<NodeProvider>
|
||||
<EditorLayout />
|
||||
</NodeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Rocket, Loader2, Info, ExternalLink, Github, Star } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
export interface TemplateEnv {
|
||||
name: string;
|
||||
label?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
type?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
logo?: string;
|
||||
image?: string;
|
||||
ports?: string[];
|
||||
volumes?: any[];
|
||||
env?: TemplateEnv[];
|
||||
categories?: string[];
|
||||
github_url?: string;
|
||||
docs_url?: string;
|
||||
architectures?: string[];
|
||||
stars?: number;
|
||||
}
|
||||
|
||||
interface AppStoreViewProps {
|
||||
onDeploySuccess: (stackName: string) => void;
|
||||
}
|
||||
|
||||
export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const [stackName, setStackName] = useState('');
|
||||
const [envVars, setEnvVars] = useState<Record<string, string>>({});
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
|
||||
const [portVars, setPortVars] = useState<Record<string, string>>({});
|
||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||
const [volVars, setVolVars] = useState<Record<string, string>>({});
|
||||
const [customEnvs, setCustomEnvs] = useState<Array<{ key: string, value: string }>>([]);
|
||||
const [newEnvKey, setNewEnvKey] = useState('');
|
||||
const [newEnvVal, setNewEnvVal] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/templates');
|
||||
if (!res.ok) throw new Error('Failed to fetch templates');
|
||||
const data = await res.json();
|
||||
setTemplates(data || []);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to load App Shop");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectTemplate = (t: Template) => {
|
||||
setSelectedTemplate(t);
|
||||
const localEnvs = [...(t.env || [])];
|
||||
// Inject LSIO standards if missing from the API
|
||||
if (!localEnvs.find(e => e.name === 'PUID')) localEnvs.push({ name: 'PUID', label: 'User ID (PUID)', default: '1000' });
|
||||
if (!localEnvs.find(e => e.name === 'PGID')) localEnvs.push({ name: 'PGID', label: 'Group ID (PGID)', default: '1000' });
|
||||
if (!localEnvs.find(e => e.name === 'TZ')) {
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
localEnvs.push({ name: 'TZ', label: 'Timezone', default: browserTz });
|
||||
}
|
||||
|
||||
const initEnvs: Record<string, string> = {};
|
||||
localEnvs.forEach(e => {
|
||||
initEnvs[e.name] = e.default || '';
|
||||
});
|
||||
setEnvVars(initEnvs);
|
||||
t.env = localEnvs; // Mutate local template copy so they render
|
||||
|
||||
// Initialize Volumes
|
||||
const initVols: Record<string, string> = {};
|
||||
t.volumes?.forEach((v: any) => {
|
||||
if (v.container) {
|
||||
initVols[v.container] = v.bind || `./${v.container.split('/').filter(Boolean).pop() || 'data'}`;
|
||||
}
|
||||
});
|
||||
setVolVars(initVols);
|
||||
setCustomEnvs([]); // Reset custom envs
|
||||
setNewEnvKey('');
|
||||
setNewEnvVal('');
|
||||
|
||||
const initPorts: Record<string, string> = {};
|
||||
t.ports?.forEach(p => {
|
||||
const parts = p.split(':');
|
||||
if (parts.length > 1) {
|
||||
initPorts[p] = parts[0]; // Store just the host port for editing
|
||||
}
|
||||
});
|
||||
setPortVars(initPorts);
|
||||
setIsDescExpanded(false); // Reset description toggle
|
||||
|
||||
// Auto-generate stack name from title
|
||||
const defaultName = t.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
setStackName(defaultName);
|
||||
|
||||
setIsSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!stackName.trim()) {
|
||||
toast.error("Stack name is required");
|
||||
return;
|
||||
}
|
||||
setIsDeploying(true);
|
||||
|
||||
const modifiedTemplate = { ...selectedTemplate };
|
||||
if (modifiedTemplate.ports) {
|
||||
modifiedTemplate.ports = modifiedTemplate.ports.map(p => {
|
||||
const parts = p.split(':');
|
||||
if (parts.length > 1 && portVars[p]) {
|
||||
return `${portVars[p]}:${parts[1]}`; // Stitch the edited host port back
|
||||
}
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
// Process Volumes
|
||||
if (modifiedTemplate.volumes) {
|
||||
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v: any) => {
|
||||
if (v.container && volVars[v.container] !== undefined) {
|
||||
return { ...v, bind: volVars[v.container] };
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
// Merge envs
|
||||
const finalEnvVars = { ...envVars };
|
||||
customEnvs.forEach(ce => {
|
||||
if (ce.key.trim()) finalEnvVars[ce.key.trim()] = ce.value;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/templates/deploy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
stackName: stackName.trim(),
|
||||
template: modifiedTemplate,
|
||||
envVars: finalEnvVars
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to deploy template');
|
||||
|
||||
toast.success(`${selectedTemplate?.title} deployed successfully!`);
|
||||
setIsSheetOpen(false);
|
||||
onDeploySuccess(stackName.trim());
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Deployment failed');
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = templates.filter(t =>
|
||||
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(t.categories && t.categories.join(' ').toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search App Store..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
||||
{filtered.map((t, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
className="cursor-pointer hover:border-primary transition-colors flex flex-col overflow-hidden h-full"
|
||||
onClick={() => handleSelectTemplate(t)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start gap-4 space-y-0">
|
||||
<div className="w-12 h-12 rounded bg-muted/50 p-1 flex-shrink-0 flex items-center justify-center bg-white overflow-hidden">
|
||||
{!imgErrors[t.title] && t.logo ? (
|
||||
<img src={t.logo} alt={t.title} className="w-full h-full object-contain" onError={() => setImgErrors(prev => ({ ...prev, [t.title]: true }))} />
|
||||
) : (
|
||||
<Rocket className="w-6 h-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate">{t.title}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1 min-h-[40px]">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{t.categories && t.categories.length > 0 && (
|
||||
<CardContent className="pt-0 mt-auto">
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{t.categories.slice(0, 3).map(c => (
|
||||
<Badge variant="secondary" key={c} className="text-[10px] px-1.5 py-0 pb-0.5">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No apps found matching "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||
<SheetContent className="w-full sm:max-w-md flex flex-col h-full" side="right">
|
||||
{selectedTemplate && (
|
||||
<div className="flex flex-col h-full">
|
||||
<SheetHeader className="mb-6 text-left">
|
||||
{activeNode?.type === 'remote' && (
|
||||
<div className="mb-2">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
Deploying to: {activeNode.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded bg-white p-1 flex-shrink-0 flex items-center justify-center overflow-hidden border">
|
||||
{!imgErrors[selectedTemplate.title] && selectedTemplate.logo ? (
|
||||
<img src={selectedTemplate.logo} alt={selectedTemplate.title} className="w-full h-full object-contain" onError={() => setImgErrors(prev => ({ ...prev, [selectedTemplate.title]: true }))} />
|
||||
) : (
|
||||
<Rocket className="w-8 h-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SheetTitle className="text-xl">{selectedTemplate.title}</SheetTitle>
|
||||
<div className="mt-1">
|
||||
<SheetDescription className={isDescExpanded ? "" : "line-clamp-3 text-sm text-muted-foreground"}>
|
||||
{selectedTemplate.description}
|
||||
</SheetDescription>
|
||||
<span className="text-xs text-primary cursor-pointer hover:underline mt-1 inline-block" onClick={() => setIsDescExpanded(!isDescExpanded)}>
|
||||
{isDescExpanded ? 'Read less' : 'Read more'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(selectedTemplate.architectures || selectedTemplate.stars !== undefined || selectedTemplate.github_url || selectedTemplate.docs_url) && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{selectedTemplate.architectures && selectedTemplate.architectures.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTemplate.architectures.map(arch => (
|
||||
<Badge variant="outline" key={arch} className="text-[10px] px-1.5 py-0">
|
||||
{arch}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
{selectedTemplate.stars !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3 fill-muted-foreground" />
|
||||
<span>{selectedTemplate.stars}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedTemplate.github_url && (
|
||||
<a href={selectedTemplate.github_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
<Github className="w-3 h-3" />
|
||||
<span>Source</span>
|
||||
</a>
|
||||
)}
|
||||
{selectedTemplate.docs_url && (
|
||||
<a href={selectedTemplate.docs_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="flex-1 pr-4 -mx-4 px-4">
|
||||
<div className="space-y-6 pb-8">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stackName" className="font-semibold">
|
||||
Stack Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="stackName"
|
||||
value={stackName}
|
||||
onChange={(e) => setStackName(e.target.value)}
|
||||
placeholder="e.g. my-app"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This determines the directory name and docker project name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Ports (Host : Container)</h4>
|
||||
{selectedTemplate.ports.map((p, idx) => {
|
||||
const parts = p.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
return (
|
||||
<div key={idx} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={portVars[p] || ''}
|
||||
onChange={(e) => setPortVars(prev => ({ ...prev, [p]: e.target.value }))}
|
||||
className="w-24 text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Volumes (Host : Container)</h4>
|
||||
{selectedTemplate.volumes.map((v: any, idx: number) => {
|
||||
if (!v.container) return null;
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-mono">Container: {v.container}</Label>
|
||||
<Input
|
||||
value={volVars[v.container] !== undefined ? volVars[v.container] : ''}
|
||||
onChange={(e) => setVolVars(prev => ({ ...prev, [v.container]: e.target.value }))}
|
||||
placeholder={`/path/to/host/dir`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate.env && selectedTemplate.env.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Environment Variables</h4>
|
||||
{selectedTemplate.env.map((e, idx) => (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label htmlFor={`env-${e.name}`} className="text-sm">
|
||||
{e.label || e.name}
|
||||
</Label>
|
||||
<Input
|
||||
id={`env-${e.name}`}
|
||||
value={envVars[e.name] !== undefined ? envVars[e.name] : ''}
|
||||
onChange={(ev) => setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))}
|
||||
placeholder={e.default || `Enter value for ${e.name}`}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">
|
||||
{e.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold text-sm">Add Custom Variable</h4>
|
||||
{customEnvs.map((ce, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<Input value={ce.key} readOnly className="w-1/3 bg-muted font-mono text-xs" />
|
||||
<Input value={ce.value} readOnly className="flex-1 bg-muted font-mono text-xs" />
|
||||
<Button variant="destructive" size="icon" onClick={() => setCustomEnvs(prev => prev.filter((_, i) => i !== idx))}>-</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="KEY" value={newEnvKey} onChange={e => setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" />
|
||||
<Input placeholder="VALUE" value={newEnvVal} onChange={e => setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" />
|
||||
<Button variant="secondary" onClick={() => {
|
||||
if (newEnvKey.trim()) {
|
||||
setCustomEnvs(prev => [...prev, { key: newEnvKey, value: newEnvVal }]);
|
||||
setNewEnvKey('');
|
||||
setNewEnvVal('');
|
||||
}
|
||||
}}>+</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="pt-4 mt-auto border-t sm:justify-start">
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={isDeploying || !stackName.trim()}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isDeploying ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Deploying Stack...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Rocket className="w-5 h-5 mr-2" />
|
||||
Deploy {selectedTemplate.title}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isDeploying && (
|
||||
<p className="text-center text-xs text-muted-foreground animate-pulse">
|
||||
This may take a few minutes for large images.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
|
||||
return;
|
||||
}
|
||||
|
||||
// Node exists and has layout — safe to initialize xterm!
|
||||
// Node exists and has layout - safe to initialize xterm!
|
||||
initTerminal(container);
|
||||
};
|
||||
|
||||
@@ -114,14 +114,15 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
|
||||
|
||||
// Connect to WebSocket for bash exec
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// Kick off the exec session
|
||||
ws.send(JSON.stringify({
|
||||
action: 'execContainer',
|
||||
containerId: containerId,
|
||||
nodeId: localStorage.getItem('sencho-active-node') || undefined
|
||||
}));
|
||||
setIsConnected(true);
|
||||
|
||||
@@ -160,7 +161,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
// Handle user input — JSON up
|
||||
// Handle user input - JSON up
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
@@ -224,9 +225,9 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
|
||||
Interactive bash terminal session for {containerName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Styling wrapper — padding and rounded corners go here */}
|
||||
{/* Styling wrapper - padding and rounded corners go here */}
|
||||
<div className="flex-1 rounded-lg bg-[#1e1e1e] p-1 min-h-0" style={{ overflow: 'hidden' }}>
|
||||
{/* Clean xterm container — NO padding, NO overflow-hidden, explicit dimensions */}
|
||||
{/* Clean xterm container - NO padding, NO overflow-hidden, explicit dimensions */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
@@ -28,6 +28,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { StackAlertSheet } from './StackAlertSheet';
|
||||
import { AppStoreView } from './AppStoreView';
|
||||
import { LogViewer } from './LogViewer';
|
||||
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
@@ -50,6 +55,7 @@ const formatBytes = (bytes: number) => {
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { logout } = useAuth();
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<string>('');
|
||||
@@ -76,7 +82,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
return true; // Default to dark mode
|
||||
});
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources'>('dashboard');
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||
@@ -85,6 +91,13 @@ export default function EditorLayout() {
|
||||
const [bashModalOpen, setBashModalOpen] = useState(false);
|
||||
const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
// LogViewer state
|
||||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||
const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
|
||||
// Image update checker state
|
||||
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Notifications & Settings state
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
@@ -113,12 +126,17 @@ export default function EditorLayout() {
|
||||
if (!background) setIsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/stacks');
|
||||
const stacks = await res.json();
|
||||
setFiles(Array.isArray(stacks) ? stacks : []);
|
||||
if (!res.ok) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const fileList: string[] = Array.isArray(data) ? data : [];
|
||||
setFiles(fileList);
|
||||
|
||||
// Fetch status for each stack
|
||||
const statuses: StackStatus = {};
|
||||
for (const file of stacks) {
|
||||
for (const file of fileList) {
|
||||
try {
|
||||
const containersRes = await apiFetch(`/stacks/${file}/containers`);
|
||||
const containers = await containersRes.json();
|
||||
@@ -137,12 +155,28 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Notification polling - independent of active node, runs once on mount
|
||||
useEffect(() => {
|
||||
refreshStacks();
|
||||
fetchNotifications();
|
||||
const notificationInterval = setInterval(fetchNotifications, 5000);
|
||||
return () => clearInterval(notificationInterval);
|
||||
}, []);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fetch stacks whenever the active node changes (or becomes available on mount).
|
||||
// Also clears any stale editor/container state that belonged to the previous node.
|
||||
useEffect(() => {
|
||||
if (!activeNode) return;
|
||||
setSelectedFile(null);
|
||||
setContent('');
|
||||
setOriginalContent('');
|
||||
setEnvContent('');
|
||||
setOriginalEnvContent('');
|
||||
setContainers([]);
|
||||
setIsEditing(false);
|
||||
setActiveView('dashboard');
|
||||
refreshStacks();
|
||||
fetchImageUpdates();
|
||||
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
@@ -154,6 +188,16 @@ export default function EditorLayout() {
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
const fetchImageUpdates = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/image-updates');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStackUpdates(data);
|
||||
}
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await apiFetch('/notifications/read', { method: 'POST' });
|
||||
@@ -181,9 +225,14 @@ export default function EditorLayout() {
|
||||
if (!container?.Id) return;
|
||||
try {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`);
|
||||
wsMap[container.Id] = ws;
|
||||
ws.onopen = () => ws.send(JSON.stringify({ action: 'streamStats', containerId: container.Id }));
|
||||
ws.onopen = () => ws.send(JSON.stringify({
|
||||
action: 'streamStats',
|
||||
containerId: container.Id,
|
||||
nodeId: activeNodeId || undefined
|
||||
}));
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
@@ -567,6 +616,16 @@ export default function EditorLayout() {
|
||||
setSelectedContainer(null);
|
||||
};
|
||||
|
||||
const openLogViewer = (containerId: string, containerName: string) => {
|
||||
setLogContainer({ id: containerId, name: containerName });
|
||||
setLogViewerOpen(true);
|
||||
};
|
||||
|
||||
const closeLogViewer = () => {
|
||||
setLogViewerOpen(false);
|
||||
setLogContainer(null);
|
||||
};
|
||||
|
||||
// Safe container list with fallback
|
||||
const safeContainers = containers || [];
|
||||
// Safe content strings with fallback
|
||||
@@ -629,6 +688,38 @@ export default function EditorLayout() {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Node Switcher */}
|
||||
{nodes.length > 1 && (
|
||||
<div className="px-4 pt-2 pb-0">
|
||||
<Select
|
||||
value={activeNode?.id?.toString() || ''}
|
||||
onValueChange={(val) => {
|
||||
const node = nodes.find(n => n.id === parseInt(val));
|
||||
if (node) setActiveNode(node);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select node" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.map(node => (
|
||||
<SelectItem key={node.id} value={node.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-green-500' :
|
||||
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
{node.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Stack Button */}
|
||||
<div className="p-4">
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
@@ -693,6 +784,13 @@ export default function EditorLayout() {
|
||||
/>
|
||||
<span className="flex-1 truncate">{getDisplayName(file)}</span>
|
||||
|
||||
{stackUpdates[file] && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0"
|
||||
title="Update available"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -720,7 +818,22 @@ export default function EditorLayout() {
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top Header Bar */}
|
||||
<div className="h-16 flex items-center justify-end px-6 border-b border-border gap-4">
|
||||
<div className="h-16 flex items-center justify-between px-6 border-b border-border gap-4">
|
||||
{/* Node Context Pill — visible only when a remote node is active */}
|
||||
<div className="flex-shrink-0">
|
||||
{activeNode?.type === 'remote' ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400 animate-pulse shrink-0" />
|
||||
{activeNode.name}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/50 border border-border text-muted-foreground text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||
{activeNode?.name ?? 'Local'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Home Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -765,6 +878,28 @@ export default function EditorLayout() {
|
||||
<HardDrive className="w-4 h-4 mr-2" />
|
||||
Resources
|
||||
</Button>
|
||||
{/* App Store Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('templates')}
|
||||
title="App Store"
|
||||
>
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
App Store
|
||||
</Button>
|
||||
{/* Global Observability Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('global-observability')}
|
||||
title="Global Logs"
|
||||
>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
|
||||
{/* Settings Modal Toggle */}
|
||||
<Button
|
||||
@@ -844,11 +979,14 @@ export default function EditorLayout() {
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>{/* end right-side buttons */}
|
||||
</div>
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{activeView === 'resources' ? (
|
||||
{activeView === 'templates' ? (
|
||||
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
|
||||
) : activeView === 'resources' ? (
|
||||
<ResourcesView />
|
||||
) : activeView === 'host-console' ? (
|
||||
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
|
||||
@@ -962,7 +1100,12 @@ export default function EditorLayout() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8"
|
||||
onClick={() => window.open(`http://${window.location.hostname}:${mainPort}`, '_blank')}
|
||||
onClick={() => {
|
||||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||||
? new URL(activeNode.api_url).hostname
|
||||
: window.location.hostname;
|
||||
window.open(`http://${host}:${mainPort}`, '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -971,6 +1114,22 @@ export default function EditorLayout() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="rounded-lg h-8 w-8"
|
||||
onClick={() => openLogViewer(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
|
||||
disabled={container?.State !== 'running'}
|
||||
>
|
||||
<ScrollText className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Live Logs</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1100,6 +1259,8 @@ export default function EditorLayout() {
|
||||
</Card>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
) : activeView === 'global-observability' ? (
|
||||
<GlobalObservabilityView />
|
||||
) : (
|
||||
<HomeDashboard />
|
||||
)}
|
||||
@@ -1132,6 +1293,16 @@ export default function EditorLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* LogViewer Modal */}
|
||||
{logContainer && (
|
||||
<LogViewer
|
||||
isOpen={logViewerOpen}
|
||||
onClose={closeLogViewer}
|
||||
containerId={logContainer.id}
|
||||
containerName={logContainer.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
|
||||
interface LogEntry {
|
||||
stackName: string;
|
||||
containerName: string;
|
||||
source: string;
|
||||
level: string;
|
||||
message: string;
|
||||
timestampMs: number;
|
||||
}
|
||||
|
||||
export function GlobalObservabilityView() {
|
||||
const { activeNode } = useNodes();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [allStacks, setAllStacks] = useState<string[]>([]);
|
||||
|
||||
// Settings state
|
||||
const [devMode, setDevMode] = useState(false);
|
||||
const [pollRate, setPollRate] = useState(5);
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStacks, setSelectedStacks] = useState<string[]>([]);
|
||||
const [streamFilter, setStreamFilter] = useState<'ALL' | 'STDOUT' | 'STDERR'>('ALL');
|
||||
const [clearedAt, setClearedAt] = useState<number>(0);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
|
||||
|
||||
// SSE throttle buffer
|
||||
const bufferRef = useRef<LogEntry[]>([]);
|
||||
|
||||
// Fetch settings on mount
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/settings');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDevMode(data.developer_mode === '1');
|
||||
setPollRate(parseInt(data.global_logs_refresh || '5', 10));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settings:', e);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
// Fetch definitive stack list from the filesystem, independent of log data
|
||||
useEffect(() => {
|
||||
const fetchStacks = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stacks');
|
||||
if (res.ok) {
|
||||
const stacks: string[] = await res.json();
|
||||
setAllStacks(stacks.sort());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stacks:', err);
|
||||
}
|
||||
};
|
||||
fetchStacks();
|
||||
}, []);
|
||||
|
||||
// Data fetching: Polling (standard) vs SSE (dev mode)
|
||||
useEffect(() => {
|
||||
if (devMode) {
|
||||
// SSE mode
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const eventSource = new EventSource(`/api/logs/global/stream?nodeId=${activeNodeId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const entry: LogEntry = JSON.parse(event.data);
|
||||
bufferRef.current.push(entry);
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// SSE will auto-reconnect, no action needed
|
||||
};
|
||||
|
||||
// 500ms throttle: flush buffer into React state
|
||||
const flushInterval = setInterval(() => {
|
||||
if (bufferRef.current.length > 0) {
|
||||
const batch = bufferRef.current.splice(0);
|
||||
setLogs(prev => {
|
||||
const merged = [...prev, ...batch];
|
||||
merged.sort((a, b) => a.timestampMs - b.timestampMs);
|
||||
return merged.slice(-10000);
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
clearInterval(flushInterval);
|
||||
bufferRef.current = [];
|
||||
};
|
||||
} else {
|
||||
// Standard polling mode
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const logsRes = await apiFetch('/logs/global');
|
||||
if (logsRes.ok) {
|
||||
setLogs(await logsRes.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch global logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, pollRate * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [devMode, pollRate]);
|
||||
|
||||
const handleStackToggle = (stack: string) => {
|
||||
setSelectedStacks(prev =>
|
||||
prev.includes(stack) ? prev.filter(s => s !== stack) : [...prev, stack]
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
setClearedAt(Date.now());
|
||||
};
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter(log => {
|
||||
if (log.timestampMs < clearedAt) return false;
|
||||
if (selectedStacks.length > 0 && !selectedStacks.includes(log.stackName)) return false;
|
||||
if (streamFilter !== 'ALL' && log.source !== streamFilter) return false;
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return log.message.toLowerCase().includes(query) ||
|
||||
log.containerName.toLowerCase().includes(query) ||
|
||||
log.stackName.toLowerCase().includes(query);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoScrollEnabled) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [filteredLogs, isAutoScrollEnabled]);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50;
|
||||
setIsAutoScrollEnabled(isAtBottom);
|
||||
}, []);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (filteredLogs.length === 0) return;
|
||||
const blob = new Blob([filteredLogs.map(l => `[${new Date(l.timestampMs).toLocaleTimeString([], { hour12: true })}] [${l.containerName}] ${l.level}: ${l.message}`).join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `sencho-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
|
||||
{/* Node Context Indicator */}
|
||||
{activeNode?.type === 'remote' && (
|
||||
<div className="absolute top-2 left-4 z-10 flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
{activeNode.name}
|
||||
</div>
|
||||
)}
|
||||
{/* Floating Action Bar */}
|
||||
<div className="absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 opacity-0 group-hover:opacity-100 focus-within:opacity-100 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md p-1 pr-1">
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8 text-sm w-48 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 text-sm">
|
||||
<Filter className="w-3.5 h-3.5 mr-2" />
|
||||
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{allStacks.map(stack => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={stack}
|
||||
checked={selectedStacks.includes(stack)}
|
||||
onCheckedChange={() => handleStackToggle(stack)}
|
||||
>
|
||||
{stack}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{allStacks.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">No stacks found</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Select value={streamFilter} onValueChange={(val: any) => setStreamFilter(val)}>
|
||||
<SelectTrigger className="w-[110px] h-8 text-sm">
|
||||
<SelectValue placeholder="Stream" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Streams</SelectItem>
|
||||
<SelectItem value="STDOUT">STDOUT</SelectItem>
|
||||
<SelectItem value="STDERR">STDERR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
{devMode && (
|
||||
<div className="flex items-center px-2 text-xs text-emerald-400 font-mono animate-pulse">
|
||||
● LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && logs.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
|
||||
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
|
||||
{filteredLogs.length > 0 ? (
|
||||
<>
|
||||
{filteredLogs.map((log, idx) => (
|
||||
<div key={idx} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
||||
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
|
||||
<span className={`mr-2 font-bold ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-green-500'}`}>{log.level}:</span>
|
||||
<span className={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-gray-500 italic p-4 text-center mt-10">
|
||||
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
|
||||
@@ -51,6 +54,7 @@ const formatBytes = (bytes: number) => {
|
||||
};
|
||||
|
||||
export default function HomeDashboard() {
|
||||
const { activeNode } = useNodes();
|
||||
const [dockerRunInput, setDockerRunInput] = useState('');
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [convertedYaml, setConvertedYaml] = useState('');
|
||||
@@ -58,12 +62,15 @@ export default function HomeDashboard() {
|
||||
const [newStackName, setNewStackName] = useState('');
|
||||
const [stats, setStats] = useState<Stats>({ active: 0, exited: 0, total: 0, inactive: 0 });
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
|
||||
// Fetch stats from backend
|
||||
// Fetch container stats - re-runs when active node changes so stale data is cleared immediately
|
||||
useEffect(() => {
|
||||
setStats({ active: 0, exited: 0, total: 0, inactive: 0 });
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stats');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
@@ -73,15 +80,15 @@ export default function HomeDashboard() {
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch system stats from backend
|
||||
// Fetch system stats (CPU/RAM/Disk/Network) - 5s polling, re-runs on node switch
|
||||
useEffect(() => {
|
||||
setSystemStats(null);
|
||||
const fetchSystemStats = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/system/stats');
|
||||
const data = await res.json();
|
||||
setSystemStats(data);
|
||||
if (res.ok) setSystemStats(await res.json());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch system stats:', error);
|
||||
}
|
||||
@@ -89,7 +96,54 @@ export default function HomeDashboard() {
|
||||
fetchSystemStats();
|
||||
const interval = setInterval(fetchSystemStats, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch historical metrics - intentionally slow 60s poll to prevent OOM.
|
||||
// The backend now returns 1-minute buckets (down from raw 5s points), so
|
||||
// re-fetching more often than 60s would return the same data anyway.
|
||||
useEffect(() => {
|
||||
setMetrics([]);
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/metrics/historical');
|
||||
if (res.ok) setMetrics(await res.json());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch historical metrics:', error);
|
||||
}
|
||||
};
|
||||
fetchMetrics();
|
||||
const interval = setInterval(fetchMetrics, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const buckets: Record<string, { time: string; timestamp: number; cpu: number; ram: number }> = {};
|
||||
const cores = systemStats?.cpu.cores || 1;
|
||||
|
||||
metrics.forEach(m => {
|
||||
const date = new Date(m.timestamp);
|
||||
date.setSeconds(0, 0);
|
||||
const key = date.getTime() + '';
|
||||
|
||||
if (!buckets[key]) {
|
||||
buckets[key] = {
|
||||
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
timestamp: date.getTime(),
|
||||
cpu: 0,
|
||||
ram: 0
|
||||
};
|
||||
}
|
||||
buckets[key].cpu += (m.cpu_percent / cores);
|
||||
buckets[key].ram += (m.memory_mb / 1024);
|
||||
});
|
||||
|
||||
return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [metrics, systemStats]);
|
||||
|
||||
const chartConfig = {
|
||||
cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' },
|
||||
ram: { label: 'RAM Usage (GB)', color: 'var(--chart-2)' },
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!dockerRunInput.trim()) return;
|
||||
@@ -242,6 +296,63 @@ export default function HomeDashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Historical Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="rounded-xl border-muted bg-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
<span>Normalized CPU Usage</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">Total CPU percentage over total host cores.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{chartData.length > 0 ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-full">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
|
||||
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, 100]} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No historical CPU data.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-xl border-muted bg-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-muted-foreground">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
<span>Normalized RAM Usage</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">Total RAM allocation in GB.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{chartData.length > 0 ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-full">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="time" minTickGap={30} tickMargin={8} />
|
||||
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area type="monotone" dataKey="ram" stroke="var(--color-ram)" fill="var(--color-ram)" fillOpacity={0.4} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No historical RAM data.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Docker Run Converter */}
|
||||
<Card className="rounded-xl border-muted bg-card">
|
||||
<CardHeader>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Terminal as TerminalIcon, X } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface HostConsoleProps {
|
||||
stackName?: string | null;
|
||||
@@ -11,6 +12,7 @@ interface HostConsoleProps {
|
||||
}
|
||||
|
||||
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -65,7 +67,11 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
});
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${stackName ? `?stack=${encodeURIComponent(stackName)}` : ''}`;
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : '';
|
||||
const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : '';
|
||||
const queryString = [nodeParam, stackParam].filter(Boolean).join('&');
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
@@ -153,6 +159,11 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<TerminalIcon className="w-4 h-4 text-muted-foreground" />
|
||||
<span>Host Console</span>
|
||||
{activeNode && (
|
||||
<span className="text-muted-foreground font-normal text-sm">
|
||||
— {activeNode.name}
|
||||
</span>
|
||||
)}
|
||||
{stackName && (
|
||||
<span className="text-muted-foreground font-normal text-sm">
|
||||
({stackName})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Loader2, Terminal } from "lucide-react";
|
||||
|
||||
interface LogViewerProps {
|
||||
containerId: string | null;
|
||||
containerName: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LogViewer({ containerId, containerName, isOpen, onClose }: LogViewerProps) {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !containerId) return;
|
||||
|
||||
setLogs([]);
|
||||
setIsConnected(false);
|
||||
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
const eventSource = new EventSource(`/api/containers/${containerId}/logs?nodeId=${activeNodeId}`);
|
||||
|
||||
eventSource.onopen = () => setIsConnected(true);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const newLog = JSON.parse(event.data);
|
||||
setLogs(prev => {
|
||||
const updated = [...prev, newLog];
|
||||
return updated.length > 1000 ? updated.slice(updated.length - 1000) : updated;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to parse log line", err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setIsConnected(false);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [isOpen, containerId]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl h-[80vh] flex flex-col bg-background border-border">
|
||||
<DialogHeader className="flex flex-row items-center gap-2 pb-2 border-b">
|
||||
<Terminal className="w-5 h-5" />
|
||||
<DialogTitle className="flex-1 text-left font-mono text-sm">
|
||||
{containerName} {isConnected ? <span className="text-green-500 text-xs ml-2">(connected)</span> : <Loader2 className="inline w-3 h-3 ml-2 animate-spin" />}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 w-full bg-[#0c0c0c] text-green-400 p-4 rounded-md overflow-y-auto font-mono text-xs mt-2"
|
||||
>
|
||||
{logs.length === 0 && !isConnected ? (
|
||||
<div className="text-muted-foreground">Connecting to container stream...</div>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="break-all whitespace-pre-wrap leading-tight mb-1">
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import { useState } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check } from 'lucide-react';
|
||||
|
||||
interface NodeFormData {
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
api_url: string;
|
||||
api_token: string;
|
||||
compose_dir: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const defaultFormData: NodeFormData = {
|
||||
name: '',
|
||||
type: 'remote',
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
export function NodeManager() {
|
||||
const { nodes, refreshNodes } = useNodes();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [formData, setFormData] = useState<NodeFormData>(defaultFormData);
|
||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||
const [deletingNode, setDeletingNode] = useState<Node | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null);
|
||||
|
||||
// Node token generation state
|
||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||
const [generatingToken, setGeneratingToken] = useState(false);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || 'Failed to create node');
|
||||
}
|
||||
const { id: newNodeId } = await res.json();
|
||||
toast.success(`Node "${formData.name}" created successfully`);
|
||||
setCreateOpen(false);
|
||||
setFormData(defaultFormData);
|
||||
|
||||
// Auto-test the new node connection immediately
|
||||
if (newNodeId && formData.type === 'remote') {
|
||||
setTesting(newNodeId);
|
||||
try {
|
||||
const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' });
|
||||
const testData = await testRes.json();
|
||||
if (testData.success) {
|
||||
toast.success(`Connected to "${formData.name}" successfully`);
|
||||
setTestResult({ nodeId: newNodeId, info: testData.info });
|
||||
} else {
|
||||
toast.warning(`Node saved, but connection test failed: ${testData.error}`);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to create node');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!editingNodeId) return;
|
||||
try {
|
||||
const res = await apiFetch(`/nodes/${editingNodeId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || 'Failed to update node');
|
||||
}
|
||||
toast.success(`Node "${formData.name}" updated`);
|
||||
setEditOpen(false);
|
||||
setEditingNodeId(null);
|
||||
setFormData(defaultFormData);
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update node');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (node: Node) => {
|
||||
setFormData({
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
api_url: node.api_url || '',
|
||||
api_token: node.api_token || '',
|
||||
compose_dir: node.compose_dir,
|
||||
is_default: node.is_default,
|
||||
});
|
||||
setEditingNodeId(node.id);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingNode) return;
|
||||
try {
|
||||
const res = await apiFetch(`/nodes/${deletingNode.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || 'Failed to delete node');
|
||||
}
|
||||
toast.success(`Node "${deletingNode.name}" deleted`);
|
||||
setDeleteOpen(false);
|
||||
setDeletingNode(null);
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete node');
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async (node: Node) => {
|
||||
setTesting(node.id);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await apiFetch(`/nodes/${node.id}/test`, { method: 'POST' });
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success(`Connected to "${node.name}" successfully`);
|
||||
setTestResult({ nodeId: node.id, info: result.info });
|
||||
} else {
|
||||
toast.error(`Failed to connect: ${result.error}`);
|
||||
}
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Connection test failed');
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const generateNodeToken = async () => {
|
||||
setGeneratingToken(true);
|
||||
setGeneratedToken(null);
|
||||
try {
|
||||
const res = await apiFetch('/auth/generate-node-token', { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to generate token');
|
||||
const { token } = await res.json();
|
||||
setGeneratedToken(token);
|
||||
toast.success('Node token generated');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to generate token');
|
||||
} finally {
|
||||
setGeneratingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToken = async () => {
|
||||
if (!generatedToken) return;
|
||||
try {
|
||||
// Clipboard API requires a secure context (HTTPS or localhost)
|
||||
await navigator.clipboard.writeText(generatedToken);
|
||||
setTokenCopied(true);
|
||||
toast.success('Token copied to clipboard');
|
||||
setTimeout(() => setTokenCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for HTTP / non-localhost deployments where Clipboard API is unavailable
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = generatedToken;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setTokenCopied(true);
|
||||
toast.success('Token copied to clipboard');
|
||||
setTimeout(() => setTokenCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Could not copy automatically - please select and copy the token manually.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return <Badge variant="default" className="bg-green-600 text-white gap-1"><Wifi className="w-3 h-3" /> Online</Badge>;
|
||||
case 'offline':
|
||||
return <Badge variant="destructive" className="gap-1"><WifiOff className="w-3 h-3" /> Offline</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary" className="gap-1">Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeIcon = (type: string) => {
|
||||
return type === 'local'
|
||||
? <Monitor className="w-4 h-4 text-muted-foreground" />
|
||||
: <Globe className="w-4 h-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
const renderFormFields = () => (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-name">Name</Label>
|
||||
<Input
|
||||
id="node-name"
|
||||
placeholder="e.g., Production VPS"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-type">Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(val) => setFormData({ ...formData, type: val as 'local' | 'remote', api_url: '', api_token: '' })}
|
||||
>
|
||||
<SelectTrigger id="node-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local — Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote — another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.type === 'remote' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-api-url">Sencho API URL</Label>
|
||||
<Input
|
||||
id="node-api-url"
|
||||
placeholder="http://192.168.1.50:3000"
|
||||
value={formData.api_url}
|
||||
onChange={(e) => setFormData({ ...formData, api_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The base URL of the Sencho instance running on the remote machine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-api-token">API Token</Label>
|
||||
<Input
|
||||
id="node-api-token"
|
||||
type="password"
|
||||
placeholder="Paste token from remote Sencho → Settings → Nodes → Generate Token"
|
||||
value={formData.api_token}
|
||||
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate this token on the <strong>remote</strong> Sencho instance using the "Generate Node Token" button in its Settings → Nodes panel.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-compose-dir">Compose Directory</Label>
|
||||
<Input
|
||||
id="node-compose-dir"
|
||||
placeholder="/app/compose"
|
||||
value={formData.compose_dir}
|
||||
onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The root directory where compose stack folders live on this node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Server className="w-5 h-5" />
|
||||
Nodes
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage connections to local and remote Sencho instances
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
// Reset form to defaults every time the dialog opens
|
||||
if (open) setFormData(defaultFormData);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-1 shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Node
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>Add {formData.type === 'local' ? 'Local' : 'Remote'} Node</DialogTitle>
|
||||
</DialogHeader>
|
||||
{renderFormFields()}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!formData.name || (formData.type === 'remote' && (!formData.api_url || !formData.api_token))}
|
||||
>
|
||||
Add Node
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Generate Node Token - for use on THIS instance as a remote target */}
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-blue-500" />
|
||||
Generate Node Token
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Create a long-lived token that allows another Sencho instance to use <strong>this</strong> instance as a remote node. Copy it and paste it into the other Sencho instance's "Add Node" form.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={generateNodeToken}
|
||||
disabled={generatingToken}
|
||||
className="shrink-0"
|
||||
>
|
||||
{generatingToken ? 'Generating...' : 'Generate Token'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{generatedToken && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
|
||||
<code className="flex-1 text-xs font-mono truncate text-muted-foreground">{generatedToken}</code>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={copyToken}>
|
||||
{tokenCopied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nodes Table */}
|
||||
<div className="rounded-md border overflow-x-auto w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{nodes.map((node) => (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
{node.is_default && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Default Node</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{getNodeIcon(node.type)}
|
||||
{node.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{node.type === 'local' ? 'Local' : 'Remote'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm font-mono">
|
||||
{node.type === 'local' ? 'docker.sock' : (node.api_url || '-')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(node.status)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => testConnection(node)}
|
||||
disabled={testing === node.id}
|
||||
>
|
||||
<Wifi className={`w-4 h-4 ${testing === node.id ? 'animate-pulse' : ''}`} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Test Connection</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEditDialog(node)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit Node</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{!node.is_default && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => { setDeletingNode(node); setDeleteOpen(true); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete Node</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Connection Test Result */}
|
||||
{testResult && (
|
||||
<div className="rounded-md border p-4 bg-muted/30 space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-green-500" />
|
||||
Connection Details - {nodes.find(n => n.id === testResult.nodeId)?.name}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||||
<div><span className="text-muted-foreground">Instance:</span> {testResult.info.serverVersion}</div>
|
||||
<div><span className="text-muted-foreground">OS:</span> {testResult.info.os}</div>
|
||||
<div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</div>
|
||||
<div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers}</div>
|
||||
<div><span className="text-muted-foreground">Images:</span> {testResult.info.images}</div>
|
||||
<div><span className="text-muted-foreground">CPUs:</span> {testResult.info.cpus}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>Edit Node</DialogTitle>
|
||||
</DialogHeader>
|
||||
{renderFormFields()}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setEditOpen(false); setEditingNodeId(null); }}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
disabled={!formData.name || (formData.type === 'remote' && !formData.api_url)}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Node</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove <strong>{deletingNode?.name}</strong>? This will only remove the node from Sencho - it will not affect the remote instance or any running containers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, PieChart as ChartIcon } from 'lucide-react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
|
||||
@@ -42,6 +43,7 @@ interface OrphanContainer {
|
||||
}
|
||||
|
||||
export default function ResourcesView() {
|
||||
const { activeNode } = useNodes();
|
||||
const [usage, setUsage] = useState<UsageData | null>(null);
|
||||
const [images, setImages] = useState<DockerImage[]>([]);
|
||||
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
|
||||
@@ -188,6 +190,9 @@ export default function ResourcesView() {
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Resources Hub</h1>
|
||||
{activeNode?.type === 'remote' && (
|
||||
<span className="text-sm font-normal text-muted-foreground">— {activeNode.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
@@ -11,7 +11,10 @@ import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Shield, Activity, Bell, Palette, Moon, Sun } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server } from 'lucide-react';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface Agent {
|
||||
type: 'discord' | 'slack' | 'webhook';
|
||||
@@ -27,7 +30,16 @@ interface SettingsModalProps {
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance'>('account');
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes'>('account');
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
useEffect(() => {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes')) {
|
||||
setActiveSection('system');
|
||||
}
|
||||
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auth State
|
||||
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
@@ -45,7 +57,9 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
host_ram_limit: '90',
|
||||
host_disk_limit: '90',
|
||||
global_crash: '1',
|
||||
docker_janitor_gb: '5'
|
||||
docker_janitor_gb: '5',
|
||||
global_logs_refresh: '5',
|
||||
developer_mode: '0'
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -218,19 +232,25 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[800px] h-[600px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
|
||||
<DialogContent className="sm:max-w-[900px] h-[650px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
|
||||
{/* Sidebar */}
|
||||
<div className="w-[200px] bg-muted/20 border-r border-border flex flex-col p-4 shrink-0">
|
||||
<div className="font-semibold text-lg mb-6 text-foreground tracking-tight">Settings Hub</div>
|
||||
<div className="font-semibold text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
|
||||
{isRemote && (
|
||||
<div className="text-xs text-muted-foreground mb-5 truncate">{activeNode!.name}</div>
|
||||
)}
|
||||
{!isRemote && <div className="mb-5" />}
|
||||
<nav className="space-y-1.5 flex flex-col">
|
||||
<Button
|
||||
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('account')}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Account
|
||||
</Button>
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('account')}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Account
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={activeSection === 'system' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
@@ -239,22 +259,44 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
System Limits
|
||||
</Button>
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('notifications')}
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notifications
|
||||
</Button>
|
||||
)}
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('appearance')}
|
||||
>
|
||||
<Palette className="w-4 h-4 mr-2" />
|
||||
Appearance
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
|
||||
variant={activeSection === 'developer' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('notifications')}
|
||||
onClick={() => setActiveSection('developer')}
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notifications
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('appearance')}
|
||||
>
|
||||
<Palette className="w-4 h-4 mr-2" />
|
||||
Appearance
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
Developer
|
||||
</Button>
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'nodes' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('nodes')}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
Nodes
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -418,6 +460,59 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'developer' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Developer</h3>
|
||||
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and extended diagnostics.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="developer_mode"
|
||||
checked={settings.developer_mode === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('developer_mode', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4 border-t border-border">
|
||||
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>Standard Log Polling Rate</Label>
|
||||
<Select
|
||||
value={settings.global_logs_refresh}
|
||||
onValueChange={(val) => handleSettingChange('global_logs_refresh', val)}
|
||||
disabled={settings.developer_mode === '1'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[200px]">
|
||||
<SelectValue placeholder="Select rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 second</SelectItem>
|
||||
<SelectItem value="3">3 seconds</SelectItem>
|
||||
<SelectItem value="5">5 seconds</SelectItem>
|
||||
<SelectItem value="10">10 seconds</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.developer_mode === '1' && (
|
||||
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden by real-time streaming.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={saveSettings} disabled={isLoading}>Save Developer Settings</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'nodes' && (
|
||||
<NodeManager />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -125,11 +125,12 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
|
||||
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const cleanStackName = stackName?.replace(/\.(yml|yaml)$/, '');
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||||
|
||||
// If a stackName is provided, connect to the dedicated logs WebSocket
|
||||
// Otherwise, fall back to the generic terminal WebSocket
|
||||
const wsUrl = cleanStackName
|
||||
? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs`
|
||||
? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`
|
||||
: `${wsProtocol}//${window.location.host}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -47,6 +47,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
const handleUnauthorized = () => setAppStatus('notAuthenticated');
|
||||
window.addEventListener('sencho-unauthorized', handleUnauthorized);
|
||||
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
compose_dir: string;
|
||||
is_default: boolean;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
created_at: number;
|
||||
api_url?: string;
|
||||
api_token?: string;
|
||||
}
|
||||
|
||||
interface NodeContextType {
|
||||
nodes: Node[];
|
||||
activeNode: Node | null;
|
||||
setActiveNode: (node: Node) => void;
|
||||
refreshNodes: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const NodeContext = createContext<NodeContextType | undefined>(undefined);
|
||||
|
||||
export function NodeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [activeNode, setActiveNodeState] = useState<Node | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// Ref lets refreshNodes read current activeNode without being a dep (breaks infinite loop)
|
||||
const activeNodeRef = useRef<Node | null>(null);
|
||||
activeNodeRef.current = activeNode;
|
||||
|
||||
const refreshNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNodes(data);
|
||||
|
||||
const currentActive = activeNodeRef.current;
|
||||
if (!currentActive) {
|
||||
// On initial load, restore from localStorage before falling back to default.
|
||||
// This keeps the UI dropdown in sync with the node ID that apiFetch is already
|
||||
// injecting via x-node-id (read directly from localStorage on every request).
|
||||
const storedId = localStorage.getItem('sencho-active-node');
|
||||
const storedNode = storedId ? data.find((n: Node) => n.id === parseInt(storedId, 10)) : null;
|
||||
const nodeToActivate = storedNode ?? data.find((n: Node) => n.is_default) ?? data[0] ?? null;
|
||||
if (nodeToActivate) {
|
||||
setActiveNodeState(nodeToActivate);
|
||||
localStorage.setItem('sencho-active-node', String(nodeToActivate.id));
|
||||
}
|
||||
} else {
|
||||
const updatedActive = data.find((n: Node) => n.id === currentActive.id);
|
||||
if (updatedActive) {
|
||||
setActiveNodeState(updatedActive);
|
||||
localStorage.setItem('sencho-active-node', String(updatedActive.id));
|
||||
} else {
|
||||
const fallback = data.find((n: Node) => n.is_default) ?? data[0] ?? null;
|
||||
if (fallback) {
|
||||
setActiveNodeState(fallback);
|
||||
localStorage.setItem('sencho-active-node', String(fallback.id));
|
||||
} else {
|
||||
setActiveNodeState(null);
|
||||
localStorage.removeItem('sencho-active-node');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch nodes:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []); // stable - reads activeNode via ref, not closure capture
|
||||
|
||||
const setActiveNode = useCallback((node: Node) => {
|
||||
setActiveNodeState(node);
|
||||
localStorage.setItem('sencho-active-node', String(node.id));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshNodes();
|
||||
|
||||
const handleNodeNotFound = () => {
|
||||
console.warn('[NodeContext] Active node is unreachable or deleted. Forcing sync...');
|
||||
refreshNodes();
|
||||
};
|
||||
|
||||
window.addEventListener('node-not-found', handleNodeNotFound);
|
||||
return () => window.removeEventListener('node-not-found', handleNodeNotFound);
|
||||
}, [refreshNodes]);
|
||||
|
||||
return (
|
||||
<NodeContext.Provider value={{ nodes, activeNode, setActiveNode, refreshNodes, isLoading }}>
|
||||
{children}
|
||||
</NodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
const context = useContext(NodeContext);
|
||||
if (!context) {
|
||||
throw new Error('useNodes must be used within a NodeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -166,6 +166,10 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
@@ -175,6 +179,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Dark Mode Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
|
||||
+18
-2
@@ -5,10 +5,13 @@ export async function apiFetch(
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node');
|
||||
|
||||
const defaultOptions: RequestInit = {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(activeNodeId ? { 'x-node-id': activeNodeId } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
@@ -16,11 +19,24 @@ export async function apiFetch(
|
||||
const response = await fetch(url, { ...defaultOptions, ...options });
|
||||
|
||||
if (response.status === 401) {
|
||||
// Clear auth state and redirect to login
|
||||
window.location.href = '/';
|
||||
// Signal auth failure to AuthContext without a hard page reload
|
||||
window.dispatchEvent(new Event('sencho-unauthorized'));
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Intercept 404 Node Not Found responses and force context refresh
|
||||
if (response.status === 404) {
|
||||
try {
|
||||
const clone = response.clone();
|
||||
const errData = await clone.json();
|
||||
if (errData.error && errData.error.includes('not found') && errData.error.includes('Node')) {
|
||||
window.dispatchEvent(new Event('node-not-found'));
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors, caller handles standard 404s
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,18 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user