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 { 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, Copy, KeyRound, Check } from 'lucide-react'; interface NodeFormData { name: string; type: 'local' | 'remote'; api_url: string; api_token: string; compose_dir: string; is_default: boolean; } const defaultFormData: NodeFormData = { name: '', type: 'remote', api_url: '', api_token: '', compose_dir: '/app/compose', 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); // Node token generation state const [generatedToken, setGeneratedToken] = useState(null); const [generatingToken, setGeneratingToken] = useState(false); const [tokenCopied, setTokenCopied] = useState(false); 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'); } const { id: newNodeId } = await res.json(); toast.success(`Node "${formData.name}" created successfully`); setCreateOpen(false); setFormData(defaultFormData); // Auto-test the new node connection immediately if (newNodeId && formData.type === 'remote') { 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 } finally { setTesting(null); } } 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, api_url: node.api_url || '', api_token: node.api_token || '', 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 generateNodeToken = async () => { setGeneratingToken(true); setGeneratedToken(null); try { const res = await apiFetch('/auth/generate-node-token', { method: 'POST' }); if (!res.ok) throw new Error('Failed to generate token'); const { token } = await res.json(); setGeneratedToken(token); toast.success('Node token generated'); } catch (error: any) { toast.error(error.message || 'Failed to generate token'); } finally { setGeneratingToken(false); } }; const copyToken = async () => { if (!generatedToken) return; await navigator.clipboard.writeText(generatedToken); setTokenCopied(true); toast.success('Token copied to clipboard'); setTimeout(() => setTokenCopied(false), 2000); }; 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, api_url: e.target.value })} />

The base URL of the Sencho instance running on the remote machine.

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

Generate this token on the remote Sencho instance using the "Generate Node Token" button in its Settings → Nodes panel.

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

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

); return (
{/* Header */}

Nodes

Manage connections to local and remote Sencho instances

Add Remote Node {renderFormFields()}
{/* Generate Node Token — for use on THIS instance as a remote target */}

Generate Node Token

Create a long-lived token that allows another Sencho instance to use this instance as a remote node. Copy it and paste it into the main dashboard's "Add Node" form.

{generatedToken && (
{generatedToken}
)}
{/* Nodes Table */}
Name Type Endpoint 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.api_url || '—')} {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}

Instance: {testResult.info.serverVersion}
OS: {testResult.info.os}
Arch: {testResult.info.architecture}
Containers: {testResult.info.containers}
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 instance or any running containers. Cancel Delete
); }