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
+12 -9
View File
@@ -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
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 };
}
+4 -4
View File
@@ -75,7 +75,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
return;
}
// Node exists and has layout safe to initialize xterm!
// Node exists and has layout - safe to initialize xterm!
initTerminal(container);
};
@@ -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%' }}
+8 -9
View File
@@ -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>
+42 -8
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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);