import { Suspense, useRef, useEffect } from 'react'; import { Editor } from '@/lib/monacoLoader'; import { RotateCw, Play, Square, Save, Terminal, CloudDownload, Pencil, X, MoreVertical, Rocket, Trash2, ScrollText, Undo2, Loader2, Check, ChevronDown, GitBranch, ShieldCheck, ArrowUpRight, Copy, FolderOpen, } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem, } from '../ui/tabs'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Sparkline } from '../ui/sparkline'; import { springs } from '@/lib/motion'; import { cn } from '@/lib/utils'; import { copyToClipboard } from '@/lib/clipboard'; import ErrorBoundary from '../ErrorBoundary'; import TerminalComponent from '../Terminal'; import StructuredLogViewer from '../StructuredLogViewer'; import StackAnatomyPanel from '../StackAnatomyPanel'; import { StackFileExplorer } from '@/components/files/StackFileExplorer'; import type { NotificationItem } from '../dashboard/types'; import type { Node } from '@/context/NodeContext'; import type { useAuth } from '@/context/AuthContext'; export interface ContainerInfo { Id: string; Names: string[]; Service?: string; State: string; Status?: string; Ports?: { PrivatePort: number; PublicPort: number }[]; healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none'; Image?: string; ImageID?: string; } export type StackAction = | 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback'; export interface ContainerStatsEntry { cpu: string; ram: string; net: string; lastRx?: number; lastTx?: number; history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] }; } const extractUptime = (status: string | undefined): string | null => { if (!status) return null; const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i); if (!match) return null; return `up ${match[1].trim()}`; }; const healthcheckLabel = ( health?: 'healthy' | 'unhealthy' | 'starting' | 'none', ): string | null => { if (!health || health === 'none') return null; if (health === 'healthy') return 'healthcheck passing'; if (health === 'unhealthy') return 'healthcheck failing'; return 'healthcheck starting'; }; type StackPill = { label: string; dotClass: string; className: string; pulse: boolean; }; const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => { if (!containers || containers.length === 0) return null; const running = containers.some(c => c.State === 'running'); if (!running) { return { label: 'exited', dotClass: 'bg-destructive', className: 'border-destructive/40 bg-destructive/10 text-destructive', pulse: false, }; } const anyUnhealthy = containers.some(c => c.healthStatus === 'unhealthy'); const anyStarting = containers.some(c => c.healthStatus === 'starting'); const anyHealthy = containers.some(c => c.healthStatus === 'healthy'); if (anyUnhealthy) { return { label: 'running · unhealthy', dotClass: 'bg-destructive', className: 'border-destructive/40 bg-destructive/10 text-destructive', pulse: true, }; } if (anyStarting) { return { label: 'running · starting', dotClass: 'bg-warning', className: 'border-warning/40 bg-warning/10 text-warning', pulse: true, }; } if (anyHealthy) { return { label: 'running · healthy', dotClass: 'bg-success', className: 'border-success/40 bg-success/10 text-success', pulse: true, }; } return { label: 'running', dotClass: 'bg-success', className: 'border-success/40 bg-success/10 text-success', pulse: true, }; }; export interface EditorViewProps { // Identity stackName: string; isDarkMode: boolean; // Stack data (raw; safe-wrapped locally for backwards-compat with prior idiom) containers: ContainerInfo[]; containerStats: Record; content: string; envContent: string; envExists: boolean; envFiles: string[]; selectedEnvFile: string; isFileLoading: boolean; backupInfo: { exists: boolean; timestamp: number | null }; gitSourcePendingMap: Record; notifications: NotificationItem[]; // Editor mode activeTab: 'compose' | 'env' | 'files'; isEditing: boolean; editingCompose: boolean; logsMode: 'structured' | 'raw'; copiedDigest: string | null; loadingAction: StackAction | null; stackMisconfigScanning: boolean; // Permissions / tier / context can: ReturnType['can']; isAdmin: boolean; isPaid: boolean; trivy: { available: boolean }; activeNode: Node | null; // Refs copiedDigestTimerRef: React.MutableRefObject; // Stack actions deployStack: (e: React.MouseEvent) => Promise; restartStack: (e: React.MouseEvent) => Promise; stopStack: (e: React.MouseEvent) => Promise; updateStack: (e?: React.MouseEvent) => Promise; rollbackStack: () => Promise; scanStackConfig: () => Promise; // Edit lifecycle enterEditMode: () => void; requestSave: () => void; requestSaveAndDeploy: (e: React.MouseEvent) => void; discardChanges: () => void; setContent: (next: string) => void; setEnvContent: (next: string) => void; changeEnvFile: (file: string) => Promise; // Container / service actions openLogViewer: (containerId: string, containerName: string) => void; openBashModal: (containerId: string, containerName: string) => void; serviceAction: ( action: 'start' | 'stop' | 'restart', serviceName: string, ) => Promise; // UI state setters setActiveTab: (tab: 'compose' | 'env' | 'files') => void; setLogsMode: (mode: 'structured' | 'raw') => void; setEditingCompose: (open: boolean) => void; setGitSourceOpen: (open: boolean) => void; setCopiedDigest: React.Dispatch>; // Composed action — wraps setStackToDelete + setDeleteDialogOpen requestDeleteStack: () => void; } export function EditorView({ stackName, isDarkMode, containers, containerStats, content, envContent, envExists, envFiles, selectedEnvFile, isFileLoading, backupInfo, gitSourcePendingMap, notifications, activeTab, isEditing, editingCompose, logsMode, copiedDigest, loadingAction, stackMisconfigScanning, can, isAdmin, isPaid, trivy, activeNode, copiedDigestTimerRef, deployStack, restartStack, stopStack, updateStack, rollbackStack, scanStackConfig, enterEditMode, requestSave, requestSaveAndDeploy, discardChanges, setContent, setEnvContent, changeEnvFile, openLogViewer, openBashModal, serviceAction, setActiveTab, setLogsMode, setEditingCompose, setGitSourceOpen, setCopiedDigest, requestDeleteStack, }: EditorViewProps) { const monacoEditorRef = useRef(null); // 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 0x0 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 safeContainers = containers || []; const safeContent = content || ''; const safeEnvContent = envContent || ''; const isRunning = safeContainers.some(c => c.State === 'running'); return (
{/* Left column: identity + health strip + logs, stacked */}
{/* Command Center Card (identity + health strip) */}
{/* Identity block */}
{(activeNode?.name || 'local')} stacks {stackName}
{stackName} {(() => { const pill = getStackStatePill(safeContainers); if (!pill) return null; return ( ); })()}
{(() => { const first = safeContainers[0]; if (!first?.Image) return null; const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : ''; return (
image · {first.Image} {digest && first.ImageID && ( <> · digest {digest} )}
); })()}
{/* Action Bar */} {can('stack:deploy', 'stack', stackName) && (
{isRunning ? ( ) : ( )} {isRunning && ( )} {(() => { const canRollback = isPaid && backupInfo.exists; const canScan = trivy.available && isAdmin && isPaid; const hasOverflowExtras = canRollback || canScan; return ( {canRollback && (
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} {backupInfo.timestamp && ( {new Date(backupInfo.timestamp).toLocaleString()} )}
)} {canScan && ( {stackMisconfigScanning ? ( ) : ( )} {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} )} {hasOverflowExtras && } {loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
); })()}
)}
{/* Per-container health strip */}

CONTAINERS

{safeContainers.length === 0 ? (
No containers running for this stack.
) : (
{safeContainers.map(container => { let mainPort: number | undefined; let mainPortPrivate: number | undefined; let mainPortProto: string | 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]; let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort)); if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort)); if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort)); const chosen = match || container.Ports[0]; mainPort = chosen.PublicPort; mainPortPrivate = chosen.PrivatePort; mainPortProto = 'tcp'; } const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; const isActive = container.State === 'running' || container.State === 'paused'; const health = container.healthStatus; const uptime = isActive ? extractUptime(container.Status) : null; const hcLabel = healthcheckLabel(health); const stats = containerStats[container?.Id]; const history = stats?.history; const badgeClass = health === 'unhealthy' || !isActive ? 'bg-destructive text-destructive-foreground' : health === 'starting' ? 'bg-warning text-warning-foreground' : 'bg-success text-success-foreground'; const badgeGlyph = health === 'unhealthy' || !isActive ? '✗' : health === 'starting' ? '…' : '✓'; const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)'; return (
{badgeGlyph}
{containerName}
{uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} {hcLabel ? <>·{hcLabel} : null} {mainPort && mainPortPrivate ? ( <> · {mainPort} → {mainPortPrivate}/{mainPortProto} ) : null}
{isAdmin && ( )} {container.Service && ( {isActive ? ( <> serviceAction('restart', container.Service!)}> Restart service serviceAction('stop', container.Service!)}> Stop service ) : ( serviceAction('start', container.Service!)}> Start service )} )}
{isActive ? (
cpu {stats?.cpu ?? '-'}
mem {stats?.ram ?? '-'}
net i/o {stats?.net ?? '-'}
) : null}
); })}
)}
{/* Logs Section (fills remaining left-column height) */}

Logs

{logsMode === 'structured' ? ( ) : (
)}
{/* Right column: anatomy panel by default, Monaco editor when editing */} {editingCompose ? (
setActiveTab(value as 'compose' | 'env' | 'files')}> compose.yaml .env Files {activeTab === 'env' && envFiles.length > 1 && ( )}
{activeTab !== 'files' && can('stack:edit', 'stack', stackName) && ( <> {!isEditing ? ( ) : (
Save Only Discard Changes
)} )}
{activeTab === 'files' ? ( setActiveTab('compose')} onNavigateToEnv={() => setActiveTab('env')} /> ) : ( <> {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 }, fontFamily: "'Geist Mono', monospace", fontSize: 14, padding: { top: 10 }, scrollBeyondLastLine: false, readOnly: !isEditing || !can('stack:edit', 'stack', stackName), }} /> )} {isFileLoading && (
Loading...
)}
)}
) : ( setEditingCompose(true)} onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }} onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} canEdit={can('stack:edit', 'stack', stackName)} notifications={notifications} /> )}
); }