import { Suspense, useRef, useEffect } from 'react'; import { Editor } from '@/lib/monacoLoader'; import { Save, Pencil, X, Rocket, ChevronDown, GitBranch, FolderOpen, } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent, CardHeader } from '../ui/card'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem, } from '../ui/tabs'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { springs } from '@/lib/motion'; import ErrorBoundary from '../ErrorBoundary'; import StackAnatomyPanel from '../StackAnatomyPanel'; import { StackFileExplorer } from '@/components/files/StackFileExplorer'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks'; import { MobileStackDetail } from './MobileStackDetail'; import { RecoveryChip } from './RecoveryChip'; import { retryHandlerFor } from './recovery-retry'; 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'; /** * Stack operations the recovery panel can offer safe next steps for. A failed * stop/start/delete is not "recoverable" through retry/restart/rollback, so it * never produces a record; narrowing the type keeps the panel's retry routing * exhaustive. */ export type RecoverableAction = Extract; /** * Terminal record of a failed stack operation, kept in memory per stack so the * recovery panel can offer safe next steps after an update/deploy fails or * stalls. Cleared when the same stack's next operation succeeds or is dismissed, * and on active-node change (the keyed stack filename can repeat across nodes). */ export interface StackActionResult { action: RecoverableAction; rolledBack: boolean; errorMessage?: string; startedAt: number; endedAt: number; // Last live output line captured only when a matching deploy-feedback // session was streaming this stack at failure time; omitted otherwise so a // line from another stack/session never leaks into diagnostics. lastOutputLine?: string; } export interface ContainerStatsEntry { cpu: string; ram: string; net: string; lastRx?: number; lastTx?: number; history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] }; } export interface EditorViewProps { // Identity stackName: string; isDarkMode: boolean; // Stack data (raw; safe-wrapped locally for backwards-compat with prior idiom) containers: ContainerInfo[]; containerStats: Record; containerStatsError: string | null; 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; 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; // Recovery surface for a failed/stalled operation on this stack (undefined // when the last op succeeded or none has run). onRefreshState re-syncs // container state; onDismissRecovery drops the record. recoveryResult?: StackActionResult; onRefreshState: () => void; onDismissRecovery: () => void; // Mobile-only: back affordance in the detail header returns to the stack list. onMobileBack?: () => void; // Mobile-only: notifications + more-menu cluster for the detail header right // slot (the global TopBar is dropped on the full-screen detail surface). headerActions?: React.ReactNode; } export function EditorView(props: EditorViewProps) { const { stackName, isDarkMode, containers, containerStats, containerStatsError, content, envContent, envExists, envFiles, selectedEnvFile, isFileLoading, backupInfo, gitSourcePendingMap, notifications, activeTab, isEditing, editingCompose, logsMode, copiedDigest, loadingAction, stackMisconfigScanning, can, isAdmin, 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, recoveryResult, onRefreshState, onDismissRecovery, } = props; const monacoEditorRef = useRef(null); // Dispose the underlying Monaco model when EditorView unmounts. The // @monaco-editor/react wrapper reuses a single model per editor instance // (we do not pass a `path`), so this catches the unmount case rather than // a per-stack-switch leak. useEffect(() => { return () => { const editor = monacoEditorRef.current; if (!editor) return; try { editor.getModel()?.dispose(); } catch { // Editor already torn down by Monaco; nothing to do. } }; }, []); // 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'); const canRead = can('stack:read', 'stack', stackName); useEffect(() => { if (activeTab === 'files' && !canRead) { setActiveTab('compose'); } }, [activeTab, canRead, setActiveTab]); // Below md, render the segmented full-screen mobile detail instead of the // desktop two-pane grid. All hooks above run unconditionally before this // branch so hook order stays stable across breakpoints. const isMobile = useIsMobile(); if (isMobile) { return ; } return (
{/* Left column: identity + health strip + logs, stacked */}
{/* Command Center Card (identity + health strip) */}
{recoveryResult && loadingAction == null && (
)}
{/* Logs Section (fills remaining left-column height) */}
{/* Right column: anatomy panel by default, Monaco editor when editing */} {editingCompose ? (
setActiveTab(value as 'compose' | 'env' | 'files')}> compose.yaml .env {canRead && ( Files )} {activeTab === 'env' && envFiles.length > 1 && ( )}
{activeTab !== 'files' && can('stack:edit', 'stack', stackName) && ( <> {!isEditing ? ( ) : (
Save Only Discard Changes
)} )}
{activeTab === 'files' && canRead ? ( 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); setActiveTab('compose'); }} onOpenFiles={canRead ? () => { setEditingCompose(true); setActiveTab('files'); } : undefined} onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} applying={loadingAction === 'update'} canEdit={can('stack:edit', 'stack', stackName)} notifications={notifications} /> )}
); }