diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 769d7752..23223315 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -61,12 +61,13 @@ The Fleet page has three tabs: ### Action buttons -Two buttons sit in the top-right corner: +Three buttons sit in the top-right corner: | Button | What it does | |--------|--------------| | **Check Updates** | Opens the Node Updates modal to view and apply Sencho version updates across your fleet (Skipper+) | | **Refresh** | Re-fetches data from all nodes. Shows a spinner while loading | +| **Add node** | Opens the same Add Node dialog as Settings → Nodes so you can register a new node without leaving the Fleet page. Admin-only. After saving a remote proxy node, a connection test runs automatically and the result toasts in (success or saved-but-unreachable). | ## Community features @@ -94,6 +95,18 @@ Nodes with an available Sencho update also show an **Update to vX.Y.Z** button a Offline nodes are visually dimmed and show a "Node unreachable" placeholder instead of stats. +### Node actions menu (admin) + +The kebab menu in the top-right of each card surfaces the same lifecycle actions you'd find in Settings → Nodes: + +| Action | Notes | +|--------|-------| +| **Edit node** | Opens the Edit dialog prefilled with the node's connection details. For proxy-mode remotes, saving with a changed API URL or token re-runs the connection test automatically. | +| **Delete node** | Opens a destructive confirmation. The local (default) node has no Delete option. Deleting a remote only removes it from this console; the remote instance and its containers are untouched. | +| **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it (Admiral). Existing deployments keep running. | + +The menu is admin-only. Non-admin users see no kebab on the card. + ### Manual refresh Click the **Refresh** button in the top-right to re-fetch data from all nodes. The button shows a spinner while loading. diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index bc4cb7aa..d704ab0b 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -1,5 +1,5 @@ import { - RefreshCw, Search, Camera, + RefreshCw, Search, Camera, Plus, Network, SlidersHorizontal, Send, KeyRound, ArrowLeftRight, Wrench, } from 'lucide-react'; @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { springs } from '@/lib/motion'; import { useLicense } from '@/context/LicenseContext'; +import { useAuth } from '@/context/AuthContext'; import { AdmiralGate } from './AdmiralGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; @@ -25,6 +26,8 @@ import { FederationTab } from './fleet/FederationTab'; import { DeploymentsTab } from './blueprints/DeploymentsTab'; import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab'; import { SecretsTab } from './fleet/secrets/SecretsTab'; +import { useNodeActions } from './nodes/useNodeActions'; +import { SettingsPrimaryButton } from './settings/SettingsActions'; interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; @@ -32,6 +35,7 @@ interface FleetViewProps { export function FleetView({ onNavigateToNode }: FleetViewProps) { const { isPaid, license } = useLicense(); + const { isAdmin } = useAuth(); const isAdmiral = isPaid && license?.variant === 'admiral'; const { prefs, updatePrefs } = useFleetPreferences(); @@ -47,6 +51,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const { mastheadStats, lastSyncAt, loading, refreshing } = overview; + const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({ + onNodeChange: () => { void overview.fetchOverview(true); }, + }); + return (
Refresh + {isAdmin && ( + + + Add node + + )}
@@ -164,6 +182,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { onRetryUpdate={updateStatus.retryNodeUpdate} onDismissUpdate={updateStatus.dismissNodeUpdate} onCordonChange={() => { void overview.fetchOverview(true); }} + onEditNode={isAdmin ? openEdit : undefined} + onDeleteNode={isAdmin ? openDelete : undefined} isPaid={isPaid} topologyMode={topology.prefs.mode} onTopologyModeChange={topology.setMode} @@ -230,6 +250,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { onOpenChange={(open) => { if (!open) updateStatus.setLocalUpdateConfirm(null); }} onConfirm={updateStatus.confirmLocalUpdate} /> + + {NodeActionModals} ); } diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 3563fc50..c7407541 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2, - MoreVertical, Ban, + MoreVertical, Ban, Pencil, Trash2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -19,6 +19,8 @@ import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { formatVersion } from '@/lib/version'; import { useLicense } from '@/context/LicenseContext'; +import { useAuth } from '@/context/AuthContext'; +import { useNodes, type Node } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { UpdateStatusBadge } from './UpdateStatusBadge'; import { StackSection } from './NodeCardStackList'; @@ -38,6 +40,8 @@ export interface NodeCardProps { onRetryUpdate?: (nodeId: number) => void; onDismissUpdate?: (nodeId: number) => void; onCordonChange?: () => void; + onEdit?: (node: Node) => void; + onDelete?: (node: Node) => void; } // --- Sub-Components --- @@ -55,7 +59,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) { // --- Main Export --- -export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange }: NodeCardProps) { +export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) { const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); @@ -64,7 +68,14 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u const [cordonSubmitting, setCordonSubmitting] = useState(false); const { isPaid, license } = useLicense(); + const { isAdmin } = useAuth(); + const { nodes: registryNodes } = useNodes(); const isAdmiral = isPaid && license?.variant === 'admiral'; + const registryNode = registryNodes.find(n => n.id === node.id); + const canEdit = Boolean(isAdmin && onEdit && registryNode); + const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default); + const canCordon = isAdmiral; + const showMenu = canEdit || canDelete || canCordon; const isOnline = node.status === 'online'; const isLocal = node.type === 'local'; @@ -131,11 +142,11 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u {/* Card Header */}
{isLocal && ( - + ★ Local )} - {isAdmiral && ( + {showMenu && (
@@ -148,10 +159,27 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u - - - {node.cordoned ? 'Uncordon node' : 'Cordon node'} - + {canEdit && registryNode && ( + onEdit!(registryNode)}> + + Edit node + + )} + {canDelete && registryNode && ( + onDelete!(registryNode)} + className="text-destructive focus:text-destructive" + > + + Delete node + + )} + {canCordon && ( + + + {node.cordoned ? 'Uncordon node' : 'Cordon node'} + + )}
diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index cf55e329..f4ff49ec 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -6,6 +6,7 @@ import { NodeCard } from './NodeCard'; import { OverviewToolbar } from './OverviewToolbar'; import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet-topology-layout'; import type { Label as StackLabel } from '../label-types'; +import type { Node } from '@/context/NodeContext'; import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types'; interface OverviewTabProps { @@ -32,6 +33,8 @@ interface OverviewTabProps { onRetryUpdate?: (nodeId: number) => void; onDismissUpdate?: (nodeId: number) => void; onCordonChange?: () => void; + onEditNode?: (node: Node) => void; + onDeleteNode?: (node: Node) => void; isPaid: boolean; topologyMode: LayoutMode; onTopologyModeChange: (mode: LayoutMode) => void; @@ -63,6 +66,8 @@ export function OverviewTab({ onRetryUpdate, onDismissUpdate, onCordonChange, + onEditNode, + onDeleteNode, isPaid, topologyMode, onTopologyModeChange, @@ -93,7 +98,7 @@ export function OverviewTab({

No nodes configured

-

Add nodes in Settings to see your fleet here.

+

Add a node to see your fleet here.

)} @@ -136,6 +141,8 @@ export function OverviewTab({ onRetryUpdate={onRetryUpdate} onDismissUpdate={onDismissUpdate} onCordonChange={onCordonChange} + onEdit={onEditNode} + onDelete={onDeleteNode} /> ))}
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index e8660591..fc7e301f 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -1,26 +1,22 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNodes } from '@/context/NodeContext'; -import type { Node, NodeMode } from '@/context/NodeContext'; +import type { Node } 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 './ui/modal'; 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 { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Monitor, Globe, Copy, KeyRound, Check, Calendar, RefreshCw, Terminal } from 'lucide-react'; import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime'; import { SettingsPrimaryButton } from './settings/SettingsActions'; import { useMastheadStats } from './settings/MastheadStatsContext'; import { NodeLabelPicker } from './blueprints/NodeLabelPicker'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; +import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions'; interface NodeSchedulingSummary { active_tasks: number; @@ -35,37 +31,11 @@ export interface SenchoNavigateDetail { 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 { isPaid } = useLicense(); const { isAdmin } = useAuth(); const canEditLabels = isPaid && isAdmin; - const { nodes, refreshNodes } = useNodes(); + const { nodes } = useNodes(); useMastheadStats([ { label: 'NODES', value: `${nodes.length}` }, { @@ -74,27 +44,22 @@ export function NodeManager() { 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); + const [testResult, setTestResult] = useState<{ nodeId: number; info: NodeTestInfo } | 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 { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({ + onTestResult: (result) => setTestResult(result), + }); + const fetchSchedulingSummary = useCallback(async () => { try { const res = await apiFetch('/nodes/scheduling-summary', { localOnly: true }); @@ -110,136 +75,6 @@ export function NodeManager() { 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`); - await refreshNodes(); - } catch (error) { - toast.error((error as Error).message || 'Failed to delete node'); - } finally { - setDeleteOpen(false); - setDeletingNode(null); - } - }; - const testConnection = async (node: Node) => { setTesting(node.id); setTestResult(null); @@ -252,7 +87,6 @@ export function NodeManager() { } else { toast.error(`Failed to connect: ${result.error}`); } - await refreshNodes(); } catch (error) { toast.error((error as Error).message || 'Connection test failed'); } finally { @@ -305,119 +139,6 @@ export function NodeManager() { : ; }; - 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. 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 })} - /> -

- 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 */} @@ -425,48 +146,11 @@ export function NodeManager() { { - setFormData(defaultFormData); - setCreateOpen(true); - }} + onClick={openCreate} > Add node - { - setCreateOpen(open); - if (open) setFormData(defaultFormData); - }} - size="lg" - > - - - {renderFormFields()} - - setCreateOpen(false)}>Cancel - } - primary={ - - Add node - - } - /> -
@@ -681,7 +365,7 @@ export function NodeManager() { variant="ghost" size="icon" className="h-8 w-8" - onClick={() => openEditDialog(node)} + onClick={() => openEdit(node)} aria-label="Edit node" > @@ -699,7 +383,7 @@ export function NodeManager() { variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" - onClick={() => { setDeletingNode(node); setDeleteOpen(true); }} + onClick={() => openDelete(node)} aria-label="Delete node" > @@ -738,121 +422,7 @@ export function NodeManager() { )} - {/* Edit Modal */} - - - - {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. -

- -
- )} -
- { setEditOpen(false); setEditingNodeId(null); }}>Cancel - } - primary={ - - } - /> -
- - {/* Pilot enrollment Modal (create + regenerate flows both open this) */} - { - if (!open) { - setActiveEnrollment(null); - setEnrollmentCopied(false); - setCreateOpen(false); - setFormData(defaultFormData); - } - }} - size="xl" - > - - - {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. -

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

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

-
+ {NodeActionModals} ); } diff --git a/frontend/src/components/nodes/useNodeActions.tsx b/frontend/src/components/nodes/useNodeActions.tsx new file mode 100644 index 00000000..7444d698 --- /dev/null +++ b/frontend/src/components/nodes/useNodeActions.tsx @@ -0,0 +1,536 @@ +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; + dockerRun: string; +} + +interface NodeFormData { + name: string; + type: 'local' | 'remote'; + mode: NodeMode; + api_url: string; + api_token: string; + compose_dir: string; + is_default: boolean; +} + +const defaultFormData: NodeFormData = { + name: '', + type: 'remote', + mode: 'pilot_agent', + api_url: '', + api_token: '', + compose_dir: '/app/compose', + 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 { + 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`); + + 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.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 openCreate = useCallback(() => { + setFormData(defaultFormData); + setCreateOpen(true); + }, []); + + const openEdit = useCallback((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, + }); + setOriginalEditValues({ + api_url: node.api_url || '', + api_token: node.api_token || '', + }); + setEditingNodeId(node.id); + setEditOpen(true); + }, []); + + const openDelete = useCallback((node: Node) => { + setDeletingNode(node); + setDeleteOpen(true); + }, []); + + 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. 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 })} + /> +

+ 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. +

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

+ +
+ )} +
+ { + setEditOpen(false); + setEditingNodeId(null); + setOriginalEditValues(null); + }} + > + Cancel + + } + primary={ + + } + /> +
+ + { + if (!open) { + setActiveEnrollment(null); + setEnrollmentCopied(false); + setCreateOpen(false); + setFormData(defaultFormData); + } + }} + size="xl" + > + + + {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. +

+ +
+ + )} +
+ { + 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, + }; +}