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
+6
View File
@@ -5,6 +5,12 @@ 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]
- **Added:** Remote Nodes Foundation (Strategy B) — `nodes` table in SQLite with auto-seeded default local node.
- **Added:** `NodeRegistry` service for managing multiple Docker daemon connections (local socket + TCP).
- **Added:** Node management API endpoints: list, get, create, update, delete, and test connection.
- **Added:** Settings Hub → Nodes tab with full CRUD UI, connection testing, and Docker info display.
- **Added:** Node switcher dropdown in sidebar (auto-visible when multiple nodes are configured).
- **Added:** `NodeContext` for frontend-wide active node state management.
- **Fixed:** Global logs false-positive error misclassifications caused by Docker containers writing INFO logs to STDERR. Replaced naive regex with a robust 3-tier classification engine supporting `level=info`, `[INFO]`, and ` INFO ` format standards.
- **Added:** Developer Mode setting to enable true Real-Time (SSE) global log streaming and infinite scroll.
- **Added:** Configurable polling rates for standard global logs monitoring.
+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';
}
}
+5 -2
View File
@@ -1,4 +1,5 @@
import { AuthProvider, useAuth } from './context/AuthContext';
import { NodeProvider } from './context/NodeContext';
import { Login } from './components/Login';
import { Setup } from './components/Setup';
import EditorLayout from './components/EditorLayout';
@@ -38,8 +39,10 @@ import { Toaster } from 'sonner';
function App() {
return (
<AuthProvider>
<AppContent />
<Toaster position="bottom-right" richColors />
<NodeProvider>
<AppContent />
<Toaster position="bottom-right" richColors />
</NodeProvider>
</AuthProvider>
);
}
+36 -1
View File
@@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
@@ -31,6 +31,7 @@ import { StackAlertSheet } from './StackAlertSheet';
import { AppStoreView } from './AppStoreView';
import { LogViewer } from './LogViewer';
import { GlobalObservabilityView } from './GlobalObservabilityView';
import { useNodes } from '@/context/NodeContext';
interface ContainerInfo {
Id: string;
@@ -54,6 +55,7 @@ const formatBytes = (bytes: number) => {
export default function EditorLayout() {
const { logout } = useAuth();
const { nodes, activeNode, setActiveNode } = useNodes();
const [files, setFiles] = useState<string[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [content, setContent] = useState<string>('');
@@ -647,6 +649,39 @@ export default function EditorLayout() {
</TooltipProvider>
</div>
{/* Node Switcher */}
{nodes.length > 1 && (
<div className="px-4 pt-2 pb-0">
<Select
value={activeNode?.id?.toString() || ''}
onValueChange={(val) => {
const node = nodes.find(n => n.id === parseInt(val));
if (node) setActiveNode(node);
}}
>
<SelectTrigger className="w-full h-9 text-sm">
<div className="flex items-center gap-2">
<Server className="w-3.5 h-3.5 text-muted-foreground" />
<SelectValue placeholder="Select node" />
</div>
</SelectTrigger>
<SelectContent>
{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'
}`} />
{node.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Create Stack Button */}
<div className="p-4">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
+414
View File
@@ -0,0 +1,414 @@
import { useState } from 'react';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { apiFetch } from '@/lib/api';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Separator } from './ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe } from 'lucide-react';
interface NodeFormData {
name: string;
type: 'local' | 'remote';
host: string;
port: number;
compose_dir: string;
is_default: boolean;
}
const defaultFormData: NodeFormData = {
name: '',
type: 'remote',
host: '',
port: 2375,
compose_dir: '/opt/docker',
is_default: false,
};
export function NodeManager() {
const { nodes, refreshNodes } = useNodes();
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [formData, setFormData] = useState<NodeFormData>(defaultFormData);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [deletingNode, setDeletingNode] = useState<Node | null>(null);
const [testing, setTesting] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null);
const handleCreate = async () => {
try {
const res = await apiFetch('/nodes', {
method: 'POST',
body: JSON.stringify(formData),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to create node');
}
toast.success(`Node "${formData.name}" created successfully`);
setCreateOpen(false);
setFormData(defaultFormData);
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Failed to create node');
}
};
const handleEdit = async () => {
if (!editingNodeId) return;
try {
const res = await apiFetch(`/nodes/${editingNodeId}`, {
method: 'PUT',
body: JSON.stringify(formData),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to update node');
}
toast.success(`Node "${formData.name}" updated`);
setEditOpen(false);
setEditingNodeId(null);
setFormData(defaultFormData);
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Failed to update node');
}
};
const openEditDialog = (node: Node) => {
setFormData({
name: node.name,
type: node.type,
host: node.host,
port: node.port,
compose_dir: node.compose_dir,
is_default: node.is_default,
});
setEditingNodeId(node.id);
setEditOpen(true);
};
const handleDelete = async () => {
if (!deletingNode) return;
try {
const res = await apiFetch(`/nodes/${deletingNode.id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to delete node');
}
toast.success(`Node "${deletingNode.name}" deleted`);
setDeleteOpen(false);
setDeletingNode(null);
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Failed to delete node');
}
};
const testConnection = async (node: Node) => {
setTesting(node.id);
setTestResult(null);
try {
const res = await apiFetch(`/nodes/${node.id}/test`, { method: 'POST' });
const result = await res.json();
if (result.success) {
toast.success(`Connected to "${node.name}" successfully`);
setTestResult({ nodeId: node.id, info: result.info });
} else {
toast.error(`Failed to connect: ${result.error}`);
}
await refreshNodes();
} catch (error: any) {
toast.error(error.message || 'Connection test failed');
} finally {
setTesting(null);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'online':
return <Badge variant="default" className="bg-green-600 text-white gap-1"><Wifi className="w-3 h-3" /> Online</Badge>;
case 'offline':
return <Badge variant="destructive" className="gap-1"><WifiOff className="w-3 h-3" /> Offline</Badge>;
default:
return <Badge variant="secondary" className="gap-1">Unknown</Badge>;
}
};
const getNodeIcon = (type: string) => {
return type === 'local'
? <Monitor className="w-4 h-4 text-muted-foreground" />
: <Globe className="w-4 h-4 text-muted-foreground" />;
};
const renderFormFields = () => (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="node-name">Name</Label>
<Input
id="node-name"
placeholder="e.g., Production VPS"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-type">Type</Label>
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v as 'local' | 'remote' })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (Docker Socket)</SelectItem>
<SelectItem value="remote">Remote (TCP)</SelectItem>
</SelectContent>
</Select>
</div>
{formData.type === 'remote' && (
<>
<div className="space-y-2">
<Label htmlFor="node-host">Host</Label>
<Input
id="node-host"
placeholder="e.g., 192.168.1.50 or vps.example.com"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-port">Docker API Port</Label>
<Input
id="node-port"
type="number"
placeholder="2375"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })}
/>
<p className="text-xs text-muted-foreground">
Default: 2375 (unencrypted) or 2376 (TLS)
</p>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="node-compose-dir">Compose Directory</Label>
<Input
id="node-compose-dir"
placeholder="/opt/docker"
value={formData.compose_dir}
onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
The root directory where compose stack folders live on this node
</p>
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Server className="w-5 h-5" />
Nodes
</h2>
<p className="text-sm text-muted-foreground">
Manage Docker daemon connections across local and remote hosts
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1">
<Plus className="w-4 h-4" />
Add Node
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Remote Node</DialogTitle>
</DialogHeader>
{renderFormFields()}
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!formData.name || (formData.type === 'remote' && !formData.host)}>
Add Node
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Separator />
{/* Nodes Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Compose Dir</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => (
<TableRow key={node.id}>
<TableCell>
{node.is_default && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
</TooltipTrigger>
<TooltipContent>Default Node</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{getNodeIcon(node.type)}
{node.name}
</div>
</TableCell>
<TableCell>
<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.host}:${node.port}`}
</TableCell>
<TableCell className="text-muted-foreground text-sm font-mono">
{node.compose_dir}
</TableCell>
<TableCell>{getStatusBadge(node.status)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => testConnection(node)}
disabled={testing === node.id}
>
<Wifi className={`w-4 h-4 ${testing === node.id ? 'animate-pulse' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Test Connection</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEditDialog(node)}
>
<Pencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit Node</TooltipContent>
</Tooltip>
</TooltipProvider>
{!node.is_default && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => { setDeletingNode(node); setDeleteOpen(true); }}
>
<Trash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete Node</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Connection Test Result */}
{testResult && (
<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}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div><span className="text-muted-foreground">Docker:</span> v{testResult.info.serverVersion}</div>
<div><span className="text-muted-foreground">OS:</span> {testResult.info.os}</div>
<div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</div>
<div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers} ({testResult.info.containersRunning} running)</div>
<div><span className="text-muted-foreground">Images:</span> {testResult.info.images}</div>
<div><span className="text-muted-foreground">CPUs:</span> {testResult.info.cpus}</div>
</div>
</div>
)}
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Node</DialogTitle>
</DialogHeader>
{renderFormFields()}
<DialogFooter>
<Button variant="outline" onClick={() => { setEditOpen(false); setEditingNodeId(null); }}>Cancel</Button>
<Button onClick={handleEdit} disabled={!formData.name || (formData.type === 'remote' && !formData.host)}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<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 Docker daemon or any running containers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+16 -3
View File
@@ -12,7 +12,8 @@ import { Slider } from '@/components/ui/slider';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code } from 'lucide-react';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server } from 'lucide-react';
import { NodeManager } from './NodeManager';
interface Agent {
type: 'discord' | 'slack' | 'webhook';
@@ -28,7 +29,7 @@ interface SettingsModalProps {
}
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer'>('account');
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes'>('account');
// Auth State
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
@@ -221,7 +222,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[800px] h-[600px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
<DialogContent className="sm:max-w-[900px] h-[650px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
{/* Sidebar */}
<div className="w-[200px] bg-muted/20 border-r border-border flex flex-col p-4 shrink-0">
<div className="font-semibold text-lg mb-6 text-foreground tracking-tight">Settings Hub</div>
@@ -266,6 +267,14 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<Code className="w-4 h-4 mr-2" />
Developer
</Button>
<Button
variant={activeSection === 'nodes' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('nodes')}
>
<Server className="w-4 h-4 mr-2" />
Nodes
</Button>
</nav>
</div>
@@ -478,6 +487,10 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
</div>
)}
{activeSection === 'nodes' && (
<NodeManager />
)}
</div>
</DialogContent>
</Dialog>
+83
View File
@@ -0,0 +1,83 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { apiFetch } from '@/lib/api';
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;
}
interface NodeContextType {
nodes: Node[];
activeNode: Node | null;
setActiveNode: (node: Node) => void;
refreshNodes: () => Promise<void>;
isLoading: boolean;
}
const NodeContext = createContext<NodeContextType | undefined>(undefined);
export function NodeProvider({ children }: { children: React.ReactNode }) {
const [nodes, setNodes] = useState<Node[]>([]);
const [activeNode, setActiveNodeState] = useState<Node | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshNodes = useCallback(async () => {
try {
const res = await apiFetch('/nodes');
if (res.ok) {
const data = await res.json();
setNodes(data);
// If no active node is set, select the default node
if (!activeNode) {
const defaultNode = data.find((n: Node) => n.is_default);
if (defaultNode) {
setActiveNodeState(defaultNode);
} else if (data.length > 0) {
setActiveNodeState(data[0]);
}
} else {
// Refresh the active node's data in case its status changed
const updatedActive = data.find((n: Node) => n.id === activeNode.id);
if (updatedActive) {
setActiveNodeState(updatedActive);
}
}
}
} catch (error) {
console.error('Failed to fetch nodes:', error);
} finally {
setIsLoading(false);
}
}, [activeNode]);
const setActiveNode = useCallback((node: Node) => {
setActiveNodeState(node);
localStorage.setItem('sencho-active-node', String(node.id));
}, []);
useEffect(() => {
refreshNodes();
}, []);
return (
<NodeContext.Provider value={{ nodes, activeNode, setActiveNode, refreshNodes, isLoading }}>
{children}
</NodeContext.Provider>
);
}
export function useNodes() {
const context = useContext(NodeContext);
if (!context) {
throw new Error('useNodes must be used within a NodeProvider');
}
return context;
}