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:
SaelixCode
2026-03-19 16:06:47 -04:00
parent 9e6f72111d
commit eb0c0263c7
12 changed files with 135 additions and 77 deletions
+20 -8
View File
@@ -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();
+2 -2
View File
@@ -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);
+4 -4
View File
@@ -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
}
});
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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) {
+37 -27
View File
@@ -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 };
}