import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNodes } 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 { Button } from './ui/button'; 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 { AlertTriangle, 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'; import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus'; import { resetFleetSyncAnchor, STICKY_CONTROL_IDENTITY_MISMATCH } from '@/lib/fleetSyncApi'; import type { SecurityTab } from '@/lib/events'; 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' | 'security'; nodeId?: number; /** Target tab when navigating to the Security view. */ tab?: SecurityTab; } export function NodeManager() { const { isPaid } = useLicense(); const { isAdmin, can } = useAuth(); const canEditLabels = isAdmin; // Mirror the backend node:manage guard. This top-level flag checks the global // role only (admin or global node-admin); the per-row Test/Edit/Delete buttons // below additionally honor scoped per-node grants via can('node:manage', 'node', id). // Admins resolve immediately via isAdmin; node-admins once /permissions/me lands. // Generate-token and reset-anchor below stay admin-only to match their stricter // backend guards (requireAdmin, and requireAdmin + requirePaid). const canManageNodes = isAdmin || can('node:manage'); const { nodes, refreshNodeMeta } = useNodes(); useMastheadStats([ { label: 'NODES', value: `${nodes.length}` }, { label: 'REMOTE', value: `${nodes.filter(n => n.type === 'remote').length}`, tone: 'subtitle', }, ]); const [testing, setTesting] = useState(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); // Per-node scheduling summary const [nodeSummary, setNodeSummary] = useState>({}); const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({ onTestResult: (result) => setTestResult(result), }); const { statuses: syncStatuses, refresh: refreshSyncStatuses } = useFleetSyncStatus(); const [resettingAnchor, setResettingAnchor] = useState(null); // Per-node aggregate of CONTROL_IDENTITY_MISMATCH sticky errors. All resources // for one peer share the same root cause (the peer's cached fingerprint), so // collapse to one entry per node id and surface a single banner. const anchorMismatches = useMemo(() => { const byNode = new Map(); for (const row of syncStatuses) { if (row.sticky_error_code !== STICKY_CONTROL_IDENTITY_MISMATCH) continue; const existing = byNode.get(row.node_id); if (existing) { existing.resources.push(row.resource); if (!existing.expected && row.sticky_error_expected) existing.expected = row.sticky_error_expected; if (!existing.got && row.sticky_error_got) existing.got = row.sticky_error_got; } else { byNode.set(row.node_id, { expected: row.sticky_error_expected, got: row.sticky_error_got, resources: [row.resource], }); } } return Array.from(byNode.entries()).map(([nodeId, agg]) => { const node = nodes.find((n) => n.id === nodeId); return { nodeId, node, ...agg }; }).filter((entry) => entry.node !== undefined); }, [syncStatuses, nodes]); const handleResetAnchor = async (nodeId: number) => { setResettingAnchor(nodeId); try { await resetFleetSyncAnchor(nodeId); toast.success('Anchor reset. Security policy sync will resume on the next push.'); refreshSyncStatuses(); } catch (error) { toast.error((error as Error).message || 'Failed to reset anchor on peer'); } finally { setResettingAnchor(null); } }; 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 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 }); // The test dropped the server-side metadata cache; force a client refresh // so the version pill and capability gates reflect the node's current state // now, instead of waiting out the dashboard's metadata TTL. void refreshNodeMeta(node.id, true); } else { toast.error(`Failed to connect: ${result.error}`); } } 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' ? : ; }; return (
{/* Actions (node management is admin / node-admin only, mirroring the node:manage backend guard). The read-only table below stays visible to every role with node:read. */} {canManageNodes && ( <>
Add node
)} {/* Generate a node token so THIS instance can serve as a remote target. Admin-only, matching the requireAdmin guard on /auth/generate-node-token. */} {isAdmin && (

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}
)}
)} {/* Sync issues: surfaces FleetSync sticky errors (currently CONTROL_IDENTITY_MISMATCH). */} {anchorMismatches.length > 0 && (
{anchorMismatches.map(({ nodeId, node, expected, got, resources }) => (
Node "{node?.name ?? `id ${nodeId}`}" is anchored to another central
Security policy sync is paused for {resources.join(', ')}. {expected && got && ( <> This peer is anchored to {expected}; this central is {got}. )} {' '}Reset the anchor on the peer to resume sync, or remove the node from this fleet.
{isAdmin && isPaid && ( )} {node && !node.is_default && (isAdmin || can('node:manage', 'node', String(nodeId))) && ( )}
))}
)} {/* Nodes Table */}
Name Type Mode Endpoint Status Labels Schedules Updates Actions {nodes.map((node) => { const canManageThis = isAdmin || can('node:manage', 'node', String(node.id)); return ( {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 {canManageThis && ( Test Connection )} {canManageThis && ( Edit Node )} {!node.is_default && canManageThis && ( 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}
)} {NodeActionModals}
); }