diff --git a/CHANGELOG.md b/CHANGELOG.md index b13b2c2b..9c99a8dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/src/index.ts b/backend/src/index.ts index 7e807cfc..ac3a3b5f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index cae947e9..7d632652 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): 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>): 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); + } } diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts new file mode 100644 index 00000000..b5eb33af --- /dev/null +++ b/backend/src/services/NodeRegistry.ts @@ -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 = 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'; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e944ede..5303b3d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - + + + + ); } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index f8740ced..aba71516 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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([]); const [selectedFile, setSelectedFile] = useState(null); const [content, setContent] = useState(''); @@ -647,6 +649,39 @@ export default function EditorLayout() { + {/* Node Switcher */} + {nodes.length > 1 && ( +
+ +
+ )} + {/* Create Stack Button */}
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx new file mode 100644 index 00000000..2cbebb07 --- /dev/null +++ b/frontend/src/components/NodeManager.tsx @@ -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(defaultFormData); + const [editingNodeId, setEditingNodeId] = useState(null); + const [deletingNode, setDeletingNode] = useState(null); + const [testing, setTesting] = useState(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 Online; + case 'offline': + return Offline; + default: + return Unknown; + } + }; + + const getNodeIcon = (type: string) => { + return type === 'local' + ? + : ; + }; + + const renderFormFields = () => ( +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ +
+ + +
+ + {formData.type === 'remote' && ( + <> +
+ + setFormData({ ...formData, host: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })} + /> +

+ Default: 2375 (unencrypted) or 2376 (TLS) +

+
+ + )} + +
+ + setFormData({ ...formData, compose_dir: e.target.value })} + /> +

+ The root directory where compose stack folders live on this node +

+
+
+ ); + + return ( +
+
+
+

+ + Nodes +

+

+ Manage Docker daemon connections across local and remote hosts +

+
+ + + + + + + Add Remote Node + + {renderFormFields()} + + + + + + +
+ + + + {/* Nodes Table */} +
+ + + + + Name + Type + Endpoint + Compose Dir + Status + Actions + + + + {nodes.map((node) => ( + + + {node.is_default && ( + + + + + + Default Node + + + )} + + +
+ {getNodeIcon(node.type)} + {node.name} +
+
+ + {node.type === 'local' ? 'Local' : 'Remote'} + + + {node.type === 'local' ? 'docker.sock' : `${node.host}:${node.port}`} + + + {node.compose_dir} + + {getStatusBadge(node.status)} + +
+ + + + + + Test Connection + + + + + + + + + Edit Node + + + + {!node.is_default && ( + + + + + + Delete Node + + + )} +
+
+
+ ))} +
+
+
+ + {/* Connection Test Result */} + {testResult && ( +
+

+ + Connection Details — {nodes.find(n => n.id === testResult.nodeId)?.name} +

+
+
Docker: v{testResult.info.serverVersion}
+
OS: {testResult.info.os}
+
Arch: {testResult.info.architecture}
+
Containers: {testResult.info.containers} ({testResult.info.containersRunning} running)
+
Images: {testResult.info.images}
+
CPUs: {testResult.info.cpus}
+
+
+ )} + + {/* Edit Dialog */} + + + + Edit Node + + {renderFormFields()} + + + + + + + + {/* Delete Confirmation */} + + + + Delete Node + + Are you sure you want to remove {deletingNode?.name}? This will only remove the node from Sencho — it will not affect the remote Docker daemon or any running containers. + + + + Cancel + Delete + + + +
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5100d6af..244c7267 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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 ( !open && onClose()}> - + {/* Sidebar */}
Settings Hub
@@ -266,6 +267,14 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se Developer +
@@ -478,6 +487,10 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
)} + {activeSection === 'nodes' && ( + + )} + diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx new file mode 100644 index 00000000..d209a29d --- /dev/null +++ b/frontend/src/context/NodeContext.tsx @@ -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; + isLoading: boolean; +} + +const NodeContext = createContext(undefined); + +export function NodeProvider({ children }: { children: React.ReactNode }) { + const [nodes, setNodes] = useState([]); + const [activeNode, setActiveNodeState] = useState(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 ( + + {children} + + ); +} + +export function useNodes() { + const context = useContext(NodeContext); + if (!context) { + throw new Error('useNodes must be used within a NodeProvider'); + } + return context; +}