feat: Remote Nodes Foundation (Strategy B) - Add nodes table with auto-seeded default local node in DatabaseService - Create NodeRegistry service for multi-instance Docker daemon connections - Add 6 Node management API endpoints (CRUD + test connection) - Create NodeManager component with table UI and connection testing - Add NodeContext for frontend-wide active node state management - Add node switcher dropdown to sidebar (visible when >1 node) - Add Nodes tab to Settings Hub

This commit is contained in:
SaelixCode
2026-03-18 11:45:04 -04:00
parent c324d987ea
commit 02e1ebe1b6
9 changed files with 929 additions and 6 deletions
+104
View File
@@ -21,6 +21,7 @@ import { NotificationService } from './services/NotificationService';
import { MonitorService } from './services/MonitorService';
import { templateService } from './services/TemplateService';
import { ErrorParser } from './utils/ErrorParser';
import { NodeRegistry } from './services/NodeRegistry';
import YAML from 'yaml';
import fs, { promises as fsPromises } from 'fs';
@@ -1340,6 +1341,109 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => {
}
});
// =========================
// Node Management API
// =========================
// List all nodes
app.get('/api/nodes', async (req: Request, res: Response) => {
try {
const nodes = DatabaseService.getInstance().getNodes();
res.json(nodes);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch nodes' });
}
});
// Get a specific node
app.get('/api/nodes/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
const node = DatabaseService.getInstance().getNode(id);
if (!node) {
return res.status(404).json({ error: 'Node not found' });
}
res.json(node);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch node' });
}
});
// Create a new node
app.post('/api/nodes', async (req: Request, res: Response) => {
try {
const { name, type, host, port, compose_dir, is_default } = req.body;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Node name is required' });
}
if (!type || !['local', 'remote'].includes(type)) {
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
}
if (type === 'remote' && (!host || typeof host !== 'string')) {
return res.status(400).json({ error: 'Host is required for remote nodes' });
}
const id = DatabaseService.getInstance().addNode({
name,
type,
host: host || '',
port: port || 2375,
compose_dir: compose_dir || '/opt/docker',
is_default: is_default || false,
});
res.json({ success: true, id });
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'A node with that name already exists' });
}
console.error('Failed to create node:', error);
res.status(500).json({ error: error.message || 'Failed to create node' });
}
});
// Update a node
app.put('/api/nodes/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
const updates = req.body;
DatabaseService.getInstance().updateNode(id, updates);
// Evict cached Docker connection so it reconnects with new config
NodeRegistry.getInstance().evictConnection(id);
res.json({ success: true });
} catch (error: any) {
console.error('Failed to update node:', error);
res.status(500).json({ error: error.message || 'Failed to update node' });
}
});
// Delete a node
app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
DatabaseService.getInstance().deleteNode(id);
NodeRegistry.getInstance().evictConnection(id);
res.json({ success: true });
} catch (error: any) {
console.error('Failed to delete node:', error);
res.status(500).json({ error: error.message || 'Failed to delete node' });
}
});
// Test connection to a node
app.post('/api/nodes/:id/test', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
const result = await NodeRegistry.getInstance().testConnection(id);
res.json(result);
} catch (error: any) {
res.status(500).json({ success: false, error: error.message || 'Connection test failed' });
}
});
// Serve static files in production (for Docker deployment)
+115
View File
@@ -25,6 +25,18 @@ export interface StackAlert {
last_fired_at?: number;
}
export interface Node {
id?: number;
name: string;
type: 'local' | 'remote';
host: string;
port: number;
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
}
export interface NotificationHistory {
id?: number;
level: 'info' | 'warning' | 'error';
@@ -110,6 +122,18 @@ export class DatabaseService {
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 nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local',
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 2375,
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
created_at INTEGER NOT NULL
);
`);
// Initialize default global settings if they don't exist
@@ -121,6 +145,14 @@ export class DatabaseService {
stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5'); // Default 5 seconds
stmt.run('developer_mode', '0'); // Default off
// 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, host, port, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run('Local', 'local', '', 0, process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
}
}
private migrateJsonConfig(dataDir: string) {
@@ -288,4 +320,87 @@ export class DatabaseService {
const stmt = this.db.prepare('DELETE FROM container_metrics WHERE timestamp < ?');
stmt.run(cutoff);
}
// --- Nodes ---
public getNodes(): Node[] {
const stmt = this.db.prepare('SELECT * 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 * 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 * 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 this node is set as default, clear other defaults first
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const stmt = this.db.prepare(
'INSERT INTO nodes (name, type, host, port, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
node.name,
node.type,
node.host,
node.port,
node.compose_dir,
node.is_default ? 1 : 0,
'unknown',
Date.now()
);
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 setting as default, clear other defaults first
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.host !== undefined) { fields.push('host = ?'); values.push(updates.host); }
if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); }
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 (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);
}
}
+150
View File
@@ -0,0 +1,150 @@
import Docker from 'dockerode';
import { DatabaseService, Node } from './DatabaseService';
/**
* NodeRegistry: Manages Docker daemon connections for multiple nodes.
* Replaces the old singleton DockerController pattern. Each node
* (local or remote) gets its own dedicated Docker client instance.
*/
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 specific node.
* Creates the connection lazily on first request and caches it.
*/
public getDocker(nodeId: number): Docker {
// Return cached connection if available
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`);
}
const docker = this.createDockerClient(node);
this.connections.set(nodeId, docker);
return docker;
}
/**
* Get the Docker client for the default node.
* This is the backward-compatible path for all existing code.
*/
public getDefaultDocker(): Docker {
const db = DatabaseService.getInstance();
const defaultNode = db.getDefaultNode();
if (!defaultNode || !defaultNode.id) {
// Absolute fallback: local socket (preserves legacy behavior)
return new Docker();
}
return this.getDocker(defaultNode.id);
}
/**
* Get the default node ID. Returns the ID of the node marked as default.
*/
public getDefaultNodeId(): number {
const db = DatabaseService.getInstance();
const defaultNode = db.getDefaultNode();
return defaultNode?.id || 1;
}
/**
* Create a Docker client based on node configuration.
* - Local nodes: use the default socket (Docker autodetects)
* - Remote nodes: connect via TCP to host:port
*/
private createDockerClient(node: Node): Docker {
if (node.type === 'local') {
// Local node: use the default Docker socket
return new Docker();
}
// Remote node: connect via Docker TCP API
if (!node.host) {
throw new Error(`Remote node "${node.name}" is missing a host address`);
}
return new Docker({
host: node.host,
port: node.port || 2375,
// TODO: Phase 55.2 — Add TLS certificate support for secure remote connections
});
}
/**
* Test connectivity to a specific node.
* Returns true if we can ping the Docker daemon.
*/
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' };
}
try {
const docker = this.createDockerClient(node);
const info = await docker.info();
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' };
}
}
/**
* Evict a cached connection (e.g., after node config changes).
*/
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 specific 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';
}
}