mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: Distributed API UI & metrics polish + DEP0060 suppression
Add Node modal — type selector & state reset:
- Restored a Local/Remote <Select> dropdown in renderFormFields so users can
explicitly choose the node type instead of it defaulting silently to 'remote'.
- Switching type clears api_url and api_token so no stale remote credentials
carry over if a user switches from Remote to Local mid-form.
- Replaced the static "Add Remote Node" title with a dynamic one that reflects
the currently selected type ("Add Local Node" / "Add Remote Node").
- onOpenChange now resets formData to defaultFormData whenever the dialog
opens, preventing stale values from a previous session leaking in.
Remote connection details — real metrics:
- testRemoteConnection previously returned hard-coded '-' for containers,
images, and cpus after a successful auth/check ping.
- Now fires three parallel requests (Promise.allSettled) after auth passes:
/api/stats → containers total + running count
/api/system/stats → cpu.cores
/api/system/images → image list length
- Each field falls back to '-' gracefully if an endpoint is unavailable,
so a slow or older remote instance never breaks the connection test.
DEP0060 util._extend suppression:
- http-proxy@1.18.1 calls util._extend when createProxyServer() is first
invoked at runtime (NOT at import time). A process.emitWarning override
placed before the proxy instantiations intercepts only DEP0060 without
suppressing any other warnings. No package version changes needed.
Also includes linter/formatter normalisation across multiple files.
This commit is contained in:
+12
-9
@@ -5,23 +5,26 @@ 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:** 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:** 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:** 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:** 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:** 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.
|
||||
- **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.
|
||||
|
||||
+20
-8
@@ -29,6 +29,18 @@ import fs, { promises as fsPromises } from 'fs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
||||
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
||||
// first invoked (NOT at import time), so intercepting process.emitWarning here —
|
||||
// before the proxy instances are created below — fully prevents it.
|
||||
// http-proxy has no compatible update; this suppression is intentional and safe.
|
||||
const _origEmitWarning = process.emitWarning.bind(process);
|
||||
(process as any).emitWarning = (warning: any, ...args: any[]) => {
|
||||
const code = typeof args[0] === 'object' ? args[0]?.code : args[1];
|
||||
if (code === 'DEP0060') return;
|
||||
_origEmitWarning(warning, ...args);
|
||||
};
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
@@ -98,7 +110,7 @@ declare global {
|
||||
const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true });
|
||||
wsProxyServer.on('error', (err, _req, socket: any) => {
|
||||
console.error('[WS Proxy] Error:', err.message);
|
||||
try { socket?.destroy(); } catch {}
|
||||
try { socket?.destroy(); } catch { }
|
||||
});
|
||||
|
||||
// Authentication Middleware
|
||||
@@ -288,7 +300,7 @@ app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, r
|
||||
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
|
||||
return;
|
||||
}
|
||||
// No expiry — this token is managed by the admin who pastes it into the main dashboard
|
||||
// No expiry - this token is managed by the admin who pastes it into the main dashboard
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret);
|
||||
res.json({ token });
|
||||
} catch (error: any) {
|
||||
@@ -305,7 +317,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
authMiddleware(req, res, next);
|
||||
});
|
||||
|
||||
// Remote Node HTTP Proxy — single global instance.
|
||||
// Remote Node HTTP Proxy - single global instance.
|
||||
// Previously, createProxyMiddleware was called inside the request handler on every API
|
||||
// call, spawning a new proxy instance (and http-proxy server) each time. This caused:
|
||||
// - MaxListenersExceededWarning: repeated 'close' listeners added to [Server]
|
||||
@@ -313,7 +325,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Fix: create ONE instance at startup; use the router option to resolve the
|
||||
// target URL dynamically per request without constructing new listeners.
|
||||
const remoteNodeProxy = createProxyMiddleware<Request, Response>({
|
||||
target: 'http://localhost:0', // placeholder — overridden per-request by router
|
||||
target: 'http://localhost:0', // placeholder - overridden per-request by router
|
||||
changeOrigin: true,
|
||||
router: (req) => {
|
||||
const node = NodeRegistry.getInstance().getNode(req.nodeId);
|
||||
@@ -322,7 +334,7 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
|
||||
on: {
|
||||
proxyReq: (proxyReq, req) => {
|
||||
const node = NodeRegistry.getInstance().getNode(req.nodeId);
|
||||
// Remote Sencho sees itself as local — strip node context and inject bearer auth
|
||||
// Remote Sencho sees itself as local - strip node context and inject bearer auth
|
||||
proxyReq.removeHeader('x-node-id');
|
||||
if (node?.api_token) {
|
||||
proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`);
|
||||
@@ -406,7 +418,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const node = NodeRegistry.getInstance().getNode(nodeId);
|
||||
|
||||
// Remote Node WebSocket Proxy — forward the entire WS connection to the remote Sencho instance
|
||||
// Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance
|
||||
if (node && node.type === 'remote' && node.api_url && node.api_token) {
|
||||
const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
|
||||
req.headers['authorization'] = `Bearer ${node.api_token}`;
|
||||
@@ -495,7 +507,7 @@ wss.on('connection', (ws) => {
|
||||
dockerController.execContainer(data.containerId, ws);
|
||||
}
|
||||
} catch (error) {
|
||||
// Malformed JSON — ignore silently
|
||||
// Malformed JSON - ignore silently
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1117,7 +1129,7 @@ app.get('/api/system/stats', async (req: Request, res: Response) => {
|
||||
const txSec = Math.max(0, globalDockerNetwork.txSec);
|
||||
|
||||
if (node && node.type === 'remote') {
|
||||
// Remote node: use Docker daemon info for CPU/RAM — disk is not available via Docker API
|
||||
// Remote node: use Docker daemon info for CPU/RAM - disk is not available via Docker API
|
||||
const docker = NodeRegistry.getInstance().getDocker(nodeId);
|
||||
const info = await docker.info();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* ComposeService — local docker compose CLI execution.
|
||||
* 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.
|
||||
@@ -160,7 +160,7 @@ export class ComposeService {
|
||||
let localProcesses: ReturnType<typeof spawn>[] = [];
|
||||
|
||||
const onWsClose = () => {
|
||||
localProcesses.forEach(cp => { try { cp.kill(); } catch {} });
|
||||
localProcesses.forEach(cp => { try { cp.kill(); } catch { } });
|
||||
};
|
||||
|
||||
ws.on('close', onWsClose);
|
||||
|
||||
@@ -350,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;
|
||||
@@ -364,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;
|
||||
@@ -445,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) {
|
||||
@@ -516,7 +516,7 @@ class DockerController {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON or malformed message — ignore
|
||||
// Non-JSON or malformed message - ignore
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { promises as fsPromises } from 'fs';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* FileSystemService — local-only file I/O for compose stack management.
|
||||
* 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
|
||||
|
||||
@@ -121,7 +121,7 @@ export class MonitorService {
|
||||
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
|
||||
// Remote nodes run their own MonitorService locally - skip direct Docker access
|
||||
if (node.type === 'remote') continue;
|
||||
try {
|
||||
const docker = DockerController.getInstance(node.id);
|
||||
@@ -208,7 +208,7 @@ export class MonitorService {
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node.id) continue;
|
||||
// Remote nodes are self-monitoring — skip direct Docker access
|
||||
// Remote nodes are self-monitoring - skip direct Docker access
|
||||
if (node.type === 'remote') continue;
|
||||
try {
|
||||
const docker = DockerController.getInstance(node.id);
|
||||
@@ -329,7 +329,7 @@ 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) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export class NodeRegistry {
|
||||
private static instance: NodeRegistry;
|
||||
private connections: Map<number, Docker> = new Map();
|
||||
|
||||
private constructor() {}
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): NodeRegistry {
|
||||
if (!NodeRegistry.instance) {
|
||||
@@ -25,7 +25,7 @@ export class NodeRegistry {
|
||||
|
||||
/**
|
||||
* Get a Docker client for a LOCAL node only.
|
||||
* Remote nodes are never accessed via Dockerode — use the HTTP proxy instead.
|
||||
* Remote nodes are never accessed via Dockerode - use the HTTP proxy instead.
|
||||
*/
|
||||
public getDocker(nodeId: number): Docker {
|
||||
if (this.connections.has(nodeId)) {
|
||||
@@ -42,7 +42,7 @@ export class NodeRegistry {
|
||||
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.`
|
||||
`Its Docker daemon is not directly accessible - all requests are proxied via HTTP.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,36 +153,46 @@ export class NodeRegistry {
|
||||
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 {
|
||||
const baseUrl = node.api_url.replace(/\/$/, '');
|
||||
const response = await axios.get(`${baseUrl}/api/auth/check`, {
|
||||
headers: { Authorization: `Bearer ${node.api_token}` },
|
||||
timeout: 8000,
|
||||
});
|
||||
// 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}`);
|
||||
|
||||
if (response.status === 200) {
|
||||
db.updateNodeStatus(node.id, 'online');
|
||||
return {
|
||||
success: true,
|
||||
info: {
|
||||
name: node.name,
|
||||
serverVersion: 'Remote Sencho',
|
||||
os: 'Remote',
|
||||
architecture: 'Remote',
|
||||
containers: '—',
|
||||
containersRunning: '—',
|
||||
images: '—',
|
||||
memTotal: 0,
|
||||
cpus: '—',
|
||||
}
|
||||
};
|
||||
}
|
||||
db.updateNodeStatus(node.id, 'online');
|
||||
|
||||
throw new Error(`Unexpected status ${response.status}`);
|
||||
// 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.'
|
||||
? 'Authentication failed - check the API token.'
|
||||
: (error.message || 'Connection failed');
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -160,7 +160,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 +224,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%' }}
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Notification polling — independent of active node, runs once on mount
|
||||
// Notification polling - independent of active node, runs once on mount
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
const notificationInterval = setInterval(fetchNotifications, 5000);
|
||||
@@ -208,10 +208,10 @@ export default function EditorLayout() {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
|
||||
wsMap[container.Id] = ws;
|
||||
ws.onopen = () => ws.send(JSON.stringify({
|
||||
action: 'streamStats',
|
||||
containerId: container.Id,
|
||||
nodeId: localStorage.getItem('sencho-active-node') || undefined
|
||||
ws.onopen = () => ws.send(JSON.stringify({
|
||||
action: 'streamStats',
|
||||
containerId: container.Id,
|
||||
nodeId: localStorage.getItem('sencho-active-node') || undefined
|
||||
}));
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
@@ -688,10 +688,9 @@ export default function EditorLayout() {
|
||||
{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'
|
||||
}`} />
|
||||
<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>
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -199,7 +200,7 @@ export function NodeManager() {
|
||||
toast.success('Token copied to clipboard');
|
||||
setTimeout(() => setTokenCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Could not copy automatically — please select and copy the token manually.');
|
||||
toast.error('Could not copy automatically - please select and copy the token manually.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -233,6 +234,32 @@ export function NodeManager() {
|
||||
/>
|
||||
</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">
|
||||
@@ -292,7 +319,14 @@ export function NodeManager() {
|
||||
Manage connections to local and remote Sencho instances
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<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" />
|
||||
@@ -301,7 +335,7 @@ export function NodeManager() {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>Add Remote Node</DialogTitle>
|
||||
<DialogTitle>Add {formData.type === 'local' ? 'Local' : 'Remote'} Node</DialogTitle>
|
||||
</DialogHeader>
|
||||
{renderFormFields()}
|
||||
<DialogFooter>
|
||||
@@ -319,7 +353,7 @@ export function NodeManager() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Generate Node Token — for use on THIS instance as a remote target */}
|
||||
{/* 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>
|
||||
@@ -328,7 +362,7 @@ export function NodeManager() {
|
||||
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 main dashboard's "Add Node" form.
|
||||
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
|
||||
@@ -390,7 +424,7 @@ export function NodeManager() {
|
||||
<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 || '—')}
|
||||
{node.type === 'local' ? 'docker.sock' : (node.api_url || '-')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(node.status)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -458,7 +492,7 @@ export function NodeManager() {
|
||||
<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}
|
||||
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>
|
||||
@@ -496,7 +530,7 @@ export function NodeManager() {
|
||||
<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.
|
||||
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>
|
||||
|
||||
@@ -476,7 +476,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
</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>
|
||||
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden by real-time streaming.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []); // stable — reads activeNode via ref, not closure capture
|
||||
}, []); // stable - reads activeNode via ref, not closure capture
|
||||
|
||||
const setActiveNode = useCallback((node: Node) => {
|
||||
setActiveNodeState(node);
|
||||
|
||||
Reference in New Issue
Block a user