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.
This commit is contained in:
Anso
2026-05-04 07:43:02 -04:00
committed by GitHub
parent 34fcb2591f
commit 1cf996142b
7 changed files with 1491 additions and 1102 deletions
File diff suppressed because it is too large Load Diff
@@ -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<number | null>;
// 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<import('monaco-editor').editor.IStandaloneCodeEditor | null>(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 || '';
@@ -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 (
<>
<DeleteStackDialog
open={deleteDialogOpen}
onOpenChange={(open) => { if (!open) closeDeleteDialog(); }}
stackName={stackToDelete}
onConfirm={stackActions.deleteStack}
/>
<UnsavedChangesDialog
open={!!pendingUnsavedLoad}
onCancel={stackActions.cancelPendingUnsavedLoad}
onConfirm={stackActions.discardAndLoadPending}
/>
{/* Bash Exec Modal */}
{selectedContainer && (
<BashExecModal
isOpen={bashModalOpen}
onClose={stackActions.closeBashModal}
containerId={selectedContainer.id}
containerName={selectedContainer.name}
/>
)}
{/* LogViewer Modal */}
{logContainer && (
<LogViewer
isOpen={logViewerOpen}
onClose={stackActions.closeLogViewer}
containerId={logContainer.id}
containerName={logContainer.name}
/>
)}
{/* Stack Alert Sheet */}
<StackAlertSheet
isOpen={alertSheetOpen}
onClose={closeAlertSheet}
stackName={alertSheetStack}
/>
{/* Pre-deploy policy block */}
<PolicyBlockDialog
open={policyBlock !== null}
payload={policyBlock?.payload ?? null}
stackName={policyBlock?.stackName ?? ''}
canBypass={isAdmin}
bypassing={policyBypassing}
onClose={() => setPolicyBlock(null)}
onBypass={stackActions.bypassPolicyAndDeploy}
/>
{/* Stack Auto-Heal Sheet */}
<StackAutoHealSheet
stackName={autoHealStackName ?? ''}
open={autoHealStackName !== null}
onOpenChange={(open) => { if (!open) setAutoHealStackName(null); }}
/>
{/* Git Source Panel */}
{stackName && (
<GitSourcePanel
open={gitSourceOpen}
onOpenChange={setGitSourceOpen}
stackName={stackName}
canEdit={can('stack:edit', 'stack', stackName)}
isDarkMode={isDarkMode}
onSourceChanged={stackActions.refreshGitSourcePending}
/>
)}
{/* Stack config misconfig scan results */}
<VulnerabilityScanSheet
scanId={stackMisconfigScanId}
onClose={() => setStackMisconfigScanId(null)}
/>
{/* Compose diff preview */}
<ComposeDiffPreviewDialog
open={diffPreview !== null}
onOpenChange={(open) => { 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 ? (
<LazyBoundary>
<Suspense fallback={null}>
<SecurityHistoryView
open
onClose={() => setSecurityHistoryOpen(false)}
/>
</Suspense>
</LazyBoundary>
) : null}
</>
);
}
@@ -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);
});
});
@@ -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<string | null>(null);
const openDeleteDialog = useCallback((stackName: string) => {
setStackToDelete(stackName);
setDeleteDialogOpen(true);
}, []);
const closeDeleteDialog = useCallback(() => {
setDeleteDialogOpen(false);
setStackToDelete(null);
}, []);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
const [bashModalOpen, setBashModalOpen] = useState(false);
const [selectedContainer, setSelectedContainer] = useState<Container | null>(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<Container | null>(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<SenchoOpenLogsDetail>).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<string | null>(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<PolicyBlock | null>(null);
const [policyBypassing, setPolicyBypassing] = useState(false);
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
const [diffPreview, setDiffPreview] = useState<DiffPreview | null>(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<typeof useOverlayState>;
@@ -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<typeof useStackListState>;
type NavState = ReturnType<typeof useViewNavigationState>;
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;
}
@@ -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<typeof useEditorViewState>;
type StackListState = ReturnType<typeof useStackListState>;
type NavState = ReturnType<typeof useViewNavigationState>;
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<void>) => Promise<RunResult>,
) => Promise<RunResult>;
diffPreviewEnabled: boolean;
}
export function useStackActions(options: UseStackActionsOptions) {
const {
editorState,
stackListState,
navState,
overlayState,
activeNode,
setActiveNode,
nodes,
isPaid,
runWithLog,
diffPreviewEnabled,
} = options;
const pendingStackLoadRef = useRef<string | null>(null);
const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null);
const checkUpdatesIntervalRef = useRef<ReturnType<typeof setInterval> | 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<string, boolean> = {};
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<void>>(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<void>,
): 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<void> => {
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<typeof useStackActions>;