Files
sencho/frontend/src/components/EditorLayout.tsx
T
SaelixCode fddd855624 fix: Distributed API proxy memory leak, node switcher refresh, and copy button
Proxy memory leak (MaxListenersExceededWarning + DEP0060):
createProxyMiddleware was instantiated inside the request handler on every
single API call. Each new instance registered fresh 'close' listeners on the
HTTP server and re-ran the http-proxy util._extend deprecated path. After ~10
requests the MaxListeners threshold was breached. Fix: declare ONE global
remoteNodeProxy at startup using the router option to dynamically resolve the
target URL per request. Listeners are registered once. ECONNREFUSED errors are
caught in the on.error handler and returned as structured 502 JSON.

Node switcher "nothing happens":
EditorLayout had a single useEffect([], []) that called refreshStacks() once
on mount. Changing the active node updated NodeContext state and localStorage
but nothing re-triggered the stack list fetch. Fix: split into two effects —
notifications polling (no dependency) and a stack-refresh effect keyed on
activeNode?.id. When the node changes, stale editor/container/file state is
cleared and the stacks for the new node are fetched.

Copy button silently failing:
navigator.clipboard.writeText() throws DOMException in non-HTTPS / non-localhost
contexts (e.g. http://192.168.x.x). The uncaught async exception silently
swallowed the success toast and state update. Fix: wrapped in try/catch with
an execCommand('copy') textarea fallback and a final error toast if both fail.
2026-03-19 15:34:04 -04:00

1277 lines
54 KiB
TypeScript

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 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 } 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';
interface ContainerInfo {
Id: string;
Names: string[];
State: string;
Status?: string;
Ports?: { PrivatePort: number, PublicPort: number }[];
}
interface StackStatus {
[key: string]: 'running' | 'exited' | 'unknown';
}
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();
const [files, setFiles] = useState<string[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [content, setContent] = useState<string>('');
const [originalContent, setOriginalContent] = useState<string>('');
const [envContent, setEnvContent] = useState<string>('');
const [originalEnvContent, setOriginalEnvContent] = useState<string>('');
const [envExists, setEnvExists] = useState<boolean>(false);
const [envFiles, setEnvFiles] = useState<string[]>([]);
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
const [containers, setContainers] = useState<ContainerInfo[]>([]);
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string, net: string, lastRx?: number, lastTx?: number }>>({});
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<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [loadingAction, setLoadingAction] = useState<string | null>(null);
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 [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<StackStatus>({});
// 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);
// Notifications & Settings state
const [notifications, setNotifications] = useState<any[]>([]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
const [alertSheetStack, setAlertSheetStack] = useState('');
const openAlertSheet = (stackName: string) => {
setAlertSheetStack(stackName);
setAlertSheetOpen(true);
};
// 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);
}
};
// Notification polling — independent of active node, runs once on mount
useEffect(() => {
fetchNotifications();
const notificationInterval = setInterval(fetchNotifications, 5000);
return () => clearInterval(notificationInterval);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 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();
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchNotifications = async () => {
try {
const res = await apiFetch('/notifications');
if (res.ok) {
const data = await res.json();
setNotifications(data);
}
} catch (e) { }
};
const markAllRead = async () => {
try {
await apiFetch('/notifications/read', { method: 'POST' });
fetchNotifications();
} catch (e) { }
};
const deleteNotification = async (id: number) => {
try {
await apiFetch(`/notifications/${id}`, { method: 'DELETE' });
fetchNotifications();
} catch (e) { }
};
const clearAllNotifications = async () => {
try {
await apiFetch('/notifications', { method: 'DELETE' });
fetchNotifications();
} catch (e) { }
};
useEffect(() => {
const wsMap: Record<string, WebSocket> = {};
(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,
nodeId: localStorage.getItem('sencho-active-node') || 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).forEach((net: any) => {
currentRx += net.rx_bytes || 0;
currentTx += net.tx_bytes || 0;
});
}
setContainerStats(prev => {
const prevStat = prev[container.Id];
// Calculate rate if we have a previous value
const rxRate = prevStat?.lastRx !== undefined ? Math.max(0, currentRx - prevStat.lastRx) : 0;
const txRate = prevStat?.lastTx !== undefined ? Math.max(0, currentTx - prevStat.lastTx) : 0;
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
// Check if values actually changed to prevent infinite re-renders
const newCpu = cpuPercent + '%';
if (prevStat && prevStat.cpu === newCpu && prevStat.ram === ramUsage && prevStat.lastRx === currentRx && prevStat.lastTx === currentTx) {
return prev;
}
return {
...prev,
[container.Id]: {
cpu: newCpu,
ram: ramUsage,
net: netIO,
lastRx: currentRx,
lastTx: currentTx
}
};
});
} 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);
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: any) {
console.error('Failed to deploy:', error);
toast.error(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: any) {
console.error('Failed to stop:', error);
toast.error(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: any) {
console.error('Failed to restart:', error);
toast.error(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: any) {
console.error('Failed to update:', error);
toast.error(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: any) {
console.error('Failed to delete stack:', error);
toast.error(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: 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);
};
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 (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
{/* Left Sidebar (Stacks) */}
<div className="w-64 border-r border-border bg-card flex flex-col">
{/* Branding Header */}
<div className="h-16 flex items-center justify-between px-4 border-b border-border">
<div className="flex items-center gap-2">
<img src="/sencho-logo.png" alt="Sencho Logo" className="w-12 h-12" />
<h1 className="text-2xl font-bold tracking-tight">Sencho</h1>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={logout}
className="text-muted-foreground hover:text-foreground"
>
<LogOut className="w-5 h-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Logout</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Node Switcher */}
{nodes.length > 1 && (
<div className="px-4 pt-2 pb-0">
<Select
value={activeNode?.id?.toString() || ''}
onValueChange={(val) => {
const node = nodes.find(n => n.id === parseInt(val));
if (node) setActiveNode(node);
}}
>
<SelectTrigger className="w-full h-9 text-sm">
<div className="flex items-center gap-2">
<Server className="w-3.5 h-3.5 text-muted-foreground" />
<SelectValue placeholder="Select node" />
</div>
</SelectTrigger>
<SelectContent>
{nodes.map(node => (
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full shrink-0 ${
node.status === 'online' ? 'bg-green-500' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
{node.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Create Stack Button */}
<div className="p-4">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full rounded-lg">
<Plus className="w-4 h-4 mr-2" />
Create Stack
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Stack</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-2">
<Label htmlFor="create-stack-name">Stack Name</Label>
<Input
id="create-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
/>
</div>
<DialogFooter>
<Button onClick={handleCreateStack}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Search Input & Stack List */}
<Command className="bg-transparent flex-1 flex flex-col overflow-hidden">
<div className="px-4 py-2 border-b border-border flex-none">
<CommandInput
placeholder="Search stacks..."
value={searchQuery}
onValueChange={setSearchQuery}
className="h-9"
/>
</div>
<h3 className="text-sm font-semibold text-muted-foreground px-4 py-2 mt-2 flex-none">STACKS</h3>
<ScrollArea className="flex-1 px-2 pb-2">
<CommandList className="max-h-none overflow-visible">
{isLoading ? (
<div className="space-y-2 px-2 mt-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : (
(filteredFiles || []).map(file => (
<CommandItem
key={file}
value={file}
onSelect={() => loadFile(file)}
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
>
<div className="flex items-center gap-2 w-full">
<div
className={`w-2 h-2 rounded-full shrink-0 ${stackStatuses[file] === 'running' ? 'bg-green-500' :
stackStatuses[file] === 'exited' ? 'bg-red-500' : 'bg-gray-400'
}`}
/>
<span className="flex-1 truncate">{getDisplayName(file)}</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openAlertSheet(file)}>
<BellRing className="h-4 w-4 mr-2" />
Alerts
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CommandItem>
))
)}
</CommandList>
</ScrollArea>
</Command>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header Bar */}
<div className="h-16 flex items-center justify-end px-6 border-b border-border gap-4">
{/* Home Button */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => {
setSelectedFile(null);
setContent('');
setOriginalContent('');
setEnvContent('');
setOriginalEnvContent('');
setEnvFiles([]);
setSelectedEnvFile('');
setEnvExists(false);
setContainers([]);
setIsEditing(false);
setActiveView('dashboard');
}}
title="Go to Home Dashboard"
>
<Home className="w-4 h-4 mr-2" />
Home
</Button>
{/* Console Toggle */}
<Button
variant={activeView === 'host-console' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
>
<Terminal className="w-4 h-4 mr-2" />
Console
</Button>
{/* Resources Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('resources')}
title="System Resources"
>
<HardDrive className="w-4 h-4 mr-2" />
Resources
</Button>
{/* App Store Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('templates')}
title="App Store"
>
<CloudDownload className="w-4 h-4 mr-2" />
App Store
</Button>
{/* Global Observability Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('global-observability')}
title="Global Logs"
>
<Activity className="w-4 h-4 mr-2" />
Logs
</Button>
{/* Settings Modal Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setSettingsModalOpen(true)}
title="Notification Settings"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
{/* Notifications Popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
<Bell className="w-4 h-4" />
{notifications.filter(n => !n.is_read).length > 0 && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
<div className="flex gap-2">
{notifications.filter(n => !n.is_read).length > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
Mark all as read
</Button>
)}
{notifications.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3 h-3 mr-1" />
Clear all
</Button>
)}
</div>
</div>
<ScrollArea className="h-80">
{notifications.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
) : (
<div className="flex flex-col">
{notifications.map((notif: any) => (
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
<div className="flex items-center gap-2 mb-1 pr-6">
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
{notif.level}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(notif.timestamp).toLocaleString()}
</span>
</div>
<p className="font-medium pr-6">{notif.message}</p>
{/* Delete individual notification button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
deleteNotification(notif.id);
}}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
</div>
{/* Main Workspace */}
<div className="flex-1 overflow-y-auto p-6">
{activeView === 'templates' ? (
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
) : activeView === 'resources' ? (
<ResourcesView />
) : activeView === 'host-console' ? (
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
) : !isLoading && selectedFile && activeView === 'editor' ? (
<ErrorBoundary>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-stretch">
{/* Left Column (Command Center & Terminal) */}
<div className="flex flex-col h-full gap-6">
{/* Command Center Card */}
<Card className="rounded-xl border-muted bg-card">
<CardHeader className="p-4 pb-2">
<div className="flex flex-col gap-3">
{/* Stack Name */}
<CardTitle className="text-2xl font-bold">{stackName}</CardTitle>
{/* Action Bar */}
<div className="flex items-center gap-2 flex-wrap">
{isRunning ? (
<>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack} disabled={loadingAction !== null}>
<Square className="w-4 h-4 mr-2" />
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={restartStack} disabled={loadingAction !== null}>
<RotateCw className="w-4 h-4 mr-2" />
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
</>
) : (
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={deployStack} disabled={loadingAction !== null}>
<Play className="w-4 h-4 mr-2" />
{loadingAction === 'deploy' || loadingAction === 'start' ? 'Starting...' : 'Start'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>
<CloudDownload className="w-4 h-4 mr-2" />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
className="rounded-lg"
disabled={loadingAction !== null}
onClick={() => {
setStackToDelete(selectedFile);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="w-4 h-4 mr-2" />
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
{/* Containers List */}
<div className="mt-4">
<h4 className="text-sm font-semibold text-muted-foreground mb-3">CONTAINERS</h4>
{safeContainers.length === 0 ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
<div className="flex flex-col gap-3">
{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 (
<div key={container?.Id || Math.random()} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<HoverCard>
<HoverCardTrigger asChild>
<div className="cursor-help inline-flex">
<Badge variant={getContainerBadge(container).variant} className="text-xs">
{getContainerBadge(container).text || 'unknown'}
</Badge>
</div>
</HoverCardTrigger>
<HoverCardContent className="flex w-50 flex-col gap-0.5">
<div className="space-y-1">
<h4 className="text-sm font-semibold">Container Status</h4>
<p className="text-sm text-muted-foreground">
{container?.Status || 'No status details available'}
</p>
</div>
</HoverCardContent>
</HoverCard>
<span className="text-xs text-muted-foreground whitespace-nowrap">
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 ↑'}
</span>
</div>
</div>
<div className="flex gap-1">
{mainPort && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => window.open(`http://${window.location.hostname}:${mainPort}`, '_blank')}
>
<ExternalLink className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open App ({mainPort})</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => openLogViewer(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
disabled={container?.State !== 'running'}
>
<ScrollText className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Live Logs</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-lg h-8 w-8"
onClick={() => openBashModal(container?.Id, container?.Names?.[0]?.replace('/', '') || 'container')}
disabled={container?.State !== 'running'}
>
<Terminal className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open Bash Terminal</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
{/* Terminal Section */}
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 min-h-[300px]">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Terminal</h3>
<div className="h-[calc(100%-24px)]">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
</ErrorBoundary>
</div>
</div>
</div>
{/* Right Column (The Editor) */}
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] bg-card">
<div className="p-4 border-b border-muted flex items-center justify-between">
<div className="flex items-center gap-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
<TabsList className="bg-muted">
<TabsTrigger value="compose" className="rounded-lg">compose.yaml</TabsTrigger>
<TabsTrigger value="env" disabled={!envExists} className="rounded-lg">.env</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === 'env' && envFiles.length > 1 && (
<Select value={selectedEnvFile} onValueChange={changeEnvFile} disabled={isEditing || isFileLoading}>
<SelectTrigger className="h-9 text-xs bg-muted border-none min-w-[200px]">
<SelectValue placeholder="Select environment file" />
</SelectTrigger>
<SelectContent>
{envFiles.map((file) => (
<SelectItem key={file} value={file} className="text-xs">
{file.split('/').pop()}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex gap-2">
{!isEditing ? (
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</Button>
) : (
<>
<Button size="sm" variant="outline" className="rounded-lg" onClick={discardChanges}>
<X className="w-4 h-4 mr-2" />
Discard
</Button>
<Button size="sm" variant="outline" className="rounded-lg" onClick={saveFile}>
<Save className="w-4 h-4 mr-2" />
Save Only
</Button>
<Button size="sm" variant="default" className="rounded-lg" onClick={handleSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" />
Save & Deploy
</Button>
</>
)}
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === 'env' && (
<div className="bg-blue-500/10 border-b border-blue-500/20 px-4 py-2 flex items-center gap-2 text-xs text-blue-400">
<span>
Variables defined here are automatically available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, you must add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
</span>
</div>
)}
<div className="flex-1 min-h-0">
{!isFileLoading && (
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
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 && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
)}
</div>
</div>
</Card>
</div>
</ErrorBoundary>
) : activeView === 'global-observability' ? (
<GlobalObservabilityView />
) : (
<HomeDashboard />
)}
</div>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Stack</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {stackToDelete}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={deleteStack}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bash Exec Modal */}
{selectedContainer && (
<BashExecModal
isOpen={bashModalOpen}
onClose={closeBashModal}
containerId={selectedContainer.id}
containerName={selectedContainer.name}
/>
)}
{/* LogViewer Modal */}
{logContainer && (
<LogViewer
isOpen={logViewerOpen}
onClose={closeLogViewer}
containerId={logContainer.id}
containerName={logContainer.name}
/>
)}
{/* Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
/>
{/* Stack Alert Sheet */}
<StackAlertSheet
isOpen={alertSheetOpen}
onClose={() => setAlertSheetOpen(false)}
stackName={alertSheetStack}
/>
</div>
);
}