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
); }