mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user