import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw } from 'lucide-react'; interface NodeSchedulingSummary { active_tasks: number; auto_update_enabled: boolean; next_run_at: number | null; stacks_with_updates: number; } export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate'; export interface SenchoNavigateDetail { view: 'scheduled-ops' | 'auto-updates'; nodeId: number; } function formatRelativeTime(timestamp: number): string { const diff = timestamp - Date.now(); if (diff < 0) return 'overdue'; const mins = Math.floor(diff / 60000); if (mins < 60) return `${mins}m`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h`; return `${Math.floor(hrs / 24)}d`; } 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: { serverVersion?: string; os?: string; architecture?: string; containers?: number; images?: number; cpus?: number } } | null>(null); // Node token generation state const [generatedToken, setGeneratedToken] = useState(null); const [generatingToken, setGeneratingToken] = useState(false); const [tokenCopied, setTokenCopied] = useState(false); // Per-node scheduling summary const [nodeSummary, setNodeSummary] = useState>({}); const fetchSchedulingSummary = useCallback(async () => { try { const res = await apiFetch('/nodes/scheduling-summary', { localOnly: true }); if (res.ok) setNodeSummary(await res.json()); } catch { // Non-fatal — summary is supplementary info } }, []); const nodeIdKey = useMemo(() => nodes.map(n => n.id).join(','), [nodes]); useEffect(() => { fetchSchedulingSummary(); }, [nodeIdKey, fetchSchedulingSummary]); 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) { toast.error((error as 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) { toast.error((error as 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) { toast.error((error as 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) { toast.error((error as 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) { toast.error((error as Error).message || 'Failed to generate token'); } finally { setGeneratingToken(false); } }; const copyToken = async () => { if (!generatedToken) return; try { // Clipboard API requires a secure context (HTTPS or localhost) await navigator.clipboard.writeText(generatedToken); setTokenCopied(true); toast.success('Token copied to clipboard'); setTimeout(() => setTokenCopied(false), 2000); } catch { // Fallback for HTTP / non-localhost deployments where Clipboard API is unavailable try { const ta = document.createElement('textarea'); ta.value = generatedToken; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); setTokenCopied(true); toast.success('Token copied to clipboard'); setTimeout(() => setTokenCopied(false), 2000); } catch { toast.error('Could not copy automatically - please select and copy the token manually.'); } } }; 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.

{formData.api_url.startsWith('http://') && (

This URL uses plain HTTP. If this node is reachable over the public internet, use HTTPS or a VPN to prevent token interception. HTTP is fine for private networks (LAN, VPN, VPC).

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

{ setCreateOpen(open); // Reset form to defaults every time the dialog opens if (open) setFormData(defaultFormData); }} > Add {formData.type === 'local' ? 'Local' : '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 other Sencho instance's "Add Node" form.

{generatedToken && (
{generatedToken}
)}
{/* Nodes Table */}
Name Type Endpoint Status Schedules Updates 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)} {(() => { const summary = nodeSummary[node.id]; if (!summary || summary.active_tasks === 0) { return ; } return (
{summary.active_tasks} {summary.next_run_at && ( next {formatRelativeTime(summary.next_run_at)} {new Date(summary.next_run_at).toLocaleString()} )}
); })()}
{(() => { const summary = nodeSummary[node.id]; return (
{summary?.auto_update_enabled ? ( Auto ) : ( Off )} {(summary?.stacks_with_updates ?? 0) > 0 && ( {summary!.stacks_with_updates} )}
); })()}
View Schedules 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
); }