import { useCallback, useState, type ReactNode } from 'react'; import { Check, Copy, AlertTriangle, Globe, Monitor, RefreshCw } from 'lucide-react'; import { useNodes, type Node, type NodeMode } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Combobox } from '@/components/ui/combobox'; import { SettingsPrimaryButton } from '@/components/settings/SettingsActions'; import { formatTimeUntil } from '@/lib/relativeTime'; export interface NodeTestInfo { serverVersion?: string; senchoVersion?: string; os?: string; architecture?: string; containers?: number; images?: number; cpus?: number; } interface PilotEnrollment { token: string; expiresAt: number; composeYaml: string; } interface NodeFormData { name: string; type: 'local' | 'remote'; mode: NodeMode; api_url: string; api_token: string; compose_dir: string; is_default: boolean; } const DEFAULT_COMPOSE_DIR = '/app/compose'; const DEFAULT_PILOT_COMPOSE_DIR = '/opt/docker/sencho'; function defaultComposeDir(type: NodeFormData['type'], mode: NodeMode): string { return type === 'remote' && mode === 'pilot_agent' ? DEFAULT_PILOT_COMPOSE_DIR : DEFAULT_COMPOSE_DIR; } const defaultFormData: NodeFormData = { name: '', type: 'remote', mode: 'pilot_agent', api_url: '', api_token: '', compose_dir: DEFAULT_PILOT_COMPOSE_DIR, is_default: false, }; interface UseNodeActionsOptions { onNodeChange?: () => void; onTestResult?: (result: { nodeId: number; info: NodeTestInfo }) => void; } interface UseNodeActionsReturn { openCreate: () => void; openEdit: (node: Node) => void; openDelete: (node: Node) => void; NodeActionModals: ReactNode; } async function runConnectionTest( nodeId: number, nodeName: string, onTestResult?: (result: { nodeId: number; info: NodeTestInfo }) => void, ): Promise { try { const res = await apiFetch(`/nodes/${nodeId}/test`, { method: 'POST' }); const data = await res.json(); if (data.success) { toast.success(`Connected to "${nodeName}" successfully`); onTestResult?.({ nodeId, info: data.info }); } else { toast.warning(`Node saved, but connection test failed: ${data.error}`); } } catch (err) { // Node is saved; the test result is supplementary. Log so connectivity issues are debuggable. console.warn(`Auto connection test failed for node ${nodeId}:`, err); } } export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActionsReturn { const { onNodeChange, onTestResult } = opts; 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 [originalEditValues, setOriginalEditValues] = useState<{ api_url: string; api_token: string } | null>(null); const [deletingNode, setDeletingNode] = useState(null); const [activeEnrollment, setActiveEnrollment] = useState<{ nodeId: number; nodeName: string; enrollment: PilotEnrollment } | null>(null); const [enrollmentCopied, setEnrollmentCopied] = useState(false); const refresh = useCallback(async () => { await refreshNodes(); onNodeChange?.(); }, [refreshNodes, onNodeChange]); 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) { setActiveEnrollment({ nodeId: newNodeId, nodeName: formData.name, enrollment }); } else { setCreateOpen(false); setFormData(defaultFormData); } const isProxy = formData.type === 'remote' && formData.mode === 'proxy'; if (newNodeId && isProxy) { await runConnectionTest(newNodeId, formData.name, onTestResult); } await refresh(); } catch (error) { toast.error((error as Error).message || 'Failed to create node'); } }; const handleEdit = async () => { if (editingNodeId === null) return; try { // A blank token means "keep the existing credential": the stored token is // never sent to the browser, so an untouched field must not overwrite it. // Only a non-empty value rotates the token on the backend. const { api_token, ...rest } = formData; const payload = api_token.trim() ? formData : rest; 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`); const isProxy = formData.type === 'remote' && formData.mode === 'proxy'; const connectionFieldsChanged = originalEditValues !== null && ( formData.api_url !== originalEditValues.api_url || formData.api_token !== originalEditValues.api_token ); const savedNodeId = editingNodeId; const savedName = formData.name; setEditOpen(false); setEditingNodeId(null); setOriginalEditValues(null); setFormData(defaultFormData); if (isProxy && connectionFieldsChanged) { await runConnectionTest(savedNodeId, savedName, onTestResult); } await refresh(); } catch (error) { toast.error((error as Error).message || 'Failed to update node'); } }; 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`); await refresh(); } catch (error) { toast.error((error as Error).message || 'Failed to delete node'); } finally { setDeleteOpen(false); setDeletingNode(null); } }; 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.composeYaml); setEnrollmentCopied(true); toast.success('Compose file copied to clipboard'); setTimeout(() => setEnrollmentCopied(false), 2000); } catch { toast.error('Could not copy automatically. Please select and copy the compose file manually.'); } }; const openCreate = useCallback(() => { setFormData(defaultFormData); setCreateOpen(true); }, []); const openEdit = useCallback((node: Node) => { // The stored api_token is never returned to the browser, so the token field // always opens blank. A blank value on save keeps the existing credential; // typing a new value rotates it. setFormData({ name: node.name, type: node.type, mode: (node.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy'), api_url: node.api_url || '', api_token: '', compose_dir: node.compose_dir, is_default: node.is_default, }); setOriginalEditValues({ api_url: node.api_url || '', api_token: '', }); setEditingNodeId(node.id); setEditOpen(true); }, []); const openDelete = useCallback((node: Node) => { setDeletingNode(node); setDeleteOpen(true); }, []); const renderFormFields = (isEdit: boolean) => (
setFormData({ ...formData, name: e.target.value })} />
{formData.type === 'remote' && (
{ const mode = val as NodeMode; const currentDefault = defaultComposeDir(formData.type, formData.mode); setFormData({ ...formData, mode, api_url: '', api_token: '', compose_dir: formData.compose_dir === currentDefault ? defaultComposeDir(formData.type, mode) : formData.compose_dir, }); }} 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. Sencho Mesh works with both modes.

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

{isEdit ? ( 'Leave blank to keep the existing token. Paste a new one only to rotate it.' ) : ( <>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 })} />

{formData.type === 'remote' && formData.mode === 'pilot_agent' ? 'Absolute host path for compose stacks. The generated agent mounts this same path inside the container.' : 'The root directory where compose stack folders live on this node.'}

); const NodeActionModals = ( <> { setCreateOpen(open); if (open) setFormData(defaultFormData); }} size="lg" > {renderFormFields(false)} setCreateOpen(false)}>Cancel } primary={ Add node } /> { setEditOpen(open); if (!open) { setEditingNodeId(null); setOriginalEditValues(null); } }} size="lg" > {renderFormFields(true)} {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.

)}
{ setEditOpen(false); setEditingNodeId(null); setOriginalEditValues(null); }} > Cancel } primary={ } />
{ if (!open) { setActiveEnrollment(null); setEnrollmentCopied(false); setCreateOpen(false); setFormData(defaultFormData); } }} size="xl" > {activeEnrollment && (

Deploy the pilot agent on {activeEnrollment.nodeName} with the Compose file below. The token is valid for 15 minutes and can only be used once.

Step 1: save the file as compose.yaml

{activeEnrollment.enrollment.composeYaml}

Step 2: start the agent on the remote host

docker compose -f compose.yaml up -d

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

)}
{ setActiveEnrollment(null); setEnrollmentCopied(false); setCreateOpen(false); setEditOpen(false); setFormData(defaultFormData); }} > Done } />

Removes {deletingNode?.name} from this console. The remote instance and its containers are not affected.

); return { openCreate, openEdit, openDelete, NodeActionModals, }; }