import { Suspense, useRef, useEffect, useState } from 'react'; import { Editor } from '@/lib/monacoLoader'; import { Save, Pencil, X, Rocket, ChevronDown, GitBranch, FolderOpen, Maximize2, Minimize2, } 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 { ScrollArea } from '../ui/scroll-area'; import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks'; import { MobileStackDetail } from './MobileStackDetail'; import { RecoveryChip } from './RecoveryChip'; import { StackOperationBanner } from './StackOperationBanner'; import { retryHandlerFor } from './recovery-retry'; import type { NotificationItem } from '../dashboard/types'; import type { Node } from '@/context/NodeContext'; import type { useAuth } from '@/context/AuthContext'; import type { useStackMuteActions } from '@/hooks/useMuteRuleActions'; export interface ContainerInfo { Id: string; Names: string[]; Service?: string; State: string; Status?: string; Ports?: { PrivatePort: number; PublicPort: number; Type?: string }[]; 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; /** * Server-side classification of a failed deploy/update: a cause headline and a * suggested next step. `reason` stays a plain string here; the backend owns the * category vocabulary and the UI only displays it. */ export interface FailureClassification { reason: string; label: string; suggestion: string; } /** * 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; // Classified cause + suggested next action from the failed response body, // when the backend (or the unreachable-node fallback) provided one. failure?: FailureClassification; } 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; // Session start (ms) of the active deploy-feedback op, or null when none, for // the inline progress banner's elapsed readout. panelStartedAt: number | null; // Mobile-only: back affordance in the detail header returns to the stack list. onMobileBack?: () => void; // Mobile-only (always supplied by renderEditor): close the full-screen // compose/.env editor, routed through the unsaved-changes guard so a dirty // close prompts before discarding. Required, not optional, because the // fallback would silently discard edits; the desktop EditorView ignores it. onCloseEditor: () => void; // Mobile-only (always supplied by renderEditor): true when the compose or env // buffer differs from disk; gates the env-file selector so switching files // cannot drop unsaved edits. Required so a wiring gap is a compile error // rather than a silently-disabled data-loss guard. hasUnsavedChanges: () => boolean; // 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; stackMuteActions?: ReturnType; } 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, panelStartedAt, stackMuteActions, } = 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]); // Fullscreen the file browser + editor by collapsing the left column. Only // meaningful on the files tab; reset when leaving it or closing the editor so // it can never strand the compose/env panels in a single-column layout. const [filesFullscreen, setFilesFullscreen] = useState(false); useEffect(() => { if (!editingCompose || activeTab !== 'files') setFilesFullscreen(false); }, [editingCompose, activeTab]); // Expand the logs by collapsing the Command Center card so the logs pane // fills the left column. Toggled from the structured log viewer header. const [logsExpanded, setLogsExpanded] = useState(false); // The expand control lives only in the structured viewer; reset when the // raw terminal is selected so the Command Center can't be stranded hidden. useEffect(() => { if (logsMode === 'raw') setLogsExpanded(false); }, [logsMode]); // 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. Hidden in files fullscreen so the editor card fills the width. */} {!filesFullscreen && (
{/* Command Center Card (identity + health strip). Hidden when the logs are expanded so the logs pane fills the column. */} {!logsExpanded && ( 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
{recoveryResult && loadingAction == null && (
)}
{safeContainers.length > 1 ? ( ) : ( )}
)} {/* Logs Section (fills remaining left-column height). On multi- container stacks a min-h guarantees logs are never hidden. */} {safeContainers.length > 1 ? (
setLogsExpanded((v) => !v)} />
) : ( setLogsExpanded((v) => !v)} /> )}
)} {/* 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' && ( )}
{activeTab === 'files' && canRead ? ( setActiveTab('compose')} onNavigateToEnv={() => setActiveTab('env')} /> ) : ( <> {activeTab === 'env' && (
Variables defined in the project environment file are available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, 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} stackMuteActions={stackMuteActions} /> )}
); }