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, ShieldCheck } from 'lucide-react'; interface NodeFormData { name: string; type: 'local' | 'remote'; host: string; port: number; ssh_port: number; compose_dir: string; is_default: boolean; ssh_user: string; ssh_auth_type: 'password' | 'key'; ssh_password: string; ssh_key: string; tls_enabled: boolean; tls_ca: string; tls_cert: string; tls_key: string; } const defaultFormData: NodeFormData = { name: '', type: 'remote', host: '', port: 2375, ssh_port: 22, compose_dir: '/opt/docker', is_default: false, ssh_user: '', ssh_auth_type: 'password', ssh_password: '', ssh_key: '', tls_enabled: false, tls_ca: '', tls_cert: '', tls_key: '', }; 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 payload = { ...formData, // Only pass TLS fields if TLS is enabled tls_ca: formData.tls_enabled ? formData.tls_ca : '', tls_cert: formData.tls_enabled ? formData.tls_cert : '', tls_key: formData.tls_enabled ? formData.tls_key : '', // Only pass the relevant SSH credential ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '', ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '', }; const res = await apiFetch('/nodes', { method: 'POST', body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to create node'); } const { id: newNodeId } = await res.json(); toast.success(`Node "${formData.name}" created successfully`); setCreateOpen(false); setFormData(defaultFormData); // Auto-test the new node connection immediately after creation if (newNodeId) { setTesting(newNodeId); try { const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' }); const testData = await testRes.json(); if (testData.success) { toast.success(`Connected to "${formData.name}" successfully`); setTestResult({ nodeId: newNodeId, info: testData.info }); } else { toast.warning(`Node saved, but connection test failed: ${testData.error}`); } } catch { // Non-fatal — node was created, test just didn't succeed } finally { setTesting(null); } } await refreshNodes(); } catch (error: any) { toast.error(error.message || 'Failed to create node'); } }; const handleEdit = async () => { if (!editingNodeId) return; try { const payload = { ...formData, tls_ca: formData.tls_enabled ? formData.tls_ca : '', tls_cert: formData.tls_enabled ? formData.tls_cert : '', tls_key: formData.tls_enabled ? formData.tls_key : '', ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '', ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '', }; const res = await apiFetch(`/nodes/${editingNodeId}`, { method: 'PUT', body: JSON.stringify(payload), }); 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) => { const hasTls = !!(node.tls_ca && node.tls_cert && node.tls_key); setFormData({ name: node.name, type: node.type, host: node.host, port: node.port, ssh_port: node.ssh_port || 22, compose_dir: node.compose_dir, is_default: node.is_default, ssh_user: node.ssh_user || '', ssh_auth_type: node.ssh_key ? 'key' : 'password', ssh_password: node.ssh_password || '', ssh_key: node.ssh_key || '', tls_enabled: hasTls, tls_ca: node.tls_ca || '', tls_cert: node.tls_cert || '', tls_key: node.tls_key || '', }); 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' && ( <>

How to expose Docker via TCP on the remote machine

Edit /lib/systemd/system/docker.service and update the ExecStart line:

ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock

Then restart: systemctl daemon-reload && systemctl restart docker

setFormData({ ...formData, host: e.target.value })} />
setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })} />

Default: 2375 (unencrypted) or 2376 (TLS)

setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })} />

Default: 22. Used for file operations (SFTP).

{/* SSH Credentials Section */}

SSH Credentials (for file operations via SFTP)

setFormData({ ...formData, ssh_user: e.target.value })} />
{formData.ssh_auth_type === 'password' ? (
setFormData({ ...formData, ssh_password: e.target.value })} />
) : (