import { useState, useEffect } from 'react'; import Editor from '@monaco-editor/react'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; import BashExecModal from './BashExecModal'; import MaintenanceModal from './MaintenanceModal'; 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, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } 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'; interface ContainerInfo { Id: string; Names: string[]; State: string; Ports?: { PrivatePort: number, PublicPort: number }[]; } interface StackStatus { [key: string]: 'running' | 'exited' | 'unknown'; } export default function EditorLayout() { const { logout } = useAuth(); 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 [containers, setContainers] = useState([]); const [containerStats, setContainerStats] = useState>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); 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 [isActionLoading, setIsActionLoading] = useState(false); const [isFileLoading, setIsFileLoading] = useState(false); const [isDarkMode, setIsDarkMode] = useState(() => { const saved = localStorage.getItem('sencho-theme'); if (saved !== null) { return saved === 'dark'; } return true; // Default to dark mode }); const [showConsole, setShowConsole] = useState(true); 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); // Maintenance modal state const [maintenanceModalOpen, setMaintenanceModalOpen] = useState(false); // Theme toggle effect useEffect(() => { const html = document.documentElement; if (isDarkMode) { html.classList.add('dark'); localStorage.setItem('sencho-theme', 'dark'); } else { html.classList.remove('dark'); localStorage.setItem('sencho-theme', 'light'); } }, [isDarkMode]); const refreshStacks = async (background = false) => { if (!background) setIsLoading(true); try { const res = await apiFetch('/stacks'); const stacks = await res.json(); setFiles(Array.isArray(stacks) ? stacks : []); // Fetch status for each stack const statuses: StackStatus = {}; for (const file of stacks) { 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); } }; useEffect(() => { refreshStacks(); }, []); useEffect(() => { const wsMap: Record = {}; (containers || []).forEach(container => { if (!container?.Id) return; try { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${wsProtocol}//${window.location.host}`); wsMap[container.Id] = ws; ws.onopen = () => ws.send(JSON.stringify({ action: 'streamStats', containerId: container.Id })); 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'; setContainerStats(prev => ({ ...prev, [container.Id]: { cpu: cpuPercent + '%', ram: ramUsage } })); } catch { // Ignore parse errors } }; } catch { // Ignore WebSocket errors } }); return () => { Object.values(wsMap).forEach(ws => { try { ws.close(); } catch { // Ignore close errors } }); }; }, [containers]); 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); setContent(text || ''); setOriginalContent(text || ''); // Load env file try { const envRes = await apiFetch(`/stacks/${filename}/env`); if (envRes.ok) { const envText = await envRes.text(); setEnvContent(envText || ''); setOriginalEnvContent(envText || ''); setEnvExists(true); } else { setEnvContent(''); setOriginalEnvContent(''); setEnvExists(false); } } catch { 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 saveFile = async () => { if (!selectedFile) return; const currentContent = activeTab === 'compose' ? (content || '') : (envContent || ''); const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env`; 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 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 || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/up`, { method: 'POST', }); // 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); } finally { setIsActionLoading(false); } }; const stopStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/stop`, { method: 'POST', }); // 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); } finally { setIsActionLoading(false); } }; const startStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/start`, { method: 'POST', }); // Refresh containers after start 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 start:', error); } finally { setIsActionLoading(false); } }; const restartStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/restart`, { method: 'POST', }); // 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); } finally { setIsActionLoading(false); } }; const updateStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedFile || isActionLoading) return; const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setIsActionLoading(true); try { await apiFetch(`/stacks/${stackName}/update`, { method: 'POST', }); // 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); } finally { setIsActionLoading(false); } }; const deleteStack = async () => { if (!stackToDelete) return; setIsActionLoading(true); try { const response = await apiFetch(`/stacks/${stackToDelete}`, { method: 'DELETE', }); if (!response.ok) throw new Error('Failed to delete stack'); 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('Failed to delete stack'); } finally { setIsActionLoading(false); } }; 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: any) { console.error('Failed to create stack:', error); toast.error(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); }; // 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 isDeployed = safeContainers && safeContainers.length > 0; 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; }; return (
{/* Left Sidebar (Stacks) */}
{/* Branding Header */}

Sencho

Logout
{/* 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 ${selectedFile === file ? 'bg-accent text-accent-foreground' : ''}`} >
{getDisplayName(file)}
)) )}
{/* Main Content Area */}
{/* Top Header Bar */}
{/* Home Button */} {/* Console Toggle */} {/* System Janitor (Maintenance) Toggle */} {/* Theme Toggle */}
{/* Main Workspace */}
{!isLoading && selectedFile ? (
{/* Left Column (Command Center & Terminal) */}
{/* Command Center Card */}
{/* Stack Name */} {stackName} {/* Action Bar */}
{!isDeployed && ( )} {isDeployed && ( <> {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 (
{container?.State || 'unknown'} CPU: {containerStats[container?.Id]?.cpu || 'N/A'} | RAM: {containerStats[container?.Id]?.ram || 'N/A'}
{mainPort && ( Open App ({mainPort}) )} Open Bash Terminal
); })}
)}
{/* Terminal Section */} {showConsole && (

Terminal

)}
{/* Right Column (The Editor) */}
setActiveTab(value as 'compose' | 'env')}> compose.yaml .env
{!isEditing ? ( ) : ( <> )}
{!isFileLoading && ( { 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...
)}
) : ( )}
{/* 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 && ( )} {/* Maintenance Modal */} setMaintenanceModalOpen(false)} />
); }