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)