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 = () => (
-
-
- Name
- setFormData({ ...formData, name: e.target.value })}
- />
-
-
-
-
Type
-
setFormData({ ...formData, type: val as 'local' | 'remote', api_url: '', api_token: '' })}
- >
-
-
-
-
-
-
-
- Local - Docker socket on this machine
-
-
-
-
-
- Remote - another Sencho instance
-
-
-
-
-
-
- {formData.type === 'remote' && (
-
-
Mode
-
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' && (
- <>
-
-
Sencho API URL
-
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).
-
-
-
- )}
-
-
-
-
API Token
-
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.
-
-
- >
- )}
-
-
-
Compose Directory
-
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.
-
-
{
- const node = nodes.find((n) => n.id === editingNodeId);
- if (node) regenerateEnrollment(node);
- }}
- className="gap-1"
- >
-
- Regenerate enrollment token
-
-
- )}
-
- { setEditOpen(false); setEditingNodeId(null); }}>Cancel
- }
- primary={
-
- Save changes
-
- }
- />
-
-
- {/* 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.
-
-
- {enrollmentCopied ? : }
- {enrollmentCopied ? 'Copied' : 'Copy command'}
-
-
- >
- )}
-
- {
- 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 = () => (
+
+
+ Name
+ setFormData({ ...formData, name: e.target.value })}
+ />
+
+
+
+
Type
+
setFormData({ ...formData, type: val as 'local' | 'remote', api_url: '', api_token: '' })}
+ >
+
+
+
+
+
+
+
+ Local - Docker socket on this machine
+
+
+
+
+
+ Remote - another Sencho instance
+
+
+
+
+
+
+ {formData.type === 'remote' && (
+
+
Mode
+
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' && (
+ <>
+
+
Sencho API URL
+
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).
+
+
+
+ )}
+
+
+
+
API Token
+
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.
+
+
+ >
+ )}
+
+
+
Compose Directory
+
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.
+
+
{
+ const node = nodes.find((n) => n.id === editingNodeId);
+ if (node) regenerateEnrollment(node);
+ }}
+ className="gap-1"
+ >
+
+ Regenerate enrollment token
+
+
+ )}
+
+ {
+ setEditOpen(false);
+ setEditingNodeId(null);
+ setOriginalEditValues(null);
+ }}
+ >
+ Cancel
+
+ }
+ primary={
+
+ Save changes
+
+ }
+ />
+
+
+ {
+ 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.
+
+
+ {enrollmentCopied ? : }
+ {enrollmentCopied ? 'Copied' : 'Copy command'}
+
+
+ >
+ )}
+
+ {
+ 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,
+ };
+}