import { useState, useEffect, useRef, useMemo } from 'react'; type Theme = 'light' | 'dark' | 'auto'; import Editor from '@monaco-editor/react'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import HostConsole from './HostConsole'; import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; import ResourcesView from './ResourcesView'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight'; import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; import { LabelAssignPopover } from './LabelAssignPopover'; import { UserProfileDropdown } from './UserProfileDropdown'; import { apiFetch, fetchForNode } from '@/lib/api'; import { isValidVersion } from '@/lib/version'; import { toast } from '@/components/ui/toast-store'; import { Label } from './ui/label'; import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; import { ScrollArea } from './ui/scroll-area'; import { Checkbox } from './ui/checkbox'; import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields'; import { Skeleton } from './ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { cn } from '@/lib/utils'; import { SettingsModal } from './SettingsModal'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; import StructuredLogViewer from './StructuredLogViewer'; import { Sparkline } from './ui/sparkline'; import { GlobalObservabilityView } from './GlobalObservabilityView'; import { FleetView } from './FleetView'; import { AuditLogView } from './AuditLogView'; import ScheduledOperationsView from './ScheduledOperationsView'; import AutoUpdateReadinessView from './AutoUpdateReadinessView'; import { SecurityHistoryView } from './SecurityHistoryView'; import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; import type { SenchoNavigateDetail } from './NodeManager'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; interface ContainerInfo { Id: string; Names: string[]; State: string; Status?: string; Ports?: { PrivatePort: number, PublicPort: number }[]; healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none'; Image?: string; ImageID?: string; } interface StackStatus { [key: string]: 'running' | 'exited' | 'unknown'; } interface StackStatusInfo { status: 'running' | 'exited' | 'unknown'; mainPort?: number; } type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback'; interface BulkActionResult { stackName: string; success: boolean; error?: string; } const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; // Extract the "up X time" portion from a Docker status string like // "Up 12 days (healthy)" → "up 12 days". Returns null when the container // is not in an uptime-reporting state (exited, created, restarting, etc.). const extractUptime = (status: string | undefined): string | null => { if (!status) return null; const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i); if (!match) return null; return `up ${match[1].trim()}`; }; const healthcheckLabel = (health?: 'healthy' | 'unhealthy' | 'starting' | 'none'): string | null => { if (!health || health === 'none') return null; if (health === 'healthy') return 'healthcheck passing'; if (health === 'unhealthy') return 'healthcheck failing'; return 'healthcheck starting'; }; type StackPill = { label: string; dotClass: string; className: string; pulse: boolean }; const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => { if (!containers || containers.length === 0) return null; const running = containers.some(c => c.State === 'running'); if (!running) { return { label: 'exited', dotClass: 'bg-destructive', className: 'border-destructive/40 bg-destructive/10 text-destructive', pulse: false, }; } const anyUnhealthy = containers.some(c => c.healthStatus === 'unhealthy'); const anyStarting = containers.some(c => c.healthStatus === 'starting'); const anyHealthy = containers.some(c => c.healthStatus === 'healthy'); if (anyUnhealthy) { return { label: 'running · unhealthy', dotClass: 'bg-destructive', className: 'border-destructive/40 bg-destructive/10 text-destructive', pulse: true, }; } if (anyStarting) { return { label: 'running · starting', dotClass: 'bg-warning', className: 'border-warning/40 bg-warning/10 text-warning', pulse: true, }; } if (anyHealthy) { return { label: 'running · healthy', dotClass: 'bg-success', className: 'border-success/40 bg-success/10 text-success', pulse: true, }; } return { label: 'running', dotClass: 'bg-success', className: 'border-success/40 bg-success/10 text-success', pulse: true, }; }; export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); const { status: trivy } = useTrivyStatus(); const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false); const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); const [copiedDigest, setCopiedDigest] = useState(null); const copiedDigestTimerRef = useRef(null); useEffect(() => { return () => { if (copiedDigestTimerRef.current !== null) { window.clearTimeout(copiedDigestTimerRef.current); } }; }, []); const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). const nodesRef = useRef([]); nodesRef.current = nodes; // Tracks cleanup functions for per-remote-node notification WebSocket connections. const remoteNotifWsRef = useRef void>>(new Map()); const [files, setFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(null); const [content, setContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); const [envContent, setEnvContent] = useState(''); const [originalEnvContent, setOriginalEnvContent] = useState(''); const [envExists, setEnvExists] = useState(false); const [envFiles, setEnvFiles] = useState([]); const [selectedEnvFile, setSelectedEnvFile] = useState(''); const [containers, setContainers] = useState([]); const [containerStats, setContainerStats] = useState>({}); // Incoming WebSocket stats are written here first (no re-render), then flushed // to React state in one batched update every 1.5 s. const pendingStatsRef = useRef>({}); // Raw rx/tx byte totals used for rate calculation. Never cleared on flush so // the delta is always computed against the most recent known value, avoiding // the stale-closure bug that occurs when reading containerStats directly. const rawBytesRef = useRef>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => { if (typeof window === 'undefined') return 'structured'; return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured'; }); useEffect(() => { try { localStorage.setItem('sencho.stackView.logsMode', logsMode); } catch { /* ignore */ } }, [logsMode]); const [gitSourceOpen, setGitSourceOpen] = useState(false); const [gitSourcePendingMap, setGitSourcePendingMap] = useState>({}); const monacoEditorRef = useRef(null); const pendingStackLoadRef = useRef(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty'); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newStackName, setNewStackName] = useState(''); // "From Docker Run" tab state const [dockerRunInput, setDockerRunInput] = useState(''); const [convertedYaml, setConvertedYaml] = useState(null); const [isConverting, setIsConverting] = useState(false); const [creatingFromDockerRun, setCreatingFromDockerRun] = useState(false); // "From Git" tab state const [gitRepoUrl, setGitRepoUrl] = useState(''); const [gitBranch, setGitBranch] = useState('main'); const [gitComposePath, setGitComposePath] = useState('compose.yaml'); const [gitSyncEnv, setGitSyncEnv] = useState(false); const [gitAuthType, setGitAuthType] = useState<'none' | 'token'>('none'); const [gitToken, setGitToken] = useState(''); const [gitApplyMode, setGitApplyMode] = useState('review'); const [gitDeployNow, setGitDeployNow] = useState(false); const [creatingFromGit, setCreatingFromGit] = useState(false); const [stackToDelete, setStackToDelete] = useState(null); const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); const [pendingUnsavedNode, setPendingUnsavedNode] = useState(null); const [isLoading, setIsLoading] = useState(false); const [stackActions, setStackActions] = useState>({}); const stackActionsRef = useRef>({}); stackActionsRef.current = stackActions; const setStackAction = (stackFile: string, action: StackAction) => { setStackActions(prev => ({ ...prev, [stackFile]: action })); }; const clearStackAction = (stackFile: string) => { setStackActions(prev => { const next = { ...prev }; delete next[stackFile]; return next; }); }; const isStackBusy = (stackFile: string) => stackFile in stackActions; const getStackMenuVisibility = (file: string) => { const status = stackStatuses[file]; return { showDeploy: status !== 'running', showStop: status === 'running', showRestart: status === 'running', showUpdate: status === 'running', }; }; const openStackApp = (file: string) => { const port = stackPorts[file]; if (!port) return; const host = activeNode?.type === 'remote' && activeNode?.api_url ? new URL(activeNode.api_url).hostname : window.location.hostname; window.open(`http://${host}:${port}`, '_blank'); }; const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null; const [isScanning, setIsScanning] = useState(false); const [isFileLoading, setIsFileLoading] = useState(false); const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null }); const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('sencho-theme') as Theme | null; if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved; return 'dark'; // Default to dark mode }); const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches ); const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'security-history'>('dashboard'); const [filterNodeId, setFilterNodeId] = useState(null); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [remoteStackResults, setRemoteStackResults] = useState>>({}); const [remoteSearchLoading, setRemoteSearchLoading] = useState(false); const [stackStatuses, setStackStatuses] = useState({}); const [stackPorts, setStackPorts] = useState>({}); const [labels, setLabels] = useState([]); const [stackLabelMap, setStackLabelMap] = useState>({}); const [activeLabelFilters, setActiveLabelFilters] = useState>(new Set()); const [bulkActionLabel, setBulkActionLabel] = useState(null); const [bulkAction, setBulkAction] = useState(''); const [bulkActionOpen, setBulkActionOpen] = useState(false); const [bulkActionRunning, setBulkActionRunning] = useState(false); // Bash exec modal state const [bashModalOpen, setBashModalOpen] = useState(false); const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null); // LogViewer state const [logViewerOpen, setLogViewerOpen] = useState(false); const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null); // Image update checker state const [stackUpdates, setStackUpdates] = useState>({}); // Notifications & Settings state const [notifications, setNotifications] = useState([]); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels'>('account'); const [alertSheetOpen, setAlertSheetOpen] = useState(false); const [alertSheetStack, setAlertSheetStack] = useState(''); const [autoHealStackName, setAutoHealStackName] = useState(null); // Mobile navigation sheet state const [mobileNavOpen, setMobileNavOpen] = useState(false); const openAlertSheet = (stackName: string) => { setAlertSheetStack(stackName); setAlertSheetOpen(true); }; // Navigation items (permission-aware, data-driven) const navItems = useMemo(() => { const items: Array<{ value: string; label: string; icon: LucideIcon }> = [ { value: 'dashboard', label: 'Home', icon: Home }, { value: 'fleet', label: 'Fleet', icon: Radar }, ]; items.push( { value: 'resources', label: 'Resources', icon: HardDrive }, { value: 'templates', label: 'App Store', icon: CloudDownload }, { value: 'global-observability', label: 'Logs', icon: Activity }, ); if (isPaid && isAdmin) { items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw }); } if (isPaid && license?.variant === 'admiral') { if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal }); if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } return items; }, [isAdmin, isPaid, license?.variant, can]); // Only highlight a tab if activeView matches a nav item const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined; // Reset editor state (extracted from Home button onClick) const resetEditorState = () => { setSelectedFile(null); setContent(''); setOriginalContent(''); setEnvContent(''); setOriginalEnvContent(''); setEnvFiles([]); setSelectedEnvFile(''); setEnvExists(false); setContainers([]); setIsEditing(false); }; const handleNavigate = (value: string) => { if (value === activeView) return; if (value === 'dashboard') { resetEditorState(); setActiveView('dashboard'); } else { setActiveView(value as typeof activeView); setFilterNodeId(null); } }; // Listen for system dark mode changes (for 'auto' theme) useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); // Apply dark class and persist theme preference useEffect(() => { document.documentElement.classList.toggle('dark', isDarkMode); localStorage.setItem('sencho-theme', theme); }, [isDarkMode, theme]); // ⌘K / Ctrl+K — focus stack search input useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); const input = document.querySelector('[cmdk-input]'); input?.focus(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); // Listen for cross-component navigation (e.g., NodeManager → Schedules) useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail?.view) { setActiveView(detail.view); setFilterNodeId(detail.nodeId ?? null); } }; window.addEventListener(SENCHO_NAVIGATE_EVENT, handler); return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler); }, []); // Global stack search: when the user types a query, fan out to every other online // node and fetch its stack list so the sidebar can surface matches from the whole // fleet. Debounced 250ms; cleared as soon as the query is empty. useEffect(() => { const query = searchQuery.trim().toLowerCase(); if (!query) { setRemoteStackResults({}); setRemoteSearchLoading(false); return; } const otherNodes = nodes.filter(n => n.id !== activeNode?.id && n.status !== 'offline'); if (otherNodes.length === 0) { setRemoteStackResults({}); return; } const controller = new AbortController(); const timer = setTimeout(async () => { setRemoteSearchLoading(true); try { const entries = await Promise.all(otherNodes.map(async (node) => { const empty = [] as Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>; try { const [listRes, statusRes] = await Promise.all([ fetchForNode('/stacks', node.id, { signal: controller.signal }), fetchForNode('/stacks/statuses', node.id, { signal: controller.signal }), ]); if (!listRes.ok) return [node.id, empty] as const; const listData = await listRes.json(); const list: string[] = Array.isArray(listData) ? listData : []; const statuses: Record = {}; if (statusRes.ok) { const raw = await statusRes.json(); for (const [key, val] of Object.entries(raw)) { if (typeof val === 'string') { statuses[key] = val as 'running' | 'exited' | 'unknown'; } else if (val && typeof val === 'object' && 'status' in val) { statuses[key] = (val as StackStatusInfo).status; } } } const matches = list .filter(f => f.toLowerCase().includes(query)) .map(file => ({ file, status: statuses[file] ?? 'unknown' as const })); return [node.id, matches] as const; } catch { return [node.id, empty] as const; } })); if (controller.signal.aborted) return; const next: Record> = {}; for (const [id, matches] of entries) { if (matches.length > 0) next[id] = matches; } setRemoteStackResults(next); } finally { if (!controller.signal.aborted) setRemoteSearchLoading(false); } }, 250); return () => { clearTimeout(timer); controller.abort(); }; }, [searchQuery, activeNode?.id, nodes]); // Force Monaco to re-measure its container after the tab switch DOM settles. // Monaco's internal child is position:static with an explicit pixel height that // creates a circular CSS dependency (Monaco drives card height → grid height → Monaco). // Fix: reset Monaco to 0×0 first (breaks the cycle), then trigger a forced synchronous // reflow so the container has its CSS-correct size before Monaco re-measures. useEffect(() => { const id = requestAnimationFrame(() => { const editor = monacoEditorRef.current; if (!editor) return; editor.layout({ width: 0, height: 0 }); // collapse → breaks CSS circular dependency editor.layout(); // forced reflow → measures correct container size }); return () => cancelAnimationFrame(id); }, [activeTab]); const refreshStacks = async (background = false): Promise => { if (!background) setIsLoading(true); try { const res = await apiFetch('/stacks'); if (!res.ok) { setFiles([]); return []; } const data = await res.json(); const fileList: string[] = Array.isArray(data) ? data : []; setFiles(fileList); // Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes) const statusRes = await apiFetch('/stacks/statuses'); let bulkStatuses: Record | null = null; const bulkPorts: Record = {}; if (statusRes.ok) { const raw = await statusRes.json(); bulkStatuses = {}; // Handle both old format (plain string) and new format ({ status, mainPort }) for (const [key, val] of Object.entries(raw)) { if (typeof val === 'string') { bulkStatuses[key] = val as 'running' | 'exited' | 'unknown'; } else if (val && typeof val === 'object' && 'status' in val) { const info = val as StackStatusInfo; bulkStatuses[key] = info.status; if (info.mainPort) bulkPorts[key] = info.mainPort; } } } else { // Fallback: query each stack individually (remote node may not have bulk endpoint) const statusResults = await Promise.allSettled( fileList.map(async (file) => { const containersRes = await apiFetch(`/stacks/${file}/containers`); if (!containersRes.ok) return { file, status: 'unknown' as const }; const containers = await containersRes.json(); const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running'); return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) }; }) ); bulkStatuses = {}; for (const result of statusResults) { if (result.status === 'fulfilled') { bulkStatuses[result.value.file] = result.value.status; } } } setStackStatuses(prev => { const next: StackStatus = {}; for (const file of fileList) { const status = bulkStatuses?.[file] ?? 'unknown'; next[file] = (file in stackActionsRef.current) ? (prev[file] ?? status) : status; } return next; }); setStackPorts(prev => { const keys = Object.keys(bulkPorts); if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev; return bulkPorts; }); refreshLabels(); return fileList; } catch (error) { console.error('Failed to refresh stacks:', error); setFiles([]); return []; } finally { setIsLoading(false); } }; const setOptimisticStatus = (stackFile: string, status: 'running' | 'exited') => { setStackStatuses(prev => ({ ...prev, [stackFile]: status })); }; const refreshLabels = async () => { if (!isPaid) return; try { const [labelsRes, assignmentsRes] = await Promise.all([ apiFetch('/labels'), apiFetch('/labels/assignments'), ]); if (labelsRes.ok) setLabels(await labelsRes.json()); if (assignmentsRes.ok) setStackLabelMap(await assignmentsRes.json()); } catch { // Labels are non-critical; fail silently } }; /** * Populate the per-stack "pending git source update" map. Runs on mount and * whenever a git-source change is signalled by the panel. Backend failure * leaves the map empty, which is the correct fallback (no badges shown). */ const refreshGitSourcePending = async () => { try { const res = await apiFetch('/git-sources'); if (!res.ok) return; const sources: Array<{ stack_name: string; pending_commit_sha: string | null }> = await res.json(); const map: Record = {}; for (const s of sources) { if (s.pending_commit_sha) map[s.stack_name] = true; } setGitSourcePendingMap(map); } catch { // Non-critical; leave prior state. } }; const handleScanStacks = async () => { if (isScanning) return; setIsScanning(true); const previousStacks = [...files]; try { const currentStacks = await refreshStacks(); const added = currentStacks.filter(s => !previousStacks.includes(s)); const removed = previousStacks.filter(s => !currentStacks.includes(s)); if (added.length > 0) { toast.success(`Found ${added.length} new stack${added.length !== 1 ? 's' : ''}: ${added.join(', ')}`); } if (removed.length > 0) { toast.info(`${removed.length} stack${removed.length !== 1 ? 's' : ''} no longer detected: ${removed.join(', ')}`); } if (added.length === 0 && removed.length === 0) { toast.info('No new stacks found.'); } } catch (error: unknown) { const err = error as Record; const data = err?.data as Record | undefined; toast.error((err?.message as string) || (err?.error as string) || (data?.error as string) || 'Something went wrong.'); } finally { setIsScanning(false); } }; // Notification WS push - subscribe to local real-time alerts. // Initial history load is handled by the [nodes] effect below. useEffect(() => { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsBase = `${wsProtocol}//${window.location.host}`; let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let isMounted = true; let retryCount = 0; const MAX_RETRY_DELAY_MS = 30000; const connect = () => { if (!isMounted) return; ws = new WebSocket(`${wsBase}/ws/notifications`); ws.onopen = () => { if (!isMounted) { // Component unmounted while the handshake was in-flight (React StrictMode double-mount) ws?.close(); return; } retryCount = 0; // Reset backoff on successful connect }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data as string); if (msg.type === 'notification' && msg.payload) { const localNode = nodesRef.current.find(n => n.type === 'local'); const tagged: NotificationItem = { ...(msg.payload as Omit), nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local', }; setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp)); } } catch (e) { console.error('[WS notifications] parse error', e); } }; ws.onclose = (event) => { if (!isMounted) return; // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS); retryCount++; console.debug(`[WS notifications] closed (code=${event.code}), reconnecting in ${delay}ms (attempt ${retryCount})`); reconnectTimer = setTimeout(connect, delay); }; ws.onerror = (event) => { // onerror always fires before onclose - log it and let onclose handle reconnect console.warn('[WS notifications] error event', event); }; }; connect(); return () => { isMounted = false; if (reconnectTimer) clearTimeout(reconnectTimer); // Only close an already-open connection. If still CONNECTING, let onopen // detect isMounted=false and close then - avoids the browser warning // "WebSocket is closed before the connection is established". if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); } }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // Re-fetch all notifications when the nodes list changes (e.g. remote node added/removed). // nodesRef ensures fetchNotifications always reads the latest nodes at call time. useEffect(() => { fetchNotifications(); }, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps // Open / close per-remote-node notification WebSocket connections as the nodes list changes. // Uses remoteNotifWsRef to avoid tearing down existing connections on unrelated node updates. useEffect(() => { const remoteNodes = nodes.filter(n => n.type === 'remote'); const currentIds = new Set(remoteNotifWsRef.current.keys()); const newIds = new Set(remoteNodes.map(n => n.id)); // Close connections for nodes that are no longer registered as remote for (const id of currentIds) { if (!newIds.has(id)) { remoteNotifWsRef.current.get(id)?.(); remoteNotifWsRef.current.delete(id); } } // Open connections for newly-added remote nodes for (const rn of remoteNodes) { if (remoteNotifWsRef.current.has(rn.id)) continue; let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let active = true; let retryCount = 0; const connect = () => { if (!active) return; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/notifications?nodeId=${rn.id}`); ws.onopen = () => { if (!active) { ws?.close(); } else { retryCount = 0; } }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data as string); if (msg.type === 'notification' && msg.payload) { // Read node name from ref so it stays fresh even if the node was renamed const current = nodesRef.current.find(n => n.id === rn.id); setNotifications(prev => [{ ...msg.payload as Omit, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev] .sort((a, b) => b.timestamp - a.timestamp) ); } } catch (e) { console.error(`[WS notifications:${rn.name}] parse error`, e); } }; ws.onclose = () => { if (!active) return; const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); retryCount++; reconnectTimer = setTimeout(connect, delay); }; ws.onerror = (e) => console.warn(`[WS notifications:${rn.name}] error`, e); }; connect(); remoteNotifWsRef.current.set(rn.id, () => { active = false; if (reconnectTimer) clearTimeout(reconnectTimer); if (ws && ws.readyState === WebSocket.OPEN) ws.close(); }); } }, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps // Cleanup all remote notification WebSocket connections on unmount useEffect(() => { return () => { for (const cleanup of remoteNotifWsRef.current.values()) cleanup(); remoteNotifWsRef.current.clear(); }; }, []); // Re-fetch stacks whenever the active node changes (or becomes available on mount). // Also clears any stale editor/container state that belonged to the previous node. useEffect(() => { if (!activeNode) return; const pendingStack = pendingStackLoadRef.current; pendingStackLoadRef.current = null; setSelectedFile(null); setContent(''); setOriginalContent(''); setEnvContent(''); setOriginalEnvContent(''); setContainers([]); setIsEditing(false); if (pendingStack) { loadFile(pendingStack); } else { setActiveView('dashboard'); } refreshStacks(); fetchImageUpdates(); refreshGitSourcePending(); // Poll for image update results every 5 minutes so background checks are picked up const imageUpdateInterval = setInterval(fetchImageUpdates, 5 * 60 * 1000); return () => clearInterval(imageUpdateInterval); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const fetchNotifications = async () => { try { const currentNodes = nodesRef.current; const localNode = currentNodes.find(n => n.type === 'local'); const remoteNodes = currentNodes.filter(n => n.type === 'remote'); const [localResult, ...remoteResults] = await Promise.allSettled([ apiFetch('/notifications', { localOnly: true }), ...remoteNodes.map(n => fetchForNode('/notifications', n.id)), ]); const all: NotificationItem[] = []; if (localResult.status === 'fulfilled' && localResult.value.ok) { const data = await localResult.value.json() as Omit[]; data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' })); } for (let i = 0; i < remoteNodes.length; i++) { const result = remoteResults[i]; if (result?.status === 'fulfilled' && result.value.ok) { const data = await result.value.json() as Omit[]; const rn = remoteNodes[i]; data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name })); } } all.sort((a, b) => b.timestamp - a.timestamp); setNotifications(all); } catch (e) { console.error('[Notifications] fetch error:', e); } }; const fetchImageUpdates = async () => { try { const res = await apiFetch('/image-updates'); if (res.ok) { const data = await res.json(); setStackUpdates(data); } } catch (e: unknown) { console.error('[ImageUpdates] fetch failed:', e); } }; const markAllRead = async () => { try { const localNode = nodesRef.current.find(n => n.type === 'local'); const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read && n.nodeId != null).map(n => n.nodeId as number))]; await Promise.allSettled(unreadNodeIds.map(nodeId => nodeId === localNode?.id ? apiFetch('/notifications/read', { method: 'POST', localOnly: true }) : fetchForNode('/notifications/read', nodeId, { method: 'POST' }) )); setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 }))); } catch (e: unknown) { const err = e as { message?: string; error?: string }; toast.error(err?.message || err?.error || 'Failed to mark notifications as read'); } }; const deleteNotification = async (notif: NotificationItem) => { try { const localNode = nodesRef.current.find(n => n.type === 'local'); if (notif.nodeId === localNode?.id) { await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true }); } else if (notif.nodeId != null) { await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' }); } setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId))); } catch (e: unknown) { const err = e as { message?: string; error?: string }; toast.error(err?.message || err?.error || 'Failed to delete notification'); } }; const clearAllNotifications = async () => { try { const localNode = nodesRef.current.find(n => n.type === 'local'); const uniqueNodeIds = [...new Set(notifications.filter(n => n.nodeId != null).map(n => n.nodeId as number))]; await Promise.allSettled(uniqueNodeIds.map(nodeId => nodeId === localNode?.id ? apiFetch('/notifications', { method: 'DELETE', localOnly: true }) : fetchForNode('/notifications', nodeId, { method: 'DELETE' }) )); setNotifications([]); } catch (e: unknown) { const err = e as { message?: string; error?: string }; toast.error(err?.message || err?.error || 'Failed to clear notifications'); } }; useEffect(() => { const wsMap: Record = {}; (containers || []).forEach(container => { if (!container?.Id) return; try { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const activeNodeId = localStorage.getItem('sencho-active-node') || ''; const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`); wsMap[container.Id] = ws; ws.onopen = () => ws.send(JSON.stringify({ action: 'streamStats', containerId: container.Id, nodeId: activeNodeId || undefined })); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); // Skip initial empty chunks where stats fields are missing if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return; const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage; const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0); const onlineCpus = data.cpu_stats.online_cpus || 1; const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * onlineCpus * 100).toFixed(2) : '0.00'; const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB'; let currentRx = 0; let currentTx = 0; if (data.networks) { Object.values(data.networks as Record).forEach((net) => { currentRx += net.rx_bytes || 0; currentTx += net.tx_bytes || 0; }); } // Rate is derived from rawBytesRef which is never cleared on flush, // so the delta is always accurate - no stale-closure risk. const prevRaw = rawBytesRef.current[container.Id]; const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0; const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0; rawBytesRef.current[container.Id] = { lastRx: currentRx, lastTx: currentTx }; const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`; // Write into the buffer ref only - zero re-render cost. pendingStatsRef.current[container.Id] = { cpu: cpuPercent + '%', ram: ramUsage, net: netIO, lastRx: currentRx, lastTx: currentTx, cpuNum: parseFloat(cpuPercent) || 0, memNum: data.memory_stats.usage / (1024 * 1024), netInNum: rxRate, netOutNum: txRate, }; } catch { // Ignore parse errors } }; } catch { // Ignore WebSocket errors } }); // Flush buffered stats into React state once every 1.5 s. // Snapshot + clear the buffer BEFORE calling setState so the updater // function remains pure (no side-effects inside it). const flushInterval = setInterval(() => { const pending = pendingStatsRef.current; if (Object.keys(pending).length === 0) return; pendingStatsRef.current = {}; setContainerStats(prev => { const next = { ...prev }; const HISTORY_CAP = 60; for (const [id, newStats] of Object.entries(pending)) { const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] }; const history = { cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP), mem: [...prior.mem, newStats.memNum].slice(-HISTORY_CAP), netIn: [...prior.netIn, newStats.netInNum].slice(-HISTORY_CAP), netOut: [...prior.netOut, newStats.netOutNum].slice(-HISTORY_CAP), }; next[id] = { cpu: newStats.cpu, ram: newStats.ram, net: newStats.net, lastRx: newStats.lastRx, lastTx: newStats.lastTx, history, }; } return next; }); }, 1500); return () => { clearInterval(flushInterval); // Discard buffered stats for the old stack so stale entries don't // briefly appear when a new stack is selected. pendingStatsRef.current = {}; Object.values(wsMap).forEach(ws => { try { ws.close(); } catch { /* ignore */ } }); }; }, [containers]); // eslint-disable-line react-hooks/exhaustive-deps const hasUnsavedChanges = () => content !== originalContent || envContent !== originalEnvContent; // Global-search result click: switch the active node, clear the query so the // sidebar snaps back to the new node's full stack list, then open the stack. // setActiveNode writes to localStorage synchronously, so the next apiFetch // picks up the new node-id header without waiting for a re-render. const loadFileOnNode = async (node: Node, filename: string) => { if (!filename) return; if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) { setPendingUnsavedNode(node); setPendingUnsavedLoad(filename); return; } setActiveNode(node); setSearchQuery(''); await loadFile(filename); }; const loadFile = async (filename: string) => { if (!filename) return; // Guard: if there are unsaved changes and we're switching to a different stack, confirm first if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) { setPendingUnsavedLoad(filename); return; } setIsFileLoading(true); setIsEditing(false); // Reset to view mode when loading a new file try { const res = await apiFetch(`/stacks/${filename}`); const text = await res.text(); setSelectedFile(filename); setActiveView('editor'); setContent(text || ''); setOriginalContent(text || ''); // Load env files try { const envsRes = await apiFetch(`/stacks/${filename}/envs`); if (envsRes.ok) { const { envFiles } = await envsRes.json(); if (envFiles && envFiles.length > 0) { setEnvFiles(envFiles); const firstFile = envFiles[0]; setSelectedEnvFile(firstFile); setEnvExists(true); // Load specific env file content const envContentRes = await apiFetch(`/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`); if (envContentRes.ok) { const envText = await envContentRes.text(); setEnvContent(envText || ''); setOriginalEnvContent(envText || ''); } else { setEnvContent(''); setOriginalEnvContent(''); } } else { setEnvFiles([]); setSelectedEnvFile(''); setEnvContent(''); setOriginalEnvContent(''); setEnvExists(false); } } else { setEnvFiles([]); setSelectedEnvFile(''); setEnvContent(''); setOriginalEnvContent(''); setEnvExists(false); } } catch { setEnvFiles([]); setSelectedEnvFile(''); setEnvContent(''); setOriginalEnvContent(''); setEnvExists(false); } // Load containers try { const containersRes = await apiFetch(`/stacks/${filename}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); } catch (error) { console.error('Failed to load containers:', error); setContainers([]); } // Load backup info (Skipper+ only) if (isPaid) { try { const backupRes = await apiFetch(`/stacks/${filename}/backup`); if (backupRes.ok) setBackupInfo(await backupRes.json()); else setBackupInfo({ exists: false, timestamp: null }); } catch { setBackupInfo({ exists: false, timestamp: null }); } } } catch (error) { console.error('Failed to load file:', error); setSelectedFile(null); setContent(''); setOriginalContent(''); setEnvContent(''); setOriginalEnvContent(''); setContainers([]); } finally { setIsFileLoading(false); } }; const changeEnvFile = async (file: string) => { setSelectedEnvFile(file); setIsFileLoading(true); try { const res = await apiFetch(`/stacks/${selectedFile}/env?file=${encodeURIComponent(file)}`); const text = await res.text(); setEnvContent(text || ''); setOriginalEnvContent(text || ''); } catch (e) { console.error('Failed to switch env file', e); } finally { setIsFileLoading(false); } }; const saveFile = async () => { if (!selectedFile) return; const currentContent = activeTab === 'compose' ? (content || '') : (envContent || ''); const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env?file=${encodeURIComponent(selectedEnvFile)}`; try { const response = await apiFetch(endpoint, { method: 'PUT', body: JSON.stringify({ content: currentContent }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`); } // Update original content after save if (activeTab === 'compose') { setOriginalContent(content); } else { setOriginalEnvContent(envContent); } setIsEditing(false); toast.success('File saved successfully!'); } catch (error) { console.error('Failed to save file:', error); toast.error(`Failed to save file: ${(error as Error).message}`); } }; const rollbackStack = async () => { if (!selectedFile || isStackBusy(selectedFile)) return; const stackFile = selectedFile; setStackAction(stackFile, 'rollback'); setOptimisticStatus(stackFile, 'running'); try { const res = await apiFetch(`/stacks/${stackFile}/rollback`, { method: 'POST' }); if (!res.ok) { const err = await res.json(); throw new Error(err?.error || 'Rollback failed'); } toast.success('Stack rolled back successfully.'); // Reload the editor content const contentRes = await apiFetch(`/stacks/${stackFile}`); const text = await contentRes.text(); setContent(text || ''); setOriginalContent(text || ''); // Refresh backup info const backupRes = await apiFetch(`/stacks/${stackFile}/backup`); if (backupRes.ok) setBackupInfo(await backupRes.json()); } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Rollback failed'; toast.error(msg); } finally { clearStackAction(stackFile); refreshStacks(true); } }; const handleSaveAndDeploy = async (e: React.MouseEvent) => { await saveFile(); await deployStack(e); }; const discardChanges = () => { if (activeTab === 'compose') { setContent(originalContent); } else { setEnvContent(originalEnvContent); } setIsEditing(false); }; const enterEditMode = () => { setIsEditing(true); }; const scanStackConfig = async () => { if (!selectedFile || stackMisconfigScanning) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setStackMisconfigScanning(true); const loadingId = toast.loading(`Scanning ${stackName} configuration...`); try { const res = await apiFetch('/security/scan/stack', { method: 'POST', body: JSON.stringify({ stackName }), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error || 'Failed to start scan'); if (data.status === 'failed') { throw new Error(data.error || 'Scan failed'); } toast.success( `Config scan complete: ${data.misconfig_count ?? 0} misconfigurations found`, ); setStackMisconfigScanId(data.id as number); } catch (error) { const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error(err?.message || err?.error || err?.data?.error || 'Config scan failed'); } finally { toast.dismiss(loadingId); setStackMisconfigScanning(false); } }; const deployStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isStackBusy(selectedFile)) return; const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'deploy'); const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'running'); try { const response = await apiFetch(`/stacks/${stackName}/deploy`, { method: 'POST', }); if (!response.ok) { const errText = await response.text(); throw new Error(errText || 'Deploy failed'); } toast.success("Stack deployed successfully!"); // Refresh containers after deploy if (selectedFile === stackFile) { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); } // Refresh backup info if (isPaid) { try { const backupRes = await apiFetch(`/stacks/${stackName}/backup`); if (backupRes.ok) setBackupInfo(await backupRes.json()); } catch { /* ignore */ } } } catch (error) { console.error('Failed to deploy:', error); if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); const msg = (error as Error).message || 'Failed to deploy stack'; toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg); } finally { clearStackAction(stackFile); refreshStacks(true); } }; const stopStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isStackBusy(selectedFile)) return; const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'stop'); const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'exited'); try { const response = await apiFetch(`/stacks/${stackName}/stop`, { method: 'POST', }); if (!response.ok) { const errText = await response.text(); throw new Error(errText || 'Stop failed'); } toast.success('Stack stopped successfully!'); // Refresh containers after stop if (selectedFile === stackFile) { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); } } catch (error) { console.error('Failed to stop:', error); if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); toast.error((error as Error).message || 'Failed to stop stack'); } finally { clearStackAction(stackFile); refreshStacks(true); } }; const restartStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isStackBusy(selectedFile)) return; const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'restart'); const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'running'); try { const response = await apiFetch(`/stacks/${stackName}/restart`, { method: 'POST', }); if (!response.ok) { const errText = await response.text(); throw new Error(errText || 'Restart failed'); } toast.success('Stack restarted successfully!'); // Refresh containers after restart if (selectedFile === stackFile) { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); } } catch (error) { console.error('Failed to restart:', error); if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); toast.error((error as Error).message || 'Failed to restart stack'); } finally { clearStackAction(stackFile); refreshStacks(true); } }; const updateStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isStackBusy(selectedFile)) return; const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'update'); const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'running'); try { const response = await apiFetch(`/stacks/${stackName}/update`, { method: 'POST', }); if (!response.ok) { const errText = await response.text(); throw new Error(errText || 'Update failed'); } toast.success('Stack updated successfully!'); // Refresh containers after update if (selectedFile === stackFile) { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); } } catch (error) { console.error('Failed to update:', error); if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); toast.error((error as Error).message || 'Failed to update stack'); } finally { clearStackAction(stackFile); refreshStacks(true); } }; const deleteStack = async () => { if (!stackToDelete) return; // Find matching file entry for per-stack tracking const deleteKey = files.find(f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete) ?? stackToDelete; if (isStackBusy(deleteKey)) return; setStackAction(deleteKey, 'delete'); try { const response = await apiFetch(`/stacks/${stackToDelete}`, { method: 'DELETE', }); if (!response.ok) { const errText = await response.text(); throw new Error(errText || 'Failed to delete stack'); } toast.success('Stack deleted successfully!'); setDeleteDialogOpen(false); setStackToDelete(null); if (selectedFile === stackToDelete) { setSelectedFile(null); setContent(''); setOriginalContent(''); setEnvContent(''); setOriginalEnvContent(''); setEnvExists(false); setContainers([]); setIsEditing(false); } await refreshStacks(); } catch (error) { console.error('Failed to delete stack:', error); toast.error((error as Error).message || 'Failed to delete stack'); } finally { clearStackAction(deleteKey); } }; // Context-menu-friendly stack actions (accept file name directly) const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => { if (isStackBusy(stackFile)) return; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, action); // Optimistic status update if (action === 'stop') { setOptimisticStatus(stackFile, 'exited'); } else if (action === 'deploy' || action === 'restart' || action === 'update') { setOptimisticStatus(stackFile, 'running'); } try { const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' }); if (!response.ok) { const errText = await response.text(); throw new Error(errText || `${action} failed`); } toast.success(`Stack ${action}ed successfully!`); if (selectedFile === stackFile) { const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); } if (action === 'update') fetchImageUpdates(); if (action === 'deploy' && isPaid) { try { const backupRes = await apiFetch(`/stacks/${stackName}/backup`); if (backupRes.ok) setBackupInfo(await backupRes.json()); } catch { /* ignore */ } } } catch (error) { console.error(`Failed to ${action}:`, error); const msg = (error as Error).message || `Failed to ${action} stack`; toast.error(action === 'deploy' && isPaid ? `${msg} - automatically rolled back to previous version.` : msg); } finally { clearStackAction(stackFile); refreshStacks(true); } }; const checkUpdatesForStack = async () => { try { const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); if (res.ok) { toast.success('Checking for image updates...'); // Poll until the background check completes instead of using a fixed timeout let elapsed = 0; const poll = setInterval(async () => { elapsed += 2000; try { const statusRes = await apiFetch('/image-updates/status'); if (statusRes.ok) { const { checking } = await statusRes.json(); if (!checking || elapsed >= 60000) { clearInterval(poll); await fetchImageUpdates(); if (!checking) toast.success('Image update check complete.'); } } } catch { clearInterval(poll); await fetchImageUpdates(); } }, 2000); } else { const data = await res.json().catch(() => ({})); toast.error(data.error || 'Failed to check for updates'); } } catch { toast.error('Failed to check for updates'); } }; const handleCreateStack = async () => { if (!newStackName.trim()) return; // Send stackName directly (no .yml extension - backend creates directory) const stackName = newStackName.trim(); try { const response = await apiFetch('/stacks', { method: 'POST', body: JSON.stringify({ stackName }), }); if (!response.ok) { if (response.status === 409) { throw new Error('Stack already exists'); } else if (response.status === 400) { throw new Error('Invalid stack name (use alphanumeric characters and hyphens only)'); } throw new Error('Failed to create stack'); } setCreateDialogOpen(false); setNewStackName(''); await refreshStacks(); // Auto-load the new stack in the editor pane await loadFile(stackName); } catch (error) { console.error('Failed to create stack:', error); toast.error((error as Error).message || 'Failed to create stack'); } }; const resetCreateFromGitForm = () => { setNewStackName(''); setGitRepoUrl(''); setGitBranch('main'); setGitComposePath('compose.yaml'); setGitSyncEnv(false); setGitAuthType('none'); setGitToken(''); setGitApplyMode('review'); setGitDeployNow(false); }; const handleCreateStackFromGit = async () => { const stackName = newStackName.trim(); if (!stackName) { toast.error('Stack name is required.'); return; } if (!gitRepoUrl.trim() || !gitBranch.trim() || !gitComposePath.trim()) { toast.error('Repository URL, branch, and compose path are required.'); return; } if (!/^https:\/\//i.test(gitRepoUrl.trim())) { toast.error('Only HTTPS repository URLs are supported.'); return; } setCreatingFromGit(true); const loadingId = toast.loading(gitDeployNow ? 'Fetching, creating, and deploying...' : 'Fetching and creating stack...'); try { const autoApply = gitApplyMode !== 'review'; const autoDeploy = gitApplyMode === 'auto-deploy'; const body: Record = { stack_name: stackName, repo_url: gitRepoUrl.trim(), branch: gitBranch.trim(), compose_path: gitComposePath.trim(), sync_env: gitSyncEnv, auth_type: gitAuthType, auto_apply_on_webhook: autoApply, auto_deploy_on_apply: autoDeploy, deploy_now: gitDeployNow, }; if (gitAuthType === 'token' && gitToken !== '') { body.token = gitToken; } const response = await apiFetch('/stacks/from-git', { method: 'POST', body: JSON.stringify(body), }); if (!response.ok) { const err = await response.json().catch(() => ({})); if (response.status === 409) { throw new Error(err?.error || 'Stack already exists.'); } throw new Error(err?.error || 'Failed to create stack from Git.'); } const data: { deployed?: boolean; deployError?: string; commitSha?: string; warnings?: string[]; } = await response.json(); const shortSha = typeof data.commitSha === 'string' ? data.commitSha.slice(0, 7) : ''; const shaSuffix = shortSha ? ` @ ${shortSha}` : ''; if (gitDeployNow && data.deployError) { toast.warning(`Stack created${shaSuffix}, but deploy failed: ${data.deployError}`); } else if (gitDeployNow && data.deployed) { toast.success(`Stack created and deployed from Git${shaSuffix}.`); } else { toast.success(`Stack created from Git${shaSuffix}.`); } if (Array.isArray(data.warnings) && data.warnings.length > 0) { toast.warning(data.warnings.join(' ')); } setCreateDialogOpen(false); resetCreateFromGitForm(); await refreshStacks(); await loadFile(stackName); } catch (error) { console.error('Failed to create stack from Git:', error); toast.error((error as Error)?.message || 'Failed to create stack from Git.'); } finally { toast.dismiss(loadingId); setCreatingFromGit(false); } }; const resetCreateFromDockerRunForm = () => { setDockerRunInput(''); setConvertedYaml(null); setIsConverting(false); setCreatingFromDockerRun(false); }; const handleConvertDockerRun = async () => { const command = dockerRunInput.trim(); if (!command) { toast.error('Paste a docker run command first.'); return; } setIsConverting(true); try { const response = await apiFetch('/convert', { method: 'POST', body: JSON.stringify({ dockerRun: command }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data?.error || 'Could not parse command.'); } if (typeof data?.yaml !== 'string' || data.yaml.length === 0) { throw new Error('Converter returned an empty result.'); } setConvertedYaml(data.yaml); toast.success('Converted to compose YAML.'); } catch (error) { setConvertedYaml(null); const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error( err?.message || err?.error || err?.data?.error || 'Failed to convert docker run command.', ); } finally { setIsConverting(false); } }; const handleCreateStackFromDockerRun = async () => { const stackName = newStackName.trim(); if (!stackName) { toast.error('Stack name is required.'); return; } if (!convertedYaml) { toast.error('Convert the command before creating the stack.'); return; } setCreatingFromDockerRun(true); const loadingId = toast.loading('Creating stack from converted YAML...'); let createdStack = false; try { const createResponse = await apiFetch('/stacks', { method: 'POST', body: JSON.stringify({ stackName }), }); if (!createResponse.ok) { if (createResponse.status === 409) { throw new Error('Stack already exists.'); } if (createResponse.status === 400) { throw new Error('Invalid stack name (use alphanumeric characters and hyphens only).'); } throw new Error('Failed to create stack.'); } createdStack = true; const saveResponse = await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'PUT', body: JSON.stringify({ content: convertedYaml }), }); if (!saveResponse.ok) { // Roll back the empty stack we just created so we don't leave an orphan. await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'DELETE' }).catch((cleanupError) => { console.error('Failed to roll back orphan stack after save failure:', cleanupError); }); createdStack = false; throw new Error('Could not save the converted YAML. Please try again.'); } toast.success(`Stack "${stackName}" created from docker run.`); setCreateDialogOpen(false); resetCreateFromDockerRunForm(); setNewStackName(''); await refreshStacks(); await loadFile(stackName); } catch (error) { console.error('Failed to create stack from docker run:', error); const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error( err?.message || err?.error || err?.data?.error || 'Failed to create stack from docker run.', ); // If we bailed before the createdStack flag got reset, surface that the stack still exists. if (createdStack) { await refreshStacks().catch(() => undefined); } } finally { toast.dismiss(loadingId); setCreatingFromDockerRun(false); } }; const openBashModal = (containerId: string, containerName: string) => { setSelectedContainer({ id: containerId, name: containerName }); setBashModalOpen(true); }; const closeBashModal = () => { setBashModalOpen(false); setSelectedContainer(null); }; const openLogViewer = (containerId: string, containerName: string) => { setLogContainer({ id: containerId, name: containerName }); setLogViewerOpen(true); }; const closeLogViewer = () => { setLogViewerOpen(false); setLogContainer(null); }; // Listen for topology click-to-logs events (ref avoids stale closure) const openLogViewerRef = useRef(openLogViewer); openLogViewerRef.current = openLogViewer; useEffect(() => { const handler = (e: Event) => { const { containerId, containerName } = (e as CustomEvent).detail; openLogViewerRef.current(containerId, containerName); }; window.addEventListener(SENCHO_OPEN_LOGS_EVENT, handler); return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler); }, []); // Safe container list with fallback const safeContainers = containers || []; // Safe content strings with fallback const safeContent = content || ''; const safeEnvContent = envContent || ''; // Stack state booleans for dynamic button rendering const isRunning = safeContainers?.some(c => c.State === 'running'); // Stack name is now the same as selectedFile (no extension to strip) const stackName = selectedFile || ''; // Filter files based on search query const filteredFiles = files.filter(file => { if (!file.toLowerCase().includes(searchQuery.toLowerCase())) return false; if (activeLabelFilters.size > 0) { const fileLabels = stackLabelMap[file] || []; if (!fileLabels.some(l => activeLabelFilters.has(l.id))) return false; } return true; }); // Get display name for stack (now just returns the name as-is since no extension) const getDisplayName = (stackName: string) => { return stackName; }; return (
{/* Left Sidebar (Stacks) */}
{/* Branding Header */}
Sencho Logo

Sencho

{/* Node Switcher */} {nodes.length > 1 && (
)} {/* Create Stack & Scan Buttons */} {can('stack:create') &&
{ setCreateDialogOpen(o); if (!o) { setCreateMode('empty'); resetCreateFromGitForm(); resetCreateFromDockerRunForm(); } }}> Create New Stack Create a new stack: empty, cloned from a Git repository, or converted from a docker run command.
setCreateMode(v as 'empty' | 'git' | 'docker-run')}> Empty From Git From Docker Run
{createMode === 'empty' && ( <>
setNewStackName(e.target.value)} />
)} {createMode === 'git' && ( <>
setNewStackName(e.target.value)} disabled={creatingFromGit} />
setGitDeployNow(c === true)} disabled={creatingFromGit} />
)} {createMode === 'docker-run' && ( <>
setNewStackName(e.target.value)} disabled={creatingFromDockerRun} />