From 1cf996142b1114626d608c314838f07993cc13d0 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 4 May 2026 07:43:02 -0400 Subject: [PATCH] refactor(frontend): EditorLayout final shell (B4-7) (#906) * refactor(frontend): extract useOverlayState hook from EditorLayout * refactor(frontend): extract useStackActions hook and wire useOverlayState into EditorLayout * refactor(frontend): fix quality issues in useStackActions post-review * fix(frontend): fix interval leak, RunResult contract, yml hardcode, and loadFile length in useStackActions * refactor(frontend): extract useSidebarContextMenu hook from EditorLayout * refactor(frontend): extract ShellOverlays component from EditorLayout * refactor(frontend): relocate Monaco layout effect and log-viewer event listener out of EditorLayout The Monaco tab-switch layout effect is now self-contained in EditorView, alongside its monacoEditorRef. The SENCHO_OPEN_LOGS_EVENT listener moves into useOverlayState, where openLogViewer lives. EditorLayout is left with the two coordination effects that depend on cross-hook state. --- frontend/src/components/EditorLayout.tsx | 1216 ++--------------- .../components/EditorLayout/EditorView.tsx | 23 +- .../components/EditorLayout/ShellOverlays.tsx | 188 +++ .../hooks/useOverlayState.test.ts | 100 ++ .../EditorLayout/hooks/useOverlayState.ts | 102 ++ .../hooks/useSidebarContextMenu.ts | 144 ++ .../EditorLayout/hooks/useStackActions.ts | 820 +++++++++++ 7 files changed, 1491 insertions(+), 1102 deletions(-) create mode 100644 frontend/src/components/EditorLayout/ShellOverlays.tsx create mode 100644 frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts create mode 100644 frontend/src/components/EditorLayout/hooks/useOverlayState.ts create mode 100644 frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts create mode 100644 frontend/src/components/EditorLayout/hooks/useStackActions.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 69006b0d..e94273ce 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,40 +1,22 @@ -import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'; - -import type { NotificationItem } from './dashboard/types'; -import BashExecModal from './BashExecModal'; -import LazyBoundary from './LazyBoundary'; +import { useEffect, useRef } from 'react'; import { Button } from './ui/button'; import { Plus } from 'lucide-react'; -import { type Label as StackLabel, type LabelColor } from './label-types'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; -import { apiFetch } from '@/lib/api'; -import { toast } from '@/components/ui/toast-store'; -import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog'; import { TopBar } from './TopBar'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; -import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog'; -import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog'; -import { EditorView, type StackAction } from './EditorLayout/EditorView'; +import { EditorView } from './EditorLayout/EditorView'; +import { ShellOverlays } from './EditorLayout/ShellOverlays'; import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState'; import { useStackListState } from './EditorLayout/hooks/useStackListState'; import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState'; +import { useOverlayState } from './EditorLayout/hooks/useOverlayState'; +import { useStackActions } from './EditorLayout/hooks/useStackActions'; import { useTheme } from './EditorLayout/hooks/useTheme'; import { useNotifications } from './EditorLayout/hooks/useNotifications'; import { useContainerStats } from './EditorLayout/hooks/useContainerStats'; -import { StackAlertSheet } from './StackAlertSheet'; -import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; -import { GitSourcePanel } from './stack/GitSourcePanel'; -import { LogViewer } from './LogViewer'; - -// SecurityHistoryView is the only lazy-loaded view that lives outside -// the ViewRouter switch — it renders as an overlay sheet wired into the -// settings flow, not as a top-level tab. The other tab-level lazy views -// (HostConsole, FleetView, AuditLogView, etc.) live inside ViewRouter. -const SecurityHistoryView = lazy(() => - import('./SecurityHistoryView').then(m => ({ default: m.SecurityHistoryView })), -); +import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu'; import { NodeSwitcher } from './NodeSwitcher'; import { GlobalCommandPalette, @@ -44,152 +26,97 @@ import { import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; -import type { Node } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; -import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { StackSidebar } from '@/components/sidebar/StackSidebar'; import type { StackRowStatus } from '@/components/sidebar/stack-status-utils'; -import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; -import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog'; export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); const { status: trivy } = useTrivyStatus(); const { runWithLog } = useDeployFeedback(); + + const editorState = useEditorViewState(); const { - stackMisconfigScanning, setStackMisconfigScanning, + stackMisconfigScanning, copiedDigest, setCopiedDigest, copiedDigestTimerRef, content, setContent, - originalContent, setOriginalContent, envContent, setEnvContent, - originalEnvContent, setOriginalEnvContent, - envExists, setEnvExists, - envFiles, setEnvFiles, - selectedEnvFile, setSelectedEnvFile, - containers, setContainers, + envExists, + envFiles, + selectedEnvFile, + containers, activeTab, setActiveTab, logsMode, setLogsMode, gitSourceOpen, setGitSourceOpen, - gitSourcePendingMap, setGitSourcePendingMap, - isFileLoading, setIsFileLoading, - backupInfo, setBackupInfo, - isEditing, setIsEditing, + gitSourcePendingMap, + isFileLoading, + backupInfo, + isEditing, editingCompose, setEditingCompose, - } = useEditorViewState(); + } = editorState; + + const stackListState = useStackListState(); const { - files, - selectedFile, setSelectedFile, + selectedFile, isLoading, - stackActions, + stackActions: stackActionMap, isScanning, searchQuery, setSearchQuery, stackStatuses, - stackPorts, - labels, stackLabelMap, - autoUpdateSettings, setAutoUpdateSettings, filterChip, setFilterChip, bulkMode, selectedFiles, filterCounts, chipFilteredFiles, remoteResults, - setStackAction, clearStackAction, isStackBusy, - setOptimisticStatus, - refreshLabels, + isStackBusy, refreshStacks, fetchAutoUpdateSettings, handleScanStacks, scheduleStateInvalidateRefresh, toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, - stackUpdates, fetchImageUpdates, - pinned, pin, unpin, isPinned, + stackUpdates, + pinned, isCollapsed, toggleCollapse, remoteSearchLoading, - } = useStackListState(); - const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); - const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null); - const [policyBypassing, setPolicyBypassing] = useState(false); + } = stackListState; + const { nodes, activeNode, setActiveNode } = useNodes(); - const monacoEditorRef = useRef(null); - const pendingStackLoadRef = useRef(null); - const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); - const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [stackToDelete, setStackToDelete] = useState(null); - const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); - const [pendingUnsavedNode, setPendingUnsavedNode] = useState(null); - const getStackMenuVisibility = (file: string) => { - const status = stackStatuses[file]; - return { - showDeploy: status !== 'running', - showStop: status === 'running', - showRestart: status === 'running', - showUpdate: status === 'running', - }; - }; - const openStackApp = (file: string) => { - const port = stackPorts[file]; - if (!port) return; - const host = activeNode?.type === 'remote' && activeNode?.api_url - ? new URL(activeNode.api_url).hostname - : window.location.hostname; - window.open(`http://${host}:${port}`, '_blank'); - }; + const overlayState = useOverlayState(); + const { + createDialogOpen, setCreateDialogOpen, + } = overlayState; - const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null; - - const { theme, setTheme, isDarkMode } = useTheme(); const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); - const [diffPreview, setDiffPreview] = useState<{ - mode: 'save' | 'save-and-deploy'; - language: 'yaml' | 'ini'; - original: string; - modified: string; - fileName: string; - } | null>(null); - const [diffPreviewConfirming, setDiffPreviewConfirming] = useState(false); - // 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); - - - const resetEditorState = () => { - setSelectedFile(null); - setContent(''); - setOriginalContent(''); - setEnvContent(''); - setOriginalEnvContent(''); - setEnvFiles([]); - setSelectedEnvFile(''); - setEnvExists(false); - setContainers([]); - setIsEditing(false); - }; + // Use a ref to break the circular dependency: + // useViewNavigationState needs onNavigateToDashboard -> resetEditorState + // but stackActions isn't created until after navState + const resetEditorStateRef = useRef<() => void>(() => {}); + const navState = useViewNavigationState({ + onNavigateToDashboard: () => resetEditorStateRef.current(), + }); const { activeView, setActiveView, settingsSection, setSettingsSection, securityHistoryOpen, setSecurityHistoryOpen, filterNodeId, setFilterNodeId, - schedulePrefill, setSchedulePrefill, + schedulePrefill, mobileNavOpen, setMobileNavOpen, handleOpenSettings, handlePrefillConsumed, handleNavigate, navItems, - } = useViewNavigationState({ onNavigateToDashboard: resetEditorState }); + } = navState; const isAdmiral = license?.variant === 'admiral'; @@ -206,49 +133,43 @@ export default function EditorLayout() { }); const containerStats = useContainerStats(containers); - const [alertSheetOpen, setAlertSheetOpen] = useState(false); - const [alertSheetStack, setAlertSheetStack] = useState(''); - const [autoHealStackName, setAutoHealStackName] = useState(null); - const openAlertSheet = (stackName: string) => { - setAlertSheetStack(stackName); - setAlertSheetOpen(true); - }; + const stackActions = useStackActions({ + editorState, + stackListState, + navState, + overlayState, + activeNode, + setActiveNode, + nodes, + isPaid, + runWithLog, + diffPreviewEnabled, + }); - // 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 0×0 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]); + // Wire the ref now that stackActions is available + resetEditorStateRef.current = stackActions.resetEditorState; - /** - * Populate the per-stack "pending git source update" map. Runs on mount and - * whenever a git-source change is signalled by the panel. Backend failure - * leaves the map empty, which is the correct fallback (no badges shown). - */ - const refreshGitSourcePending = async () => { - try { - const res = await apiFetch('/git-sources'); - if (!res.ok) return; - const sources: Array<{ stack_name: string; pending_commit_sha: string | null }> = await res.json(); - const map: Record = {}; - for (const s of sources) { - if (s.pending_commit_sha) map[s.stack_name] = true; - } - setGitSourcePendingMap(map); - } catch { - // Non-critical; leave prior state. - } - }; + const buildMenuCtx = useSidebarContextMenu({ + stackListState, + navState, + overlayState, + stackActions, + activeNode, + isPaid, + isAdmiral, + can, + }); + + const { + pendingStackLoadRef, + pendingLogsRef, + } = stackActions; + + const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null; + const stackName = selectedFile || ''; + + const { theme, setTheme, isDarkMode } = useTheme(); // 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. @@ -257,25 +178,17 @@ export default function EditorLayout() { const pendingStack = pendingStackLoadRef.current; pendingStackLoadRef.current = null; - setSelectedFile(null); - setContent(''); - setOriginalContent(''); - setEnvContent(''); - setOriginalEnvContent(''); - setContainers([]); - setIsEditing(false); + stackActions.resetEditorState(); if (pendingStack) { - loadFile(pendingStack); + void stackActions.loadFile(pendingStack); } else { setActiveView('dashboard'); } refreshStacks(); - // Image-update fetching + 5-minute poll are owned by useImageUpdates, - // which mirrors this effect's activeNode.id dependency. fetchAutoUpdateSettings(); - refreshGitSourcePending(); + void stackActions.refreshGitSourcePending(); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps // Resolve a pending container name (from notification click) to a live @@ -297,791 +210,7 @@ export default function EditorLayout() { detail: { containerId: match.Id, containerName: pending.containerName }, })); } - }, [containers, selectedFile]); - - const hasUnsavedChanges = () => - content !== originalContent || envContent !== originalEnvContent; - - // Global-search result click: switch the active node, clear the query so the - // sidebar snaps back to the new node's full stack list, then open the stack. - // setActiveNode writes to localStorage synchronously, so the next apiFetch - // picks up the new node-id header without waiting for a re-render. - const loadFileOnNode = async (node: Node, filename: string) => { - if (!filename) return; - if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) { - setPendingUnsavedNode(node); - setPendingUnsavedLoad(filename); - return; - } - setActiveNode(node); - setSearchQuery(''); - await loadFile(filename); - }; - - const loadFile = async (filename: string) => { - if (!filename) return; - // Guard: if there are unsaved changes and we're switching to a different stack, confirm first - if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) { - setPendingUnsavedLoad(filename); - return; - } - setIsFileLoading(true); - setIsEditing(false); // Reset to view mode when loading a new file - setEditingCompose(false); // Default back to anatomy on stack switch - setActiveTab('compose'); - 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([]); - } - - // Load backup info (Skipper+ only) - if (isPaid) { - try { - const backupRes = await apiFetch(`/stacks/${filename}/backup`); - if (backupRes.ok) setBackupInfo(await backupRes.json()); - else setBackupInfo({ exists: false, timestamp: null }); - } catch { - setBackupInfo({ exists: false, timestamp: null }); - } - } - } catch (error) { - console.error('Failed to load file:', error); - setSelectedFile(null); - setContent(''); - setOriginalContent(''); - setEnvContent(''); - setOriginalEnvContent(''); - setContainers([]); - } finally { - setIsFileLoading(false); - } - }; - - const navigateToNotification = (notif: NotificationItem) => { - if (!notif.stack_name) return; - pendingLogsRef.current = notif.container_name - ? { stackName: notif.stack_name, containerName: notif.container_name } - : null; - const targetNode = notif.nodeId !== undefined - ? nodes.find(n => n.id === notif.nodeId) - : activeNode; - if (targetNode && targetNode.id !== activeNode?.id) { - loadFileOnNode(targetNode, notif.stack_name); - } else { - loadFile(notif.stack_name); - } - }; - - const changeEnvFile = async (file: string) => { - setSelectedEnvFile(file); - setIsFileLoading(true); - try { - const res = await apiFetch(`/stacks/${selectedFile}/env?file=${encodeURIComponent(file)}`); - if (!res.ok) { - // Don't stuff a JSON error body into the editor on a non-OK response. - setEnvContent(''); - setOriginalEnvContent(''); - toast.error('Could not load env file'); - return; - } - const text = await res.text(); - setEnvContent(text || ''); - setOriginalEnvContent(text || ''); - } catch (e) { - console.error('Failed to switch env file', e); - setEnvContent(''); - setOriginalEnvContent(''); - } finally { - setIsFileLoading(false); - } - }; - - const saveFile = async () => { - if (activeTab === 'files') return; - 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 requestSave = () => { - const isCompose = activeTab === 'compose'; - const orig = isCompose ? originalContent : originalEnvContent; - const curr = isCompose ? content : envContent; - if (diffPreviewEnabled && activeTab !== 'files' && curr !== orig) { - setDiffPreview({ - mode: 'save', - language: isCompose ? 'yaml' : 'ini', - original: orig, - modified: curr, - fileName: isCompose ? 'compose.yaml' : (selectedEnvFile || '.env'), - }); - } else { - void saveFile(); - } - }; - - const requestSaveAndDeploy = (e: React.MouseEvent) => { - const isCompose = activeTab === 'compose'; - const orig = isCompose ? originalContent : originalEnvContent; - const curr = isCompose ? content : envContent; - if (diffPreviewEnabled && activeTab !== 'files' && curr !== orig) { - setDiffPreview({ - mode: 'save-and-deploy', - language: isCompose ? 'yaml' : 'ini', - original: orig, - modified: curr, - fileName: isCompose ? 'compose.yaml' : (selectedEnvFile || '.env'), - }); - } else { - void handleSaveAndDeploy(e); - } - }; - - const rollbackStack = async () => { - if (!selectedFile || isStackBusy(selectedFile)) return; - const stackFile = selectedFile; - setStackAction(stackFile, 'rollback'); - setOptimisticStatus(stackFile, 'running'); - try { - const res = await apiFetch(`/stacks/${stackFile}/rollback`, { method: 'POST' }); - if (!res.ok) { - const err = await res.json(); - throw new Error(err?.error || 'Rollback failed'); - } - toast.success('Stack rolled back successfully.'); - // Reload the editor content - const contentRes = await apiFetch(`/stacks/${stackFile}`); - const text = await contentRes.text(); - setContent(text || ''); - setOriginalContent(text || ''); - // Refresh backup info - const backupRes = await apiFetch(`/stacks/${stackFile}/backup`); - if (backupRes.ok) setBackupInfo(await backupRes.json()); - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : 'Rollback failed'; - toast.error(msg); - } finally { - clearStackAction(stackFile); - refreshStacks(true); - } - }; - - const handleSaveAndDeploy = async (e: React.MouseEvent) => { - await saveFile(); - await deployStack(e); - }; - - const discardChanges = () => { - if (activeTab === 'files') return; - if (activeTab === 'compose') { - setContent(originalContent); - } else { - setEnvContent(originalEnvContent); - } - setIsEditing(false); - }; - - const enterEditMode = () => { - setIsEditing(true); - }; - - const scanStackConfig = async () => { - if (!selectedFile || stackMisconfigScanning) return; - const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); - setStackMisconfigScanning(true); - const loadingId = toast.loading(`Scanning ${stackName} configuration...`); - try { - const res = await apiFetch('/security/scan/stack', { - method: 'POST', - body: JSON.stringify({ stackName }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error || 'Failed to start scan'); - if (data.status === 'failed') { - throw new Error(data.error || 'Scan failed'); - } - toast.success( - `Config scan complete: ${data.misconfig_count ?? 0} misconfigurations found`, - ); - setStackMisconfigScanId(data.id as number); - } catch (error) { - const err = error as { message?: string; error?: string; data?: { error?: string } }; - toast.error(err?.message || err?.error || err?.data?.error || 'Config scan failed'); - } finally { - toast.dismiss(loadingId); - setStackMisconfigScanning(false); - } - }; - - const runDeploy = async ( - stackName: string, - stackFile: string, - ignorePolicy: boolean, - started?: Promise, - ): Promise<{ ok: boolean; errorMessage?: string }> => { - const previousStatus = stackStatuses[stackFile]; - setOptimisticStatus(stackFile, 'running'); - try { - const path = ignorePolicy - ? `/stacks/${stackName}/deploy?ignorePolicy=true` - : `/stacks/${stackName}/deploy`; - if (started) await started; - const response = await apiFetch(path, { method: 'POST' }); - if (!response.ok) { - const rawBody = await response.text(); - if (response.status === 409) { - let parsed: PolicyBlockPayload | null = null; - try { parsed = JSON.parse(rawBody) as PolicyBlockPayload; } catch { /* not JSON */ } - if (parsed && parsed.policy && Array.isArray(parsed.violations)) { - setPolicyBlock({ stackName, payload: parsed }); - if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); - toast.error(`Deploy blocked by policy "${parsed.policy.name}"`); - return { ok: false, errorMessage: `Deploy blocked by policy "${parsed.policy.name}"` }; - } - } - throw new Error(rawBody || 'Deploy failed'); - } - setPolicyBlock(null); - toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!'); - if (selectedFile === stackFile) { - const containersRes = await apiFetch(`/stacks/${stackName}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - } - if (isPaid) { - try { - const backupRes = await apiFetch(`/stacks/${stackName}/backup`); - if (backupRes.ok) setBackupInfo(await backupRes.json()); - } catch { /* ignore */ } - } - return { ok: true }; - } catch (error) { - console.error('Failed to deploy:', error); - if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); - const errorMessage = (error as Error).message || 'Failed to deploy stack'; - toast.error(isPaid ? `${errorMessage} - automatically rolled back to previous version.` : errorMessage); - return { ok: false, errorMessage }; - } - }; - - const deployStack = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!selectedFile || isStackBusy(selectedFile)) return; - const stackFile = selectedFile; - const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); - setStackAction(stackFile, 'deploy'); - try { - await runWithLog({ stackName, action: 'deploy' }, (started) => - runDeploy(stackName, stackFile, false, started) - ); - } finally { - clearStackAction(stackFile); - refreshStacks(true); - } - }; - - const bypassPolicyAndDeploy = async () => { - if (!policyBlock) return; - const stackFile = `${policyBlock.stackName}.yml`; - const existingFile = selectedFile && selectedFile.startsWith(policyBlock.stackName + '.') - ? selectedFile - : stackFile; - setPolicyBypassing(true); - setStackAction(existingFile, 'deploy'); - try { - await runWithLog({ stackName: policyBlock.stackName, action: 'deploy' }, (started) => - runDeploy(policyBlock.stackName, existingFile, true, started) - ); - } finally { - setPolicyBypassing(false); - clearStackAction(existingFile); - refreshStacks(true); - } - }; - - const stopStack = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!selectedFile || isStackBusy(selectedFile)) return; - const stackFile = selectedFile; - const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); - setStackAction(stackFile, 'stop'); - const previousStatus = stackStatuses[stackFile]; - setOptimisticStatus(stackFile, 'exited'); - try { - await runWithLog({ stackName, action: 'stop' }, async (started) => { - await started; - 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!'); - if (selectedFile === stackFile) { - const containersRes = await apiFetch(`/stacks/${stackName}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - } - return { ok: true }; - }); - } catch (error) { - console.error('Failed to stop:', error); - if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); - toast.error((error as Error).message || 'Failed to stop stack'); - } finally { - clearStackAction(stackFile); - refreshStacks(true); - } - }; - - const restartStack = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!selectedFile || isStackBusy(selectedFile)) return; - const stackFile = selectedFile; - const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); - setStackAction(stackFile, 'restart'); - const previousStatus = stackStatuses[stackFile]; - setOptimisticStatus(stackFile, 'running'); - try { - await runWithLog({ stackName, action: 'restart' }, async (started) => { - await started; - 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!'); - if (selectedFile === stackFile) { - const containersRes = await apiFetch(`/stacks/${stackName}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - } - return { ok: true }; - }); - } catch (error) { - console.error('Failed to restart:', error); - if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); - toast.error((error as Error).message || 'Failed to restart stack'); - } finally { - clearStackAction(stackFile); - refreshStacks(true); - } - }; - - const serviceAction = async (action: 'start' | 'stop' | 'restart', serviceName: string) => { - if (!selectedFile) return; - const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); - try { - const r = await apiFetch(`/stacks/${stackName}/services/${encodeURIComponent(serviceName)}/${action}`, { - method: 'POST', - }); - if (!r.ok) throw new Error((await r.text()) || `${action} failed`); - const label = action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started'; - toast.success(`Service "${serviceName}" ${label}`); - const cr = await apiFetch(`/stacks/${stackName}/containers`); - const conts = await cr.json(); - setContainers(Array.isArray(conts) ? conts : []); - } catch (e) { - console.error(`Failed to ${action} service "${serviceName}":`, e); - toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`); - } finally { - refreshStacks(true); - } - }; - - const updateStack = async (e?: React.MouseEvent) => { - e?.preventDefault(); - e?.stopPropagation(); - if (!selectedFile || isStackBusy(selectedFile)) return; - const stackFile = selectedFile; - const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); - setStackAction(stackFile, 'update'); - const previousStatus = stackStatuses[stackFile]; - setOptimisticStatus(stackFile, 'running'); - try { - await runWithLog({ stackName, action: 'update' }, async (started) => { - await started; - 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!'); - fetchImageUpdates(); - if (selectedFile === stackFile) { - const containersRes = await apiFetch(`/stacks/${stackName}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - } - return { ok: true }; - }); - } catch (error) { - console.error('Failed to update:', error); - if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); - toast.error((error as Error).message || 'Failed to update stack'); - } finally { - clearStackAction(stackFile); - refreshStacks(true); - } - }; - - const deleteStack = async (pruneVolumes: boolean) => { - if (!stackToDelete) return; - // Find matching file entry for per-stack tracking - const deleteKey = files.find(f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete) ?? stackToDelete; - if (isStackBusy(deleteKey)) return; - setStackAction(deleteKey, 'delete'); - try { - const url = pruneVolumes - ? `/stacks/${stackToDelete}?pruneVolumes=true` - : `/stacks/${stackToDelete}`; - const response = await apiFetch(url, { - 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) { - console.error('Failed to delete stack:', error); - toast.error((error as Error).message || 'Failed to delete stack'); - } finally { - clearStackAction(deleteKey); - } - }; - - const cancelPendingUnsavedLoad = () => { - setPendingUnsavedLoad(null); - setPendingUnsavedNode(null); - }; - - const discardAndLoadPending = () => { - const target = pendingUnsavedLoad; - const targetNode = pendingUnsavedNode; - setContent(originalContent); - setEnvContent(originalEnvContent); - setPendingUnsavedLoad(null); - setPendingUnsavedNode(null); - if (target) { - if (targetNode) loadFileOnNode(targetNode, target); - else loadFile(target); - } - }; - - const requestDeleteStack = () => { - setStackToDelete(selectedFile); - setDeleteDialogOpen(true); - }; - - // Context-menu-friendly stack actions (accept file name directly) - const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => { - if (isStackBusy(stackFile)) return; - const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); - setStackAction(stackFile, action); - - // Optimistic status update - if (action === 'stop') { - setOptimisticStatus(stackFile, 'exited'); - } else if (action === 'deploy' || action === 'restart' || action === 'update') { - setOptimisticStatus(stackFile, 'running'); - } - - try { - const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' }); - if (!response.ok) { - const errText = await response.text(); - throw new Error(errText || `${action} failed`); - } - toast.success(`Stack ${action}ed successfully!`); - if (selectedFile === stackFile) { - const containersRes = await apiFetch(`/stacks/${stackName}/containers`); - const conts = await containersRes.json(); - setContainers(Array.isArray(conts) ? conts : []); - } - if (action === 'update') fetchImageUpdates(); - if (action === 'deploy' && isPaid) { - try { - const backupRes = await apiFetch(`/stacks/${stackName}/backup`); - if (backupRes.ok) setBackupInfo(await backupRes.json()); - } catch { /* ignore */ } - } - } catch (error) { - console.error(`Failed to ${action}:`, error); - const msg = (error as Error).message || `Failed to ${action} stack`; - toast.error(action === 'deploy' && isPaid ? `${msg} - automatically rolled back to previous version.` : msg); - } finally { - clearStackAction(stackFile); - refreshStacks(true); - } - }; - - const checkUpdatesForStack = async () => { - try { - const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); - if (res.ok) { - toast.success('Checking for image updates...'); - // Poll until the background check completes instead of using a fixed timeout - let elapsed = 0; - const poll = setInterval(async () => { - elapsed += 2000; - try { - const statusRes = await apiFetch('/image-updates/status'); - if (statusRes.ok) { - const { checking } = await statusRes.json(); - if (!checking || elapsed >= 60000) { - clearInterval(poll); - await fetchImageUpdates(); - if (!checking) toast.success('Image update check complete.'); - } - } - } catch { - clearInterval(poll); - await fetchImageUpdates(); - } - }, 2000); - } else { - const data = await res.json().catch(() => ({})); - toast.error(data.error || 'Failed to check for updates'); - } - } catch { - toast.error('Failed to check for updates'); - } - }; - - 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); - }; - - // Listen for topology click-to-logs events (ref avoids stale closure) - const openLogViewerRef = useRef(openLogViewer); - openLogViewerRef.current = openLogViewer; - useEffect(() => { - const handler = (e: Event) => { - const { containerId, containerName } = (e as CustomEvent).detail; - openLogViewerRef.current(containerId, containerName); - }; - window.addEventListener(SENCHO_OPEN_LOGS_EVENT, handler); - return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler); - }, []); - - // Stack name is now the same as selectedFile (no extension to strip) - const stackName = selectedFile || ''; - - // Get display name for stack (now just returns the name as-is since no extension) - const getDisplayName = (stackName: string) => { - return stackName; - }; - - const buildMenuCtx = useCallback((file: string): StackMenuCtx => { - const stackName = file.replace(/\.(yml|yaml)$/, ''); - return { - stackStatus: (stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown', - hasPort: Boolean(stackPorts[file]), - isBusy: isStackBusy(file), - isPaid, - isAdmiral, - canDelete: can('stack:delete', 'stack', stackName), - isPinned: isPinned(file), - labels, - assignedLabelIds: (stackLabelMap[file] ?? []).map(l => l.id), - menuVisibility: getStackMenuVisibility(file), - autoUpdateEnabled: autoUpdateSettings[stackName] ?? true, - openAlertSheet: () => openAlertSheet(file), - openAutoHeal: () => setAutoHealStackName(file), - checkUpdates: () => checkUpdatesForStack(), - openStackApp: () => openStackApp(file), - deploy: () => executeStackActionByFile(file, 'deploy', 'deploy'), - stop: () => executeStackActionByFile(file, 'stop', 'stop'), - restart: () => executeStackActionByFile(file, 'restart', 'restart'), - update: () => executeStackActionByFile(file, 'update', 'update'), - remove: () => { setStackToDelete(stackName); setDeleteDialogOpen(true); }, - pin: () => pin(file), - unpin: () => unpin(file), - setAutoUpdateEnabled: async (enabled: boolean) => { - setAutoUpdateSettings(prev => ({ ...prev, [stackName]: enabled })); - try { - const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/auto-update`, { - method: 'PUT', - body: JSON.stringify({ enabled }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error((data as { error?: string })?.error || 'Failed to update auto-update setting.'); - } - } catch (err: unknown) { - setAutoUpdateSettings(prev => ({ ...prev, [stackName]: !enabled })); - toast.error((err as Error)?.message || 'Failed to update auto-update setting.'); - } - }, - toggleLabel: async (labelId: number) => { - const currentIds = (stackLabelMap[file] ?? []).map(l => l.id); - const assigned = currentIds.includes(labelId); - const newIds = assigned ? currentIds.filter(id => id !== labelId) : [...currentIds, labelId]; - const loadingId = toast.loading('Updating labels...'); - try { - const res = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { - method: 'PUT', - body: JSON.stringify({ labelIds: newIds }), - }); - if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error((data as { error?: string })?.error || 'Failed to update labels.'); } - refreshLabels(); - } catch (err: unknown) { - toast.error((err as Error)?.message || 'Failed to update labels.'); - } finally { - toast.dismiss(loadingId); - } - }, - createAndAssignLabel: async (name: string, color: LabelColor) => { - const loadingId = toast.loading('Creating label...'); - try { - const createRes = await apiFetch('/labels', { - method: 'POST', - body: JSON.stringify({ name, color }), - }); - if (!createRes.ok) { - const data = await createRes.json().catch(() => ({})); - throw new Error((data as { error?: string })?.error || 'Failed to create label.'); - } - const created: StackLabel = await createRes.json(); - const currentIds = (stackLabelMap[file] ?? []).map(l => l.id); - const newIds = [...currentIds, created.id]; - const assignRes = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { - method: 'PUT', - body: JSON.stringify({ labelIds: newIds }), - }); - if (!assignRes.ok) { - const data = await assignRes.json().catch(() => ({})); - throw new Error((data as { error?: string })?.error || 'Failed to assign label.'); - } - toast.success(`Label "${created.name}" created.`); - refreshLabels(); - } catch (err: unknown) { - toast.error((err as Error)?.message || 'Failed to create label.'); - throw err; - } finally { - toast.dismiss(loadingId); - } - }, - openLabelManager: () => handleOpenSettings('labels'), - openScheduleTask: () => { - const stackName = file.replace(/\.(yml|yaml)$/, ''); - setSchedulePrefill({ stackName, nodeId: activeNode?.id ?? null }); - setActiveView('scheduled-ops'); - }, - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - stackStatuses, stackPorts, isPaid, isAdmiral, isPinned, labels, stackLabelMap, - autoUpdateSettings, pin, unpin, - ]); + }, [containers, selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps const createStackSlot = can('stack:create') ? ( <> @@ -1096,9 +225,9 @@ export default function EditorLayout() { { + onStackCreated={async (sName) => { await refreshStacks(); - await loadFile(stackName); + await stackActions.loadFile(sName); }} onStacksChanged={async () => { await refreshStacks(); }} /> @@ -1111,7 +240,7 @@ export default function EditorLayout() { {/* Left Sidebar (Stacks) */} { const node = nodes.find(n => n.id === nodeId); - if (node) loadFileOnNode(node, file); + if (node) void stackActions.loadFileOnNode(node, file); }, }} notifications={notifications} @@ -1182,7 +311,7 @@ export default function EditorLayout() { onMarkAllRead={markAllRead} onClearAll={clearAllNotifications} onDelete={deleteNotification} - onNavigate={navigateToNotification} + onNavigate={stackActions.navigateToNotification} /> } userMenu={ @@ -1202,15 +331,18 @@ export default function EditorLayout() { isLoading={isLoading} settingsSection={settingsSection} onSettingsSectionChange={setSettingsSection} - onTemplateDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} + onTemplateDeploySuccess={(sName) => { + refreshStacks(); + void stackActions.loadFile(sName); + }} onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} - onFleetNavigateToNode={(nodeId, stackName) => { + onFleetNavigateToNode={(nodeId, sName) => { const node = nodes.find(n => n.id === nodeId); if (node) { if (activeNode?.id === nodeId) { - loadFile(stackName); + void stackActions.loadFile(sName); } else { - pendingStackLoadRef.current = stackName; + pendingStackLoadRef.current = sName; setActiveNode(node); } } @@ -1220,7 +352,7 @@ export default function EditorLayout() { schedulePrefill={schedulePrefill} onPrefillConsumed={handlePrefillConsumed} notifications={notifications} - onNavigateToStack={(stackFile) => { loadFile(stackFile); }} + onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }} onOpenSettingsSection={(section) => handleOpenSettings(section)} onClearNotifications={clearAllNotifications} renderEditor={() => ( @@ -1250,158 +382,48 @@ export default function EditorLayout() { isPaid={isPaid} trivy={trivy} activeNode={activeNode} - monacoEditorRef={monacoEditorRef} copiedDigestTimerRef={copiedDigestTimerRef} - deployStack={deployStack} - restartStack={restartStack} - stopStack={stopStack} - updateStack={updateStack} - rollbackStack={rollbackStack} - scanStackConfig={scanStackConfig} - enterEditMode={enterEditMode} - requestSave={requestSave} - requestSaveAndDeploy={requestSaveAndDeploy} - discardChanges={discardChanges} + deployStack={stackActions.deployStack} + restartStack={stackActions.restartStack} + stopStack={stackActions.stopStack} + updateStack={stackActions.updateStack} + rollbackStack={stackActions.rollbackStack} + scanStackConfig={stackActions.scanStackConfig} + enterEditMode={stackActions.enterEditMode} + requestSave={stackActions.requestSave} + requestSaveAndDeploy={stackActions.requestSaveAndDeploy} + discardChanges={stackActions.discardChanges} setContent={setContent} setEnvContent={setEnvContent} - changeEnvFile={changeEnvFile} - openLogViewer={openLogViewer} - openBashModal={openBashModal} - serviceAction={serviceAction} + changeEnvFile={stackActions.changeEnvFile} + openLogViewer={stackActions.openLogViewer} + openBashModal={stackActions.openBashModal} + serviceAction={stackActions.serviceAction} setActiveTab={setActiveTab} setLogsMode={setLogsMode} setEditingCompose={setEditingCompose} setGitSourceOpen={setGitSourceOpen} setCopiedDigest={setCopiedDigest} - requestDeleteStack={requestDeleteStack} + requestDeleteStack={stackActions.requestDeleteStack} /> )} /> - - - - - {/* Bash Exec Modal */} - {selectedContainer && ( - - )} - - {/* LogViewer Modal */} - {logContainer && ( - - )} - - - {/* Stack Alert Sheet */} - setAlertSheetOpen(false)} - stackName={alertSheetStack} - /> - - {/* Pre-deploy policy block */} - setPolicyBlock(null)} - onBypass={bypassPolicyAndDeploy} - /> - - {/* Stack Auto-Heal Sheet */} - { if (!open) setAutoHealStackName(null); }} - /> - - {/* Git Source Panel */} - {stackName && ( - - )} - - {/* Stack config misconfig scan results */} - setStackMisconfigScanId(null)} - /> - - {/* Compose diff preview */} - { if (!open && !diffPreviewConfirming) setDiffPreview(null); }} - stackName={selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : ''} - fileName={diffPreview?.fileName ?? ''} - language={diffPreview?.language ?? 'yaml'} - original={diffPreview?.original ?? ''} - modified={diffPreview?.modified ?? ''} - actionLabel={diffPreview?.mode === 'save-and-deploy' ? 'Save & deploy' : 'Save'} - confirming={diffPreviewConfirming} + { - const snapshot = diffPreview; - setDiffPreviewConfirming(true); - try { - if (snapshot?.mode === 'save-and-deploy') { - await saveFile(); - // e.preventDefault/stopPropagation are no-ops here; no browser event is in flight - await deployStack({ preventDefault() {}, stopPropagation() {} } as unknown as React.MouseEvent); - } else { - await saveFile(); - } - } finally { - setDiffPreviewConfirming(false); - setDiffPreview(null); - } - }} + isAdmin={isAdmin} + can={can} + selectedFile={selectedFile} + stackName={stackName} + gitSourceOpen={gitSourceOpen} + setGitSourceOpen={setGitSourceOpen} + securityHistoryOpen={securityHistoryOpen} + setSecurityHistoryOpen={setSecurityHistoryOpen} /> - - {/* Scan history overlay. Conditionally mounted so the lazy chunk - only fetches when the user opens the overlay; an always-mounted - lazy component would fetch on EditorLayout's first render and - defeat the split. The overlay has no internal state that needs - to persist across opens. */} - {securityHistoryOpen ? ( - - - setSecurityHistoryOpen(false)} - /> - - - ) : null} ); diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 9e788533..fc79a613 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -1,4 +1,4 @@ -import { Suspense } from 'react'; +import { Suspense, useRef, useEffect } from 'react'; import { Editor } from '@/lib/monacoLoader'; import { RotateCw, @@ -192,9 +192,6 @@ export interface EditorViewProps { activeNode: Node | null; // Refs - monacoEditorRef: React.MutableRefObject< - import('monaco-editor').editor.IStandaloneCodeEditor | null - >; copiedDigestTimerRef: React.MutableRefObject; // Stack actions @@ -259,7 +256,6 @@ export function EditorView({ isPaid, trivy, activeNode, - monacoEditorRef, copiedDigestTimerRef, deployStack, restartStack, @@ -284,6 +280,23 @@ export function EditorView({ 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 || ''; diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx new file mode 100644 index 00000000..da353281 --- /dev/null +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -0,0 +1,188 @@ +import { lazy, Suspense } from 'react'; +import BashExecModal from '../BashExecModal'; +import LazyBoundary from '../LazyBoundary'; +import { PolicyBlockDialog } from '../stack/PolicyBlockDialog'; +import { DeleteStackDialog } from './DeleteStackDialog'; +import { UnsavedChangesDialog } from './UnsavedChangesDialog'; +import { StackAlertSheet } from '../StackAlertSheet'; +import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; +import { GitSourcePanel } from '../stack/GitSourcePanel'; +import { LogViewer } from '../LogViewer'; +import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet'; +import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog'; +import type { OverlayState } from './hooks/useOverlayState'; +import type { StackActionsHook } from './hooks/useStackActions'; +import type { PermissionAction } from '@/context/AuthContext'; + +// SecurityHistoryView is the only lazy-loaded view that lives outside +// the ViewRouter switch -- it renders as an overlay sheet wired into the +// settings flow, not as a top-level tab. The other tab-level lazy views +// (HostConsole, FleetView, AuditLogView, etc.) live inside ViewRouter. +const SecurityHistoryView = lazy(() => + import('../SecurityHistoryView').then(m => ({ default: m.SecurityHistoryView })), +); + +interface ShellOverlaysProps { + overlayState: OverlayState; + stackActions: StackActionsHook; + isDarkMode: boolean; + isAdmin: boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + selectedFile: string | null; + stackName: string; + gitSourceOpen: boolean; + setGitSourceOpen: (open: boolean) => void; + securityHistoryOpen: boolean; + setSecurityHistoryOpen: (open: boolean) => void; +} + +export function ShellOverlays({ + overlayState, + stackActions, + isDarkMode, + isAdmin, + can, + selectedFile, + stackName, + gitSourceOpen, + setGitSourceOpen, + securityHistoryOpen, + setSecurityHistoryOpen, +}: ShellOverlaysProps) { + const { + deleteDialogOpen, closeDeleteDialog, stackToDelete, + pendingUnsavedLoad, + bashModalOpen, selectedContainer, + logViewerOpen, logContainer, + alertSheetOpen, closeAlertSheet, alertSheetStack, + policyBlock, setPolicyBlock, policyBypassing, + autoHealStackName, setAutoHealStackName, + stackMisconfigScanId, setStackMisconfigScanId, + diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, + } = overlayState; + + return ( + <> + { if (!open) closeDeleteDialog(); }} + stackName={stackToDelete} + onConfirm={stackActions.deleteStack} + /> + + + + {/* Bash Exec Modal */} + {selectedContainer && ( + + )} + + {/* LogViewer Modal */} + {logContainer && ( + + )} + + {/* Stack Alert Sheet */} + + + {/* Pre-deploy policy block */} + setPolicyBlock(null)} + onBypass={stackActions.bypassPolicyAndDeploy} + /> + + {/* Stack Auto-Heal Sheet */} + { if (!open) setAutoHealStackName(null); }} + /> + + {/* Git Source Panel */} + {stackName && ( + + )} + + {/* Stack config misconfig scan results */} + setStackMisconfigScanId(null)} + /> + + {/* Compose diff preview */} + { if (!open && !diffPreviewConfirming) setDiffPreview(null); }} + stackName={selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : ''} + fileName={diffPreview?.fileName ?? ''} + language={diffPreview?.language ?? 'yaml'} + original={diffPreview?.original ?? ''} + modified={diffPreview?.modified ?? ''} + actionLabel={diffPreview?.mode === 'save-and-deploy' ? 'Save & deploy' : 'Save'} + confirming={diffPreviewConfirming} + isDarkMode={isDarkMode} + onConfirm={async () => { + const snapshot = diffPreview; + setDiffPreviewConfirming(true); + try { + if (snapshot?.mode === 'save-and-deploy') { + await stackActions.saveFile(); + await stackActions.deployStack(); + } else { + await stackActions.saveFile(); + } + } finally { + setDiffPreviewConfirming(false); + setDiffPreview(null); + } + }} + /> + + {/* Scan history overlay. Conditionally mounted so the lazy chunk + only fetches when the user opens the overlay; an always-mounted + lazy component would fetch on EditorLayout's first render and + defeat the split. The overlay has no internal state that needs + to persist across opens. */} + {securityHistoryOpen ? ( + + + setSecurityHistoryOpen(false)} + /> + + + ) : null} + + ); +} diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts new file mode 100644 index 00000000..f46cd7e0 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts @@ -0,0 +1,100 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useOverlayState } from './useOverlayState'; + +describe('useOverlayState', () => { + it('initialises with all overlays closed and null/empty data', () => { + const { result } = renderHook(() => useOverlayState()); + expect(result.current.createDialogOpen).toBe(false); + expect(result.current.deleteDialogOpen).toBe(false); + expect(result.current.stackToDelete).toBeNull(); + expect(result.current.pendingUnsavedLoad).toBeNull(); + expect(result.current.pendingUnsavedNode).toBeNull(); + expect(result.current.bashModalOpen).toBe(false); + expect(result.current.selectedContainer).toBeNull(); + expect(result.current.logViewerOpen).toBe(false); + expect(result.current.logContainer).toBeNull(); + expect(result.current.alertSheetOpen).toBe(false); + expect(result.current.alertSheetStack).toBe(''); + expect(result.current.autoHealStackName).toBeNull(); + expect(result.current.policyBlock).toBeNull(); + expect(result.current.policyBypassing).toBe(false); + expect(result.current.stackMisconfigScanId).toBeNull(); + expect(result.current.diffPreview).toBeNull(); + expect(result.current.diffPreviewConfirming).toBe(false); + }); + + it('openBashModal sets open flag and container object', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openBashModal({ id: 'abc', name: 'my-container' })); + expect(result.current.bashModalOpen).toBe(true); + expect(result.current.selectedContainer).toEqual({ id: 'abc', name: 'my-container' }); + }); + + it('closeBashModal resets bash state', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openBashModal({ id: 'abc', name: 'my-container' })); + act(() => result.current.closeBashModal()); + expect(result.current.bashModalOpen).toBe(false); + expect(result.current.selectedContainer).toBeNull(); + }); + + it('openDeleteDialog sets open flag and stack name', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openDeleteDialog('my-stack')); + expect(result.current.deleteDialogOpen).toBe(true); + expect(result.current.stackToDelete).toBe('my-stack'); + }); + + it('closeDeleteDialog resets delete state', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openDeleteDialog('my-stack')); + act(() => result.current.closeDeleteDialog()); + expect(result.current.deleteDialogOpen).toBe(false); + expect(result.current.stackToDelete).toBeNull(); + }); + + it('openLogViewer sets open flag and container object', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openLogViewer({ id: 'xyz', name: 'log-container' })); + expect(result.current.logViewerOpen).toBe(true); + expect(result.current.logContainer).toEqual({ id: 'xyz', name: 'log-container' }); + }); + + it('closeLogViewer resets log viewer state', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openLogViewer({ id: 'xyz', name: 'log-container' })); + act(() => result.current.closeLogViewer()); + expect(result.current.logViewerOpen).toBe(false); + expect(result.current.logContainer).toBeNull(); + }); + + it('openAlertSheet sets sheet state', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openAlertSheet('web-stack')); + expect(result.current.alertSheetOpen).toBe(true); + expect(result.current.alertSheetStack).toBe('web-stack'); + }); + + it('openAlertSheet with autoHeal sets autoHealStackName', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openAlertSheet('web-stack', 'web-stack')); + expect(result.current.alertSheetOpen).toBe(true); + expect(result.current.alertSheetStack).toBe('web-stack'); + expect(result.current.autoHealStackName).toBe('web-stack'); + }); + + it('openAlertSheet without autoHeal leaves autoHealStackName null', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openAlertSheet('web-stack')); + expect(result.current.alertSheetOpen).toBe(true); + expect(result.current.autoHealStackName).toBeNull(); + }); + + it('closeAlertSheet sets alertSheetOpen to false', () => { + const { result } = renderHook(() => useOverlayState()); + act(() => result.current.openAlertSheet('web-stack')); + act(() => result.current.closeAlertSheet()); + expect(result.current.alertSheetOpen).toBe(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts new file mode 100644 index 00000000..ee3578b1 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -0,0 +1,102 @@ +import { useState, useCallback, useEffect } from 'react'; +import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; +import type { SenchoOpenLogsDetail } from '@/lib/events'; +import type { PolicyBlockPayload } from '../../stack/PolicyBlockDialog'; +import type { Node } from '@/context/NodeContext'; + +type DiffPreview = { + mode: 'save' | 'save-and-deploy'; + language: 'yaml' | 'ini'; + original: string; + modified: string; + fileName: string; +}; + +type PolicyBlock = { stackName: string; payload: PolicyBlockPayload }; +type Container = { id: string; name: string }; + +export function useOverlayState() { + const [createDialogOpen, setCreateDialogOpen] = useState(false); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [stackToDelete, setStackToDelete] = useState(null); + const openDeleteDialog = useCallback((stackName: string) => { + setStackToDelete(stackName); + setDeleteDialogOpen(true); + }, []); + const closeDeleteDialog = useCallback(() => { + setDeleteDialogOpen(false); + setStackToDelete(null); + }, []); + + const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); + const [pendingUnsavedNode, setPendingUnsavedNode] = useState(null); + + const [bashModalOpen, setBashModalOpen] = useState(false); + const [selectedContainer, setSelectedContainer] = useState(null); + const openBashModal = useCallback((container: Container) => { + setSelectedContainer(container); + setBashModalOpen(true); + }, []); + const closeBashModal = useCallback(() => { + setBashModalOpen(false); + setSelectedContainer(null); + }, []); + + const [logViewerOpen, setLogViewerOpen] = useState(false); + const [logContainer, setLogContainer] = useState(null); + const openLogViewer = useCallback((container: Container) => { + setLogContainer(container); + setLogViewerOpen(true); + }, []); + const closeLogViewer = useCallback(() => { + setLogViewerOpen(false); + setLogContainer(null); + }, []); + + // Listen for topology click-to-logs events and open the log viewer. + // openLogViewer is stable (useCallback with empty deps), so this effect + // mounts/unmounts once and never re-registers. + useEffect(() => { + const handler = (e: Event) => { + const { containerId, containerName } = (e as CustomEvent).detail; + openLogViewer({ id: containerId, name: containerName }); + }; + window.addEventListener(SENCHO_OPEN_LOGS_EVENT, handler); + return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler); + }, [openLogViewer]); // openLogViewer is stable (useCallback with empty deps) + + const [alertSheetOpen, setAlertSheetOpen] = useState(false); + const [alertSheetStack, setAlertSheetStack] = useState(''); + const [autoHealStackName, setAutoHealStackName] = useState(null); + const openAlertSheet = useCallback((stackName: string, autoHeal?: string | null) => { + setAlertSheetStack(stackName); + setAutoHealStackName(autoHeal ?? null); + setAlertSheetOpen(true); + }, []); + const closeAlertSheet = useCallback(() => setAlertSheetOpen(false), []); + + const [policyBlock, setPolicyBlock] = useState(null); + const [policyBypassing, setPolicyBypassing] = useState(false); + + const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); + + const [diffPreview, setDiffPreview] = useState(null); + const [diffPreviewConfirming, setDiffPreviewConfirming] = useState(false); + + return { + createDialogOpen, setCreateDialogOpen, + deleteDialogOpen, stackToDelete, openDeleteDialog, closeDeleteDialog, + pendingUnsavedLoad, setPendingUnsavedLoad, + pendingUnsavedNode, setPendingUnsavedNode, + bashModalOpen, selectedContainer, openBashModal, closeBashModal, + logViewerOpen, logContainer, openLogViewer, closeLogViewer, + alertSheetOpen, alertSheetStack, autoHealStackName, openAlertSheet, closeAlertSheet, + setAutoHealStackName, + policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing, + stackMisconfigScanId, setStackMisconfigScanId, + diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, + } as const; +} + +export type OverlayState = ReturnType; diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts new file mode 100644 index 00000000..6d9b26ca --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -0,0 +1,144 @@ +import { useCallback } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; +import type { Label as StackLabel, LabelColor } from '../../label-types'; +import type { OverlayState } from './useOverlayState'; +import type { StackActionsHook } from './useStackActions'; +import type { useStackListState } from './useStackListState'; +import type { useViewNavigationState } from './useViewNavigationState'; +import type { Node } from '@/context/NodeContext'; +import type { PermissionAction } from '@/context/AuthContext'; + +type StackListState = ReturnType; +type NavState = ReturnType; + +interface UseSidebarContextMenuOptions { + stackListState: StackListState; + navState: NavState; + overlayState: OverlayState; + stackActions: StackActionsHook; + activeNode: Node | null | undefined; + isPaid: boolean; + isAdmiral: boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; +} + +export function useSidebarContextMenu({ + stackListState, + navState, + overlayState, + stackActions, + activeNode, + isPaid, + isAdmiral, + can, +}: UseSidebarContextMenuOptions) { + const buildMenuCtx = useCallback((file: string): StackMenuCtx => { + const sName = file.replace(/\.(yml|yaml)$/, ''); + return { + stackStatus: (stackListState.stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown', + hasPort: Boolean(stackListState.stackPorts[file]), + isBusy: stackListState.isStackBusy(file), + isPaid, + isAdmiral, + canDelete: can('stack:delete', 'stack', sName), + isPinned: stackListState.isPinned(file), + labels: stackListState.labels, + assignedLabelIds: (stackListState.stackLabelMap[file] ?? []).map(l => l.id), + menuVisibility: stackActions.getStackMenuVisibility(file), + autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true, + openAlertSheet: () => overlayState.openAlertSheet(file), + openAutoHeal: () => overlayState.setAutoHealStackName(file), + checkUpdates: () => stackActions.checkUpdatesForStack(), + openStackApp: () => stackActions.openStackApp(file), + deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'), + stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'), + restart: () => stackActions.executeStackActionByFile(file, 'restart', 'restart'), + update: () => stackActions.executeStackActionByFile(file, 'update', 'update'), + remove: () => overlayState.openDeleteDialog(sName), + pin: () => stackListState.pin(file), + unpin: () => stackListState.unpin(file), + setAutoUpdateEnabled: async (enabled: boolean) => { + stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: enabled })); + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(sName)}/auto-update`, { + method: 'PUT', + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error((data as { error?: string })?.error || 'Failed to update auto-update setting.'); + } + } catch (err: unknown) { + stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: !enabled })); + toast.error((err as Error)?.message || 'Failed to update auto-update setting.'); + } + }, + toggleLabel: async (labelId: number) => { + const currentIds = (stackListState.stackLabelMap[file] ?? []).map(l => l.id); + const assigned = currentIds.includes(labelId); + const newIds = assigned ? currentIds.filter(id => id !== labelId) : [...currentIds, labelId]; + const loadingId = toast.loading('Updating labels...'); + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { + method: 'PUT', + body: JSON.stringify({ labelIds: newIds }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error((data as { error?: string })?.error || 'Failed to update labels.'); + } + stackListState.refreshLabels(); + } catch (err: unknown) { + toast.error((err as Error)?.message || 'Failed to update labels.'); + } finally { + toast.dismiss(loadingId); + } + }, + createAndAssignLabel: async (name: string, color: LabelColor) => { + const loadingId = toast.loading('Creating label...'); + try { + const createRes = await apiFetch('/labels', { + method: 'POST', + body: JSON.stringify({ name, color }), + }); + if (!createRes.ok) { + const data = await createRes.json().catch(() => ({})); + throw new Error((data as { error?: string })?.error || 'Failed to create label.'); + } + const created: StackLabel = await createRes.json(); + const currentIds = (stackListState.stackLabelMap[file] ?? []).map(l => l.id); + const newIds = [...currentIds, created.id]; + const assignRes = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { + method: 'PUT', + body: JSON.stringify({ labelIds: newIds }), + }); + if (!assignRes.ok) { + const data = await assignRes.json().catch(() => ({})); + throw new Error((data as { error?: string })?.error || 'Failed to assign label.'); + } + toast.success(`Label "${created.name}" created.`); + stackListState.refreshLabels(); + } catch (err: unknown) { + toast.error((err as Error)?.message || 'Failed to create label.'); + throw err; + } finally { + toast.dismiss(loadingId); + } + }, + openLabelManager: () => navState.handleOpenSettings('labels'), + openScheduleTask: () => { + navState.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null }); + navState.setActiveView('scheduled-ops'); + }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral, + stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, + stackListState.autoUpdateSettings, stackListState.pin, stackListState.unpin, + ]); + + return buildMenuCtx; +} diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts new file mode 100644 index 00000000..ca250198 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -0,0 +1,820 @@ +import { useRef, useCallback, useEffect } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import type { useEditorViewState } from './useEditorViewState'; +import type { useStackListState } from './useStackListState'; +import type { useViewNavigationState } from './useViewNavigationState'; +import type { OverlayState } from './useOverlayState'; +import type { Node } from '@/context/NodeContext'; +import type { ActionVerb } from '@/context/DeployFeedbackContext'; +import type { StackAction } from '../EditorView'; +import type { NotificationItem } from '../../dashboard/types'; +import type { PolicyBlockPayload } from '../../stack/PolicyBlockDialog'; + +interface RunResult { + ok: boolean; + errorMessage?: string; +} + +type EditorState = ReturnType; +type StackListState = ReturnType; +type NavState = ReturnType; + +interface UseStackActionsOptions { + editorState: EditorState; + stackListState: StackListState; + navState: NavState; + overlayState: OverlayState; + activeNode: Node | null | undefined; + setActiveNode: (node: Node) => void; + nodes: Node[]; + isPaid: boolean; + runWithLog: ( + params: { stackName: string; action: ActionVerb }, + run: (deployStarted: Promise) => Promise, + ) => Promise; + diffPreviewEnabled: boolean; +} + +export function useStackActions(options: UseStackActionsOptions) { + const { + editorState, + stackListState, + navState, + overlayState, + activeNode, + setActiveNode, + nodes, + isPaid, + runWithLog, + diffPreviewEnabled, + } = options; + + const pendingStackLoadRef = useRef(null); + const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); + const checkUpdatesIntervalRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (checkUpdatesIntervalRef.current !== null) { + clearInterval(checkUpdatesIntervalRef.current); + } + }; + }, []); + + const hasUnsavedChanges = () => + editorState.content !== editorState.originalContent || + editorState.envContent !== editorState.originalEnvContent; + + const getStackMenuVisibility = (file: string) => { + const status = stackListState.stackStatuses[file]; + return { + showDeploy: status !== 'running', + showStop: status === 'running', + showRestart: status === 'running', + showUpdate: status === 'running', + }; + }; + + const openStackApp = (file: string) => { + const port = stackListState.stackPorts[file]; + if (!port) return; + const host = + activeNode?.type === 'remote' && activeNode?.api_url + ? new URL(activeNode.api_url).hostname + : window.location.hostname; + window.open(`http://${host}:${port}`, '_blank'); + }; + + const resetEditorState = () => { + stackListState.setSelectedFile(null); + editorState.setContent(''); + editorState.setOriginalContent(''); + editorState.setEnvContent(''); + editorState.setOriginalEnvContent(''); + editorState.setEnvFiles([]); + editorState.setSelectedEnvFile(''); + editorState.setEnvExists(false); + editorState.setContainers([]); + editorState.setIsEditing(false); + }; + + const refreshGitSourcePending = async () => { + try { + const res = await apiFetch('/git-sources'); + if (!res.ok) return; + const sources: Array<{ stack_name: string; pending_commit_sha: string | null }> = + await res.json(); + const map: Record = {}; + for (const s of sources) { + if (s.pending_commit_sha) map[s.stack_name] = true; + } + editorState.setGitSourcePendingMap(map); + } catch { + // Non-critical; leave prior state. + } + }; + + // loadFile and loadFileOnNode call each other (loadFileOnNode -> loadFile, navigateToNotification + // -> loadFileOnNode or loadFile). A ref breaks the mutual-recursion hoisting constraint without + // needing to hoist both functions or restructure the call graph. + const loadFileRef = useRef<(filename: string) => Promise>(async () => {}); + + const loadFileOnNode = async (node: Node, filename: string) => { + if (!filename) return; + if ( + stackListState.selectedFile && + filename !== stackListState.selectedFile && + hasUnsavedChanges() + ) { + overlayState.setPendingUnsavedNode(node); + overlayState.setPendingUnsavedLoad(filename); + return; + } + setActiveNode(node); + stackListState.setSearchQuery(''); + await loadFileRef.current(filename); + }; + + const clearEnvState = () => { + editorState.setEnvFiles([]); + editorState.setSelectedEnvFile(''); + editorState.setEnvContent(''); + editorState.setOriginalEnvContent(''); + editorState.setEnvExists(false); + }; + + const loadEnvState = async (filename: string) => { + try { + const envsRes = await apiFetch(`/stacks/${filename}/envs`); + if (!envsRes.ok) { + clearEnvState(); + return; + } + const { envFiles } = await envsRes.json(); + if (envFiles && envFiles.length > 0) { + editorState.setEnvFiles(envFiles); + const firstFile = envFiles[0]; + editorState.setSelectedEnvFile(firstFile); + editorState.setEnvExists(true); + const envContentRes = await apiFetch( + `/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`, + ); + if (envContentRes.ok) { + const envText = await envContentRes.text(); + editorState.setEnvContent(envText || ''); + editorState.setOriginalEnvContent(envText || ''); + } else { + editorState.setEnvContent(''); + editorState.setOriginalEnvContent(''); + } + } else { + clearEnvState(); + } + } catch { + clearEnvState(); + } + }; + + const loadContainerState = async (filename: string) => { + try { + const containersRes = await apiFetch(`/stacks/${filename}/containers`); + const conts = await containersRes.json(); + editorState.setContainers(Array.isArray(conts) ? conts : []); + } catch (error) { + console.error('Failed to load containers:', error); + editorState.setContainers([]); + } + }; + + const loadBackupState = async (filename: string) => { + if (!isPaid) return; + try { + const backupRes = await apiFetch(`/stacks/${filename}/backup`); + if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); + else editorState.setBackupInfo({ exists: false, timestamp: null }); + } catch { + editorState.setBackupInfo({ exists: false, timestamp: null }); + } + }; + + const loadFile = async (filename: string) => { + if (!filename) return; + if ( + stackListState.selectedFile && + filename !== stackListState.selectedFile && + hasUnsavedChanges() + ) { + overlayState.setPendingUnsavedLoad(filename); + return; + } + editorState.setIsFileLoading(true); + editorState.setIsEditing(false); + editorState.setEditingCompose(false); + editorState.setActiveTab('compose'); + try { + const res = await apiFetch(`/stacks/${filename}`); + const text = await res.text(); + stackListState.setSelectedFile(filename); + navState.setActiveView('editor'); + editorState.setContent(text || ''); + editorState.setOriginalContent(text || ''); + await loadEnvState(filename); + await loadContainerState(filename); + await loadBackupState(filename); + } catch (error) { + console.error('Failed to load file:', error); + stackListState.setSelectedFile(null); + editorState.setContent(''); + editorState.setOriginalContent(''); + editorState.setEnvContent(''); + editorState.setOriginalEnvContent(''); + editorState.setContainers([]); + } finally { + editorState.setIsFileLoading(false); + } + }; + + // Keep ref in sync so loadFileOnNode always calls the latest loadFile closure + loadFileRef.current = loadFile; + + const navigateToNotification = (notif: NotificationItem) => { + if (!notif.stack_name) return; + pendingLogsRef.current = notif.container_name + ? { stackName: notif.stack_name, containerName: notif.container_name } + : null; + const targetNode = + notif.nodeId !== undefined ? nodes.find(n => n.id === notif.nodeId) : activeNode; + if (targetNode && targetNode.id !== activeNode?.id) { + void loadFileOnNode(targetNode, notif.stack_name); + } else { + void loadFile(notif.stack_name); + } + }; + + const changeEnvFile = async (file: string) => { + editorState.setSelectedEnvFile(file); + editorState.setIsFileLoading(true); + try { + const res = await apiFetch( + `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(file)}`, + ); + if (!res.ok) { + editorState.setEnvContent(''); + editorState.setOriginalEnvContent(''); + toast.error('Could not load env file'); + return; + } + const text = await res.text(); + editorState.setEnvContent(text || ''); + editorState.setOriginalEnvContent(text || ''); + } catch (e) { + console.error('Failed to switch env file', e); + editorState.setEnvContent(''); + editorState.setOriginalEnvContent(''); + toast.error('Failed to load env file'); + } finally { + editorState.setIsFileLoading(false); + } + }; + + const saveFile = async () => { + if (editorState.activeTab === 'files') return; + if (!stackListState.selectedFile) return; + const currentContent = + editorState.activeTab === 'compose' + ? editorState.content || '' + : editorState.envContent || ''; + const endpoint = + editorState.activeTab === 'compose' + ? `/stacks/${stackListState.selectedFile}` + : `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(editorState.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()}`); + } + if (editorState.activeTab === 'compose') { + editorState.setOriginalContent(editorState.content); + } else { + editorState.setOriginalEnvContent(editorState.envContent); + } + editorState.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 requestSave = () => { + const isCompose = editorState.activeTab === 'compose'; + const orig = isCompose ? editorState.originalContent : editorState.originalEnvContent; + const curr = isCompose ? editorState.content : editorState.envContent; + if (diffPreviewEnabled && editorState.activeTab !== 'files' && curr !== orig) { + overlayState.setDiffPreview({ + mode: 'save', + language: isCompose ? 'yaml' : 'ini', + original: orig, + modified: curr, + fileName: isCompose ? 'compose.yaml' : editorState.selectedEnvFile || '.env', + }); + } else { + void saveFile(); + } + }; + + const requestSaveAndDeploy = (e: React.MouseEvent) => { + const isCompose = editorState.activeTab === 'compose'; + const orig = isCompose ? editorState.originalContent : editorState.originalEnvContent; + const curr = isCompose ? editorState.content : editorState.envContent; + if (diffPreviewEnabled && editorState.activeTab !== 'files' && curr !== orig) { + overlayState.setDiffPreview({ + mode: 'save-and-deploy', + language: isCompose ? 'yaml' : 'ini', + original: orig, + modified: curr, + fileName: isCompose ? 'compose.yaml' : editorState.selectedEnvFile || '.env', + }); + } else { + void handleSaveAndDeploy(e); + } + }; + + const runDeploy = async ( + stackName: string, + stackFile: string, + ignorePolicy: boolean, + started?: Promise, + ): Promise<{ ok: boolean; errorMessage?: string }> => { + const previousStatus = stackListState.stackStatuses[stackFile]; + stackListState.setOptimisticStatus(stackFile, 'running'); + try { + const path = ignorePolicy + ? `/stacks/${stackName}/deploy?ignorePolicy=true` + : `/stacks/${stackName}/deploy`; + if (started) await started; + const response = await apiFetch(path, { method: 'POST' }); + if (!response.ok) { + const rawBody = await response.text(); + if (response.status === 409) { + let parsed: PolicyBlockPayload | null = null; + try { + parsed = JSON.parse(rawBody) as PolicyBlockPayload; + } catch { + /* not JSON */ + } + if (parsed && parsed.policy && Array.isArray(parsed.violations)) { + overlayState.setPolicyBlock({ stackName, payload: parsed }); + if (previousStatus !== undefined) + stackListState.setOptimisticStatus( + stackFile, + previousStatus as 'running' | 'exited', + ); + toast.error(`Deploy blocked by policy "${parsed.policy.name}"`); + return { + ok: false, + errorMessage: `Deploy blocked by policy "${parsed.policy.name}"`, + }; + } + } + throw new Error(rawBody || 'Deploy failed'); + } + overlayState.setPolicyBlock(null); + toast.success( + ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!', + ); + if (stackListState.selectedFile === stackFile) { + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); + const conts = await containersRes.json(); + editorState.setContainers(Array.isArray(conts) ? conts : []); + } + if (isPaid) { + try { + const backupRes = await apiFetch(`/stacks/${stackName}/backup`); + if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); + } catch { + /* ignore */ + } + } + return { ok: true }; + } catch (error) { + console.error('Failed to deploy:', error); + if (previousStatus !== undefined) + stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); + const errorMessage = (error as Error).message || 'Failed to deploy stack'; + toast.error( + isPaid + ? `${errorMessage} - automatically rolled back to previous version.` + : errorMessage, + ); + return { ok: false, errorMessage }; + } + }; + + const deployStack = async (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile)) + return; + const stackFile = stackListState.selectedFile; + const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); + stackListState.setStackAction(stackFile, 'deploy'); + try { + await runWithLog({ stackName, action: 'deploy' }, started => + runDeploy(stackName, stackFile, false, started), + ); + } finally { + stackListState.clearStackAction(stackFile); + stackListState.refreshStacks(true); + } + }; + + const handleSaveAndDeploy = async (e: React.MouseEvent) => { + await saveFile(); + await deployStack(e); + }; + + const bypassPolicyAndDeploy = async () => { + const policyBlock = overlayState.policyBlock; + if (!policyBlock) return; + const { stackName } = policyBlock; + const existingFile = + stackListState.selectedFile?.replace(/\.(yml|yaml)$/, '') === stackName + ? stackListState.selectedFile + : (stackListState.files.find(f => f.replace(/\.(yml|yaml)$/, '') === stackName) ?? `${stackName}.yml`); + overlayState.setPolicyBypassing(true); + stackListState.setStackAction(existingFile, 'deploy'); + try { + await runWithLog({ stackName, action: 'deploy' }, started => + runDeploy(stackName, existingFile, true, started), + ); + } finally { + overlayState.setPolicyBypassing(false); + stackListState.clearStackAction(existingFile); + stackListState.refreshStacks(true); + } + }; + + const rollbackStack = async () => { + if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile)) + return; + const stackFile = stackListState.selectedFile; + stackListState.setStackAction(stackFile, 'rollback'); + stackListState.setOptimisticStatus(stackFile, 'running'); + try { + const res = await apiFetch(`/stacks/${stackFile}/rollback`, { method: 'POST' }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err?.error || 'Rollback failed'); + } + toast.success('Stack rolled back successfully.'); + const contentRes = await apiFetch(`/stacks/${stackFile}`); + const text = await contentRes.text(); + editorState.setContent(text || ''); + editorState.setOriginalContent(text || ''); + const backupRes = await apiFetch(`/stacks/${stackFile}/backup`); + if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Rollback failed'; + toast.error(msg); + } finally { + stackListState.clearStackAction(stackFile); + stackListState.refreshStacks(true); + } + }; + + const discardChanges = () => { + if (editorState.activeTab === 'files') return; + if (editorState.activeTab === 'compose') { + editorState.setContent(editorState.originalContent); + } else { + editorState.setEnvContent(editorState.originalEnvContent); + } + editorState.setIsEditing(false); + }; + + const enterEditMode = () => { + editorState.setIsEditing(true); + }; + + const scanStackConfig = async () => { + if (!stackListState.selectedFile || editorState.stackMisconfigScanning) return; + const stackName = stackListState.selectedFile.replace(/\.(yml|yaml)$/, ''); + editorState.setStackMisconfigScanning(true); + const loadingId = toast.loading(`Scanning ${stackName} configuration...`); + try { + const res = await apiFetch('/security/scan/stack', { + method: 'POST', + body: JSON.stringify({ stackName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || 'Failed to start scan'); + if (data.status === 'failed') { + throw new Error(data.error || 'Scan failed'); + } + toast.success( + `Config scan complete: ${data.misconfig_count ?? 0} misconfigurations found`, + ); + overlayState.setStackMisconfigScanId(data.id as number); + } catch (error) { + const msg = error instanceof Error + ? error.message + : ((error as { error?: string })?.error ?? 'Config scan failed'); + toast.error(msg); + } finally { + toast.dismiss(loadingId); + editorState.setStackMisconfigScanning(false); + } + }; + + const runStackAction = async ( + stackFile: string, + action: 'stop' | 'restart' | 'update', + endpoint: string, + optimisticStatus: 'running' | 'exited', + successMessage: string, + ): Promise => { + if (stackListState.isStackBusy(stackFile)) return; + const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); + const previousStatus = stackListState.stackStatuses[stackFile]; + stackListState.setStackAction(stackFile, action); + stackListState.setOptimisticStatus(stackFile, optimisticStatus); + try { + await runWithLog({ stackName, action }, async (started) => { + await started; + try { + const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' }); + if (!response.ok) { + const errText = await response.text(); + return { ok: false as const, errorMessage: errText || `${action} failed` }; + } + toast.success(successMessage); + if (action === 'update') stackListState.fetchImageUpdates(); + if (stackListState.selectedFile === stackFile) { + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); + const conts = await containersRes.json(); + editorState.setContainers(Array.isArray(conts) ? conts : []); + } + return { ok: true as const }; + } catch (err) { + return { ok: false as const, errorMessage: (err as Error).message || `${action} failed` }; + } + }); + } catch (error) { + console.error(`Failed to ${action}:`, error); + if (previousStatus !== undefined) + stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); + toast.error((error as Error).message || `Failed to ${action} stack`); + } finally { + stackListState.clearStackAction(stackFile); + stackListState.refreshStacks(true); + } + }; + + const stopStack = async (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!stackListState.selectedFile) return; + await runStackAction(stackListState.selectedFile, 'stop', 'stop', 'exited', 'Stack stopped successfully!'); + }; + + const restartStack = async (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!stackListState.selectedFile) return; + await runStackAction(stackListState.selectedFile, 'restart', 'restart', 'running', 'Stack restarted successfully!'); + }; + + const serviceAction = async ( + action: 'start' | 'stop' | 'restart', + serviceName: string, + ) => { + if (!stackListState.selectedFile) return; + const stackName = stackListState.selectedFile.replace(/\.(yml|yaml)$/, ''); + try { + const r = await apiFetch( + `/stacks/${stackName}/services/${encodeURIComponent(serviceName)}/${action}`, + { method: 'POST' }, + ); + if (!r.ok) throw new Error((await r.text()) || `${action} failed`); + const label = + action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started'; + toast.success(`Service "${serviceName}" ${label}`); + const cr = await apiFetch(`/stacks/${stackName}/containers`); + const conts = await cr.json(); + editorState.setContainers(Array.isArray(conts) ? conts : []); + } catch (e) { + console.error(`Failed to ${action} service "${serviceName}":`, e); + toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`); + } finally { + stackListState.refreshStacks(true); + } + }; + + const updateStack = async (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!stackListState.selectedFile) return; + await runStackAction(stackListState.selectedFile, 'update', 'update', 'running', 'Stack updated successfully!'); + }; + + const deleteStack = async (pruneVolumes: boolean) => { + const stackToDelete = overlayState.stackToDelete; + if (!stackToDelete) return; + const deleteKey = + stackListState.files.find( + f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete, + ) ?? stackToDelete; + if (stackListState.isStackBusy(deleteKey)) return; + stackListState.setStackAction(deleteKey, 'delete'); + try { + const url = pruneVolumes + ? `/stacks/${stackToDelete}?pruneVolumes=true` + : `/stacks/${stackToDelete}`; + const response = await apiFetch(url, { method: 'DELETE' }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || 'Failed to delete stack'); + } + toast.success('Stack deleted successfully!'); + overlayState.closeDeleteDialog(); + if (stackListState.selectedFile === stackToDelete) { + resetEditorState(); + } + await stackListState.refreshStacks(); + } catch (error) { + console.error('Failed to delete stack:', error); + toast.error((error as Error).message || 'Failed to delete stack'); + } finally { + stackListState.clearStackAction(deleteKey); + } + }; + + const cancelPendingUnsavedLoad = () => { + overlayState.setPendingUnsavedLoad(null); + overlayState.setPendingUnsavedNode(null); + }; + + const discardAndLoadPending = () => { + const target = overlayState.pendingUnsavedLoad; + const targetNode = overlayState.pendingUnsavedNode; + editorState.setContent(editorState.originalContent); + editorState.setEnvContent(editorState.originalEnvContent); + overlayState.setPendingUnsavedLoad(null); + overlayState.setPendingUnsavedNode(null); + if (target) { + if (targetNode) void loadFileOnNode(targetNode, target); + else void loadFile(target); + } + }; + + const requestDeleteStack = () => { + overlayState.openDeleteDialog(stackListState.selectedFile ?? ''); + }; + + const executeStackActionByFile = async ( + stackFile: string, + action: StackAction, + endpoint: string, + ) => { + if (stackListState.isStackBusy(stackFile)) return; + const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); + stackListState.setStackAction(stackFile, action); + + if (action === 'stop') { + stackListState.setOptimisticStatus(stackFile, 'exited'); + } else if (action === 'deploy' || action === 'restart' || action === 'update') { + stackListState.setOptimisticStatus(stackFile, 'running'); + } + + try { + const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || `${action} failed`); + } + toast.success(`Stack ${action}ed successfully!`); + if (stackListState.selectedFile === stackFile) { + const containersRes = await apiFetch(`/stacks/${stackName}/containers`); + const conts = await containersRes.json(); + editorState.setContainers(Array.isArray(conts) ? conts : []); + } + if (action === 'update') stackListState.fetchImageUpdates(); + if (action === 'deploy' && isPaid) { + try { + const backupRes = await apiFetch(`/stacks/${stackName}/backup`); + if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); + } catch { + /* ignore */ + } + } + } catch (error) { + console.error(`Failed to ${action}:`, error); + const msg = (error as Error).message || `Failed to ${action} stack`; + toast.error( + action === 'deploy' && isPaid + ? `${msg} - automatically rolled back to previous version.` + : msg, + ); + } finally { + stackListState.clearStackAction(stackFile); + stackListState.refreshStacks(true); + } + }; + + const checkUpdatesForStack = async () => { + try { + const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); + if (res.ok) { + toast.success('Checking for image updates...'); + let elapsed = 0; + const poll = setInterval(async () => { + elapsed += 2000; + try { + const statusRes = await apiFetch('/image-updates/status'); + if (statusRes.ok) { + const { checking } = await statusRes.json(); + if (!checking || elapsed >= 60000) { + clearInterval(poll); + checkUpdatesIntervalRef.current = null; + await stackListState.fetchImageUpdates(); + if (!checking) toast.success('Image update check complete.'); + } + } + } catch { + clearInterval(poll); + checkUpdatesIntervalRef.current = null; + await stackListState.fetchImageUpdates(); + } + }, 2000); + checkUpdatesIntervalRef.current = poll; + } else { + const data = await res.json().catch(() => ({})); + toast.error(data.error || 'Failed to check for updates'); + } + } catch { + toast.error('Failed to check for updates'); + } + }; + + const getDisplayName = (stackName: string) => stackName; + + // Adapter wrappers: convert (id, name) signature to overlayState object style + const openBashModal = useCallback( + (containerId: string, containerName: string) => + overlayState.openBashModal({ id: containerId, name: containerName }), + [overlayState.openBashModal], + ); + const closeBashModal = overlayState.closeBashModal; + const openLogViewer = useCallback( + (containerId: string, containerName: string) => + overlayState.openLogViewer({ id: containerId, name: containerName }), + [overlayState.openLogViewer], + ); + const closeLogViewer = overlayState.closeLogViewer; + + return { + pendingStackLoadRef, + pendingLogsRef, + getStackMenuVisibility, + openStackApp, + resetEditorState, + refreshGitSourcePending, + loadFile, + loadFileOnNode, + navigateToNotification, + changeEnvFile, + saveFile, + requestSave, + requestSaveAndDeploy, + handleSaveAndDeploy, + rollbackStack, + discardChanges, + enterEditMode, + scanStackConfig, + runDeploy, + deployStack, + bypassPolicyAndDeploy, + stopStack, + restartStack, + serviceAction, + updateStack, + deleteStack, + cancelPendingUnsavedLoad, + discardAndLoadPending, + requestDeleteStack, + executeStackActionByFile, + checkUpdatesForStack, + getDisplayName, + openBashModal, + closeBashModal, + openLogViewer, + closeLogViewer, + }; +} + +export type StackActionsHook = ReturnType;