import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNodes } from '@/context/NodeContext'; import type { Node, NodeMode } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; 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 { Combobox } from './ui/combobox'; import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw, Terminal } from 'lucide-react'; import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime'; import { SettingsPrimaryButton } from './settings/SettingsActions'; import { useMastheadStats } from './settings/MastheadStatsContext'; 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' | 'security-history'; nodeId?: number; } interface NodeFormData { name: string; type: 'local' | 'remote'; mode: NodeMode; api_url: string; api_token: string; compose_dir: string; is_default: boolean; } interface PilotEnrollment { token: string; expiresAt: number; dockerRun: string; } const defaultFormData: NodeFormData = { name: '', type: 'remote', mode: 'pilot_agent', api_url: '', api_token: '', compose_dir: '/app/compose', is_default: false, }; export function NodeManager() { const { nodes, refreshNodes } = useNodes(); useMastheadStats([ { label: 'NODES', value: `${nodes.length}` }, { label: 'REMOTE', value: `${nodes.filter(n => n.type === 'remote').length}`, tone: 'subtitle', }, ]); 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; senchoVersion?: 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); // Pilot enrollment state (shown after creating a pilot-agent node) const [activeEnrollment, setActiveEnrollment] = useState<{ nodeId: number; nodeName: string; enrollment: PilotEnrollment } | null>(null); const [enrollmentCopied, setEnrollmentCopied] = 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 body = await res.json(); const newNodeId: number | undefined = body.id; const enrollment: PilotEnrollment | undefined = body.enrollment; toast.success(`Node "${formData.name}" created successfully`); const isPilot = formData.type === 'remote' && formData.mode === 'pilot_agent'; if (isPilot && newNodeId && enrollment) { // Keep the dialog open so the admin can copy the docker run command. setActiveEnrollment({ nodeId: newNodeId, nodeName: formData.name, enrollment }); } else { setCreateOpen(false); setFormData(defaultFormData); } // Auto-test the new node connection immediately (proxy mode only; // pilot agents flip online asynchronously once the container connects). if (newNodeId && formData.type === 'remote' && formData.mode === 'proxy') { 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 regenerateEnrollment = async (node: Node) => { try { const res = await apiFetch(`/nodes/${node.id}/pilot/enroll`, { method: 'POST', localOnly: true }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to regenerate enrollment'); } const { enrollment } = (await res.json()) as { enrollment: PilotEnrollment }; setActiveEnrollment({ nodeId: node.id, nodeName: node.name, enrollment }); toast.success('New enrollment token generated'); } catch (error) { toast.error((error as Error).message || 'Failed to regenerate enrollment'); } }; const copyEnrollment = async () => { if (!activeEnrollment) return; try { await copyToClipboard(activeEnrollment.enrollment.dockerRun); setEnrollmentCopied(true); toast.success('Command copied to clipboard'); setTimeout(() => setEnrollmentCopied(false), 2000); } catch { toast.error('Could not copy automatically. Please select and copy the command manually.'); } }; 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, mode: (node.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy'), 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 { await copyToClipboard(generatedToken); 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, mode: val as NodeMode, api_url: '', api_token: '' })} options={[ { value: 'pilot_agent', label: 'Pilot Agent - outbound tunnel from remote host' }, { value: 'proxy', label: 'Distributed API Proxy - primary dials the remote' }, ]} />

Pilot Agent requires only outbound HTTPS from the remote host. Distributed API Proxy requires the remote host to expose an inbound port.

)} {formData.type === 'remote' && formData.mode === 'proxy' && ( <>
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 (
{/* Actions */}
{ setCreateOpen(open); // Reset form to defaults every time the dialog opens if (open) setFormData(defaultFormData); }} > Add node Add {formData.type === 'local' ? 'Local' : 'Remote'} Node {renderFormFields()} Add node
{/* 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 Mode 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' ? ( - ) : node.mode === 'pilot_agent' ? ( Pilot Agent ) : ( Proxy )} {node.type === 'local' ? 'docker.sock' : node.mode === 'pilot_agent' ? (node.pilot_last_seen ? `tunnel (seen ${formatTimeAgo(node.pilot_last_seen)})` : 'tunnel (waiting)') : (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 {formatTimeUntil(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}
{testResult.info.senchoVersion && (
Sencho: v{testResult.info.senchoVersion}
)}
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()} {formData.type === 'remote' && formData.mode === 'pilot_agent' && editingNodeId !== null && (

Re-enroll the agent if the container was lost or the enrollment token expired. The previous tunnel is disconnected automatically.

)}
{/* Pilot enrollment dialog (create + regenerate flows both open this) */} { if (!open) { setActiveEnrollment(null); setEnrollmentCopied(false); setCreateOpen(false); setFormData(defaultFormData); } }} > Enroll the pilot agent {activeEnrollment && (

Run this command on {activeEnrollment.nodeName} to start the pilot agent. The token below is valid for 15 minutes and can only be used once.

{activeEnrollment.enrollment.dockerRun}

Expires {formatTimeUntil(activeEnrollment.enrollment.expiresAt)} from now.

)}
{/* 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
); }