import { useState, useEffect, useRef } from 'react'; import { motion } from 'motion/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 BashExecModal from './BashExecModal'; import HostConsole from './HostConsole'; import ResourcesView from './ResourcesView'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; 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, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from 'sonner'; import { Label } from './ui/label'; import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; import { ScrollArea } from './ui/scroll-area'; import { Skeleton } from './ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SettingsModal } from './SettingsModal'; import { StackAlertSheet } from './StackAlertSheet'; import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; import { GlobalObservabilityView } from './GlobalObservabilityView'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; interface ContainerInfo { Id: string; Names: string[]; State: string; Status?: string; Ports?: { PrivatePort: number, PublicPort: number }[]; } interface StackStatus { [key: string]: 'running' | 'exited' | 'unknown'; } interface Notification { id: number; level: 'info' | 'warning' | 'error'; message: string; timestamp: number; is_read: number; // 0 | 1 (SQLite boolean) nodeId: number; nodeName: 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]; }; export default function EditorLayout() { const { logout } = useAuth(); const { nodes, activeNode, setActiveNode } = 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 monacoEditorRef = useRef(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newStackName, setNewStackName] = useState(''); const [stackToDelete, setStackToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); const [loadingAction, setLoadingAction] = useState(null); const [isFileLoading, setIsFileLoading] = useState(false); 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'>('dashboard'); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); // 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 [alertSheetOpen, setAlertSheetOpen] = useState(false); const [alertSheetStack, setAlertSheetStack] = useState(''); const openAlertSheet = (stackName: string) => { setAlertSheetStack(stackName); setAlertSheetOpen(true); }; // 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]); // 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) => { 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 status for each stack const statuses: StackStatus = {}; for (const file of fileList) { try { const containersRes = await apiFetch(`/stacks/${file}/containers`); const containers = await containersRes.json(); const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running'); statuses[file] = hasRunning ? 'running' : (Array.isArray(containers) && containers.length > 0 ? 'exited' : 'unknown'); } catch { statuses[file] = 'unknown'; } } setStackStatuses(statuses); } catch (error) { console.error('Failed to refresh stacks:', error); setFiles([]); } finally { setIsLoading(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: Notification = { ...(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; setSelectedFile(null); setContent(''); setOriginalContent(''); setEnvContent(''); setOriginalEnvContent(''); setContainers([]); setIsEditing(false); setActiveView('dashboard'); refreshStacks(); fetchImageUpdates(); }, [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: Notification[] = []; 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).map(n => n.nodeId))]; 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: Notification) => { 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 { 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.map(n => n.nodeId))]; 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, }; } 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 => { let hasChanges = false; const next = { ...prev }; for (const [id, newStats] of Object.entries(pending)) { const old = prev[id]; if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) { next[id] = newStats; hasChanges = true; } } return hasChanges ? next : prev; }); }, 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 loadFile = async (filename: string) => { if (!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([]); } } 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 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 deployStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || loadingAction !== null) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('deploy'); 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 const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); await refreshStacks(true); } catch (error) { console.error('Failed to deploy:', error); toast.error((error as Error).message || 'Failed to deploy stack'); } finally { setLoadingAction(null); } }; const stopStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || loadingAction !== null) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('stop'); 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 const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); await refreshStacks(true); } catch (error) { console.error('Failed to stop:', error); toast.error((error as Error).message || 'Failed to stop stack'); } finally { setLoadingAction(null); } }; const restartStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || loadingAction !== null) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('restart'); 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 const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); await refreshStacks(true); } catch (error) { console.error('Failed to restart:', error); toast.error((error as Error).message || 'Failed to restart stack'); } finally { setLoadingAction(null); } }; const updateStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || loadingAction !== null) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('update'); 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 const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); await refreshStacks(true); } catch (error) { console.error('Failed to update:', error); toast.error((error as Error).message || 'Failed to update stack'); } finally { setLoadingAction(null); } }; const deleteStack = async () => { if (!stackToDelete) return; setLoadingAction('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 { setLoadingAction(null); } }; 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 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); }; // 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 => { return file.toLowerCase().includes(searchQuery.toLowerCase()); }); // Get display name for stack (now just returns the name as-is since no extension) const getDisplayName = (stackName: string) => { return stackName; }; const getContainerBadge = (container: ContainerInfo) => { const status = (container.Status || '').toLowerCase(); const state = (container.State || '').toLowerCase(); if (status.includes('(unhealthy)') || state === 'exited' || state === 'dead') { return { variant: 'destructive' as const, text: container.State }; } if (status.includes('(starting)')) { return { variant: 'secondary' as const, text: container.State }; } return { variant: 'default' as const, text: container.State }; }; return (
{/* Left Sidebar (Stacks) */}
{/* Branding Header */}
Sencho Logo

Sencho

Logout
{/* Node Switcher */} {nodes.length > 1 && (
)} {/* Create Stack Button */}
Create New Stack
setNewStackName(e.target.value)} />
{/* Search Input & Stack List */}

STACKS

{isLoading ? (
) : ( (filteredFiles || []).map(file => ( loadFile(file)} className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`} >
{getDisplayName(file)} {stackUpdates[file] && ( )}
e.stopPropagation()}> openAlertSheet(file)}> Alerts
)) )}
{/* Main Content Area */}
{/* Top Header Bar */}
{/* Node Context Pill - visible only when a remote node is active */}
{activeNode?.type === 'remote' ? (
{activeNode.name}
) : (
{activeNode?.name ?? 'Local'}
)}
{/* Home Button */} {/* Console Toggle */} {/* Resources Toggle */} {/* App Store Toggle */} {/* Global Observability Toggle */} {/* Settings Modal Toggle */} {/* Notifications Popover */}

Notifications

{notifications.filter(n => !n.is_read).length > 0 && ( )} {notifications.length > 0 && ( )}
{notifications.length === 0 ? (
No notifications
) : (
{notifications.map((notif) => (
{notif.level} {nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && ( {notif.nodeName} )} {new Date(notif.timestamp).toLocaleString()}

{notif.message}

{/* Delete individual notification button */}
))}
)}
{/* end right-side buttons */}
{/* Main Workspace */}
{activeView === 'templates' ? ( { refreshStacks(); loadFile(stackName); }} /> ) : activeView === 'resources' ? ( ) : activeView === 'host-console' ? ( setActiveView(selectedFile ? 'editor' : 'dashboard')} /> ) : !isLoading && selectedFile && activeView === 'editor' ? (
{/* Left Column (Command Center & Terminal) */}
{/* Command Center Card */}
{/* Stack Name */} {stackName} {/* Action Bar */}
{isRunning ? ( <> ) : ( )}
{/* Containers List */}

CONTAINERS

{safeContainers.length === 0 ? (
No containers running for this stack.
) : (
{safeContainers.map(container => { let mainPort: number | undefined; if (container.Ports && container.Ports.length > 0) { const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000]; const IGNORE_PORTS = [1900, 53, 22]; // 1. Match typical Web UI Private ports let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort)); // 2. Match typical Web UI Public ports if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort)); // 3. Fallback to any port not in ignore list if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort)); mainPort = (match || container.Ports[0]).PublicPort; } return (
{getContainerBadge(container).text || 'unknown'}

Container Status

{container?.Status || 'No status details available'}

CPU: {container.State === 'running' ? (containerStats[container?.Id]?.cpu || 'N/A') : '0.00%'} | RAM: {container.State === 'running' ? (containerStats[container?.Id]?.ram || 'N/A') : '0.00 MB'} | NET: {container.State === 'running' ? (containerStats[container?.Id]?.net || '0 B ↓ / 0 B ↑') : '0 B/s ↓ / 0 B/s ↑'}
{mainPort && ( Open App ({mainPort}) )} View Live Logs Open Bash Terminal
); })}
)}
{/* Terminal Section */}

Terminal

{/* Right Column (The Editor) */}
setActiveTab(value as 'compose' | 'env')}> {activeTab === 'compose' && ( )} compose.yaml {activeTab === 'env' && ( )} .env {activeTab === 'env' && envFiles.length > 1 && ( )}
{!isEditing ? ( ) : ( <> )}
{activeTab === 'env' && (
Variables defined here are automatically available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, you must add env_file: - .env to your service definition.
)}
{!isFileLoading && ( { monacoEditorRef.current = editor; }} onChange={(value) => { if (!isEditing) return; // Prevent changes in view mode if (activeTab === 'compose') { setContent(value || ''); } else { setEnvContent(value || ''); } }} options={{ minimap: { enabled: false }, fontSize: 14, padding: { top: 10 }, scrollBeyondLastLine: false, readOnly: !isEditing, }} /> )} {isFileLoading && (
Loading...
)}
) : activeView === 'global-observability' ? ( ) : ( )}
{/* Delete Confirmation Dialog */} Delete Stack Are you sure you want to delete {stackToDelete}? This action cannot be undone. setDeleteDialogOpen(false)}>Cancel Delete {/* Bash Exec Modal */} {selectedContainer && ( )} {/* LogViewer Modal */} {logContainer && ( )} {/* Settings Modal */} setSettingsModalOpen(false)} theme={theme} setTheme={setTheme} /> {/* Stack Alert Sheet */} setAlertSheetOpen(false)} stackName={alertSheetStack} />
); }