Files
sencho/frontend/src/components/EditorLayout/ShellOverlays.tsx
T
Anso 0f9925e04f feat: block self-stack lifecycle ops with UI and preflight guardrails (#1569)
* feat: block self-stack lifecycle ops with UI and preflight guardrails

Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project.

Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions.

Add SelfStackProtectedDialog and self-managed-stack preflight warning.

Closes #1564

* fix: add missing stackSelfFlags mock to useSidebarContextMenu test

The production hook now reads stackListState.stackSelfFlags[file], but the
test mock did not include it, causing 6 tests to fail with TypeError:
Cannot read properties of undefined (reading 'web.yml').

* fix: harden self-stack protection during startup

Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR.

Align status decoration and route guards on Docker label fallback detection.

Block rollback and service-level stop on the protected self stack.

* fix: add self_stack_location to diagnostics-route expected check IDs
2026-07-06 02:08:16 -04:00

188 lines
6.3 KiB
TypeScript

import BashExecModal from '../BashExecModal';
import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
import { DeleteStackDialog } from './DeleteStackDialog';
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
import { StackAlertSheet } from '../StackAlertSheet';
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';
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;
canSelfUpdate: boolean;
onOpenFleetNodeUpdates: () => void;
}
export function ShellOverlays({
overlayState,
stackActions,
isDarkMode,
isAdmin,
can,
selectedFile,
stackName,
gitSourceOpen,
setGitSourceOpen,
canSelfUpdate,
onOpenFleetNodeUpdates,
}: ShellOverlaysProps) {
const {
deleteDialogOpen, closeDeleteDialog, stackToDelete,
pendingUnsavedLoad, pendingLeaveAction,
bashModalOpen, selectedContainer,
logViewerOpen, logContainer,
stackMonitor, closeStackMonitor,
policyBlock, setPolicyBlock, policyBypassing,
updateReadiness, setUpdateReadiness,
preDeployAdvisory,
selfStackProtectedOpen, setSelfStackProtectedOpen,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} = overlayState;
return (
<>
<DeleteStackDialog
open={deleteDialogOpen}
onOpenChange={(open) => { if (!open) closeDeleteDialog(); }}
stackName={stackToDelete}
onConfirm={stackActions.deleteStack}
/>
<SelfStackProtectedDialog
open={selfStackProtectedOpen}
onOpenChange={setSelfStackProtectedOpen}
canOpenFleetUpdates={canSelfUpdate}
onOpenFleetUpdates={onOpenFleetNodeUpdates}
/>
<UnsavedChangesDialog
open={!!pendingUnsavedLoad || !!pendingLeaveAction}
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 monitor (alerts + auto-heal as tabs) */}
<StackAlertSheet
open={stackMonitor !== null}
onOpenChange={(open) => { if (!open) closeStackMonitor(); }}
stackName={stackMonitor?.stackName ?? ''}
initialTab={stackMonitor?.tab ?? 'alerts'}
/>
{/* Pre-update readiness check */}
<UpdateReadinessDialog
open={updateReadiness !== null}
stackName={updateReadiness?.stackName ?? ''}
nodeId={updateReadiness?.nodeId ?? null}
onCancel={() => setUpdateReadiness(null)}
onProceed={() => updateReadiness?.proceed()}
/>
{/* Pre-deploy scan advisory (visibility only; never blocks) */}
<PreDeployScanDialog
open={preDeployAdvisory !== null}
stackName={preDeployAdvisory?.stackName ?? ''}
images={preDeployAdvisory?.images ?? []}
onCancel={() => preDeployAdvisory?.cancel()}
onDeploy={() => preDeployAdvisory?.proceed()}
/>
{/* Pre-deploy policy block */}
<PolicyBlockDialog
open={policyBlock !== null}
payload={policyBlock?.payload ?? null}
stackName={policyBlock?.stackName ?? ''}
canBypass={isAdmin}
bypassing={policyBypassing}
onClose={() => setPolicyBlock(null)}
onBypass={stackActions.bypassPolicyAndRetry}
/>
{/* 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)}
canManageSuppressions={isAdmin}
/>
{/* 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') {
const saved = await stackActions.saveFile();
if (saved) await stackActions.deployStack();
} else {
await stackActions.saveFile();
}
} finally {
setDiffPreviewConfirming(false);
setDiffPreview(null);
}
}}
/>
</>
);
}