feat(fleet): reapply Compose configuration without a version update (#1716)

* feat(fleet): reapply Compose configuration without a version update

Add a distinct Fleet Reapply configuration path so Compose-managed nodes can recreate Sencho from the current on-disk project when already up to date, without pulling or rewriting the image reference.

* fix(fleet): confirm remote reapply and close concurrent tracker race

Require confirmation for remote compose reapply, and lock dispatch before the remote POST so a second request cannot overwrite a successful in-flight tracker.

* fix(ui): icon-only Reapply control so Up to date badge can breathe

Collapse the Node updates Reapply label into a tooltip so the status pill no longer wraps in the Status column.

* feat(editor): Save & Reapply self-stack via fleet compose reapply (#1726)

* feat(editor): Save & Reapply self-stack via fleet compose reapply

Eligible admins can apply on-disk Compose edits to Sencho's own stack from the editor using the same confirm, dispatch, and reconnect path as Fleet Node Updates.

* fix(editor): gate Save & Reapply label to self-stack only

Ordinary stacks were labeled Save & Reapply whenever the node was
reapply-eligible. Require the selected file to be the self-stack for the
toolbar label and diff confirm CTA.

* fix(ui): move compose diff action label helper out of dialog module

Keep ComposeDiffPreviewDialog component-only so react-refresh Fast Refresh
lint passes after the Save and reapply stacked merge.
This commit is contained in:
Anso
2026-07-28 14:26:46 -04:00
committed by GitHub
parent 543e4ef256
commit b0b423b234
42 changed files with 1770 additions and 168 deletions
@@ -207,6 +207,8 @@ export interface EditorViewProps {
showTakeDown: boolean;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
/** Admin + node reapply eligibility + self-stack: show Save & Reapply instead of Save & Deploy. */
canSaveAndReapply?: boolean;
// Recovery surface for a failed/stalled operation on this stack (undefined
// when the last op succeeded or none has run). onRefreshState re-syncs
@@ -300,6 +302,7 @@ export function EditorView(props: EditorViewProps) {
requestTakeDownStack,
showTakeDown,
isSelfStack,
canSaveAndReapply = false,
recoveryResult,
onRefreshState,
onDismissRecovery,
@@ -609,7 +612,7 @@ export function EditorView(props: EditorViewProps) {
<div className="flex items-center">
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={requestSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save & Deploy
{canSaveAndReapply ? 'Save & Reapply' : 'Save & Deploy'}
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -27,6 +27,7 @@ interface MobileComposeEditorProps {
canEdit: boolean;
requestSave: () => void;
requestSaveAndDeploy: (e: React.MouseEvent) => void;
canSaveAndReapply?: boolean;
onClose: () => void;
hasUnsavedChanges: () => boolean;
}
@@ -54,6 +55,7 @@ export function MobileComposeEditor(props: MobileComposeEditorProps) {
canEdit,
requestSave,
requestSaveAndDeploy,
canSaveAndReapply = false,
onClose,
hasUnsavedChanges,
} = props;
@@ -195,7 +197,7 @@ export function MobileComposeEditor(props: MobileComposeEditorProps) {
className="h-11 flex-1 rounded-lg"
>
<Rocket className="mr-2 h-4 w-4" strokeWidth={1.5} />
Save &amp; Deploy
{canSaveAndReapply ? 'Save & Reapply' : 'Save & Deploy'}
</Button>
</div>
)}
@@ -75,6 +75,7 @@ export function MobileStackDetail(props: EditorViewProps) {
requestTakeDownStack,
showTakeDown,
isSelfStack = false,
canSaveAndReapply = false,
onMobileBack,
onCloseEditor,
hasUnsavedChanges,
@@ -115,6 +116,7 @@ export function MobileStackDetail(props: EditorViewProps) {
canEdit={canEditStack}
requestSave={requestSave}
requestSaveAndDeploy={requestSaveAndDeploy}
canSaveAndReapply={canSaveAndReapply}
onClose={onCloseEditor}
hasUnsavedChanges={hasUnsavedChanges}
/>
@@ -4,6 +4,8 @@ import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
import { MissingExternalNetworksDialog } from '../stack/MissingExternalNetworksDialog';
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
import { LocalUpdateConfirmDialog } from '../FleetView/LocalUpdateConfirmDialog';
import { ReconnectingOverlay } from '../FleetView/ReconnectingOverlay';
import { DeleteStackDialog } from './DeleteStackDialog';
import { TakeDownStackDialog } from './TakeDownStackDialog';
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
@@ -12,9 +14,11 @@ import { GitSourcePanel } from '../stack/GitSourcePanel';
import { LogViewer } from '../LogViewer';
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog';
import { resolveComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel';
import type { OverlayState } from './hooks/useOverlayState';
import type { StackActionsHook } from './hooks/useStackActions';
import type { PermissionAction } from '@/context/AuthContext';
import type { useComposeReapplyAction } from '../FleetView/hooks/useComposeReapplyAction';
interface ShellOverlaysProps {
overlayState: OverlayState;
@@ -27,6 +31,8 @@ interface ShellOverlaysProps {
gitSourceOpen: boolean;
setGitSourceOpen: (open: boolean) => void;
canSelfUpdate: boolean;
composeReapply: ReturnType<typeof useComposeReapplyAction>;
canSaveAndReapply: boolean;
canOfferVolumeRemoval: boolean;
onOpenFleetNodeUpdates: () => void;
}
@@ -42,6 +48,8 @@ export function ShellOverlays({
gitSourceOpen,
setGitSourceOpen,
canSelfUpdate,
composeReapply,
canSaveAndReapply,
canOfferVolumeRemoval,
onOpenFleetNodeUpdates,
}: ShellOverlaysProps) {
@@ -57,6 +65,7 @@ export function ShellOverlays({
preDeployAdvisory,
missingExternalNetworks, setMissingExternalNetworks,
selfStackProtectedOpen, setSelfStackProtectedOpen,
composeReapplyCapture, setComposeReapplyCapture,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} = overlayState;
@@ -85,6 +94,32 @@ export function ShellOverlays({
onOpenFleetUpdates={onOpenFleetNodeUpdates}
/>
<LocalUpdateConfirmDialog
open={composeReapplyCapture !== null}
mode="reapply"
nodeType={composeReapplyCapture?.nodeType ?? 'local'}
onOpenChange={(open) => {
if (!open) setComposeReapplyCapture(null);
}}
onConfirm={() => {
const capture = composeReapplyCapture;
setComposeReapplyCapture(null);
if (!capture || composeReapply.dispatching) return;
void composeReapply.runReapply({
nodeId: capture.nodeId,
type: capture.nodeType,
name: capture.nodeName,
});
}}
/>
{composeReapply.reconnecting && (
<ReconnectingOverlay
preUpdateStartedAt={composeReapply.preUpdateStartedAt}
mode="reapply"
/>
)}
<UnsavedChangesDialog
open={!!pendingUnsavedLoad || !!pendingLeaveAction}
onCancel={stackActions.cancelPendingUnsavedLoad}
@@ -197,7 +232,7 @@ export function ShellOverlays({
language={diffPreview?.language ?? 'yaml'}
original={diffPreview?.original ?? ''}
modified={diffPreview?.modified ?? ''}
actionLabel={diffPreview?.mode === 'save-and-deploy' ? 'Save & deploy' : 'Save'}
actionLabel={resolveComposeDiffActionLabel(diffPreview?.mode, canSaveAndReapply)}
confirming={diffPreviewConfirming}
isDarkMode={isDarkMode}
onConfirm={async () => {
@@ -164,6 +164,17 @@ describe('EditorView single edit gate', () => {
expect(lastReadOnly).toBe(false);
});
it('shows Save & Reapply when the self-stack is eligible for compose reapply', () => {
render(<EditorView {...makeProps({
editingCompose: true,
activeTab: 'compose',
isSelfStack: true,
canSaveAndReapply: true,
})} />);
expect(screen.getByRole('button', { name: 'Save & Reapply' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save & Deploy' })).not.toBeInTheDocument();
});
it('disables the env file selector when hasUnsavedChanges is true', () => {
render(
<EditorView
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { resolveCanSaveAndReapply } from '../resolveCanSaveAndReapply';
describe('resolveCanSaveAndReapply', () => {
it('is true only when admin, node-eligible, and self-stack', () => {
expect(resolveCanSaveAndReapply(true, true, true)).toBe(true);
});
it('is false for ordinary stacks even when admin and node-eligible', () => {
expect(resolveCanSaveAndReapply(true, true, false)).toBe(false);
});
it('is false when not admin or not node-eligible', () => {
expect(resolveCanSaveAndReapply(false, true, true)).toBe(false);
expect(resolveCanSaveAndReapply(true, false, true)).toBe(false);
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useActiveNodeReapplyEligibility } from '../useActiveNodeReapplyEligibility';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('useActiveNodeReapplyEligibility', () => {
beforeEach(() => {
apiFetchMock.mockReset();
});
it('derives canReapply only when owned result matches active node and value is true', async () => {
apiFetchMock.mockResolvedValue(okJson({
nodes: [{ nodeId: 1, canReapplyCompose: true }],
}));
const { result } = renderHook(() => useActiveNodeReapplyEligibility(1));
expect(result.current.canReapply).toBe(false);
await waitFor(() => expect(result.current.canReapply).toBe(true));
});
it('ignores a late response for a previous node after switching', async () => {
let resolveA!: (value: Response) => void;
const pendingA = new Promise<Response>((resolve) => { resolveA = resolve; });
apiFetchMock
.mockImplementationOnce(() => pendingA)
.mockResolvedValueOnce(okJson({
nodes: [{ nodeId: 2, canReapplyCompose: false }],
}));
const { result, rerender } = renderHook(
({ id }: { id: number | null }) => useActiveNodeReapplyEligibility(id),
{ initialProps: { id: 1 as number | null } },
);
rerender({ id: 2 });
await waitFor(() => expect(apiFetchMock).toHaveBeenCalledTimes(2));
expect(result.current.canReapply).toBe(false);
await act(async () => {
resolveA(okJson({
nodes: [{ nodeId: 1, canReapplyCompose: true }],
}));
});
expect(result.current.canReapply).toBe(false);
expect(result.current.owned?.nodeId === 2 || result.current.owned === null || result.current.owned.nodeId === 2).toBe(true);
});
it('stays ineligible when the row is missing canReapplyCompose', async () => {
apiFetchMock.mockResolvedValue(okJson({
nodes: [{ nodeId: 1 }],
}));
const { result } = renderHook(() => useActiveNodeReapplyEligibility(1));
await waitFor(() => expect(result.current.owned).not.toBeNull());
expect(result.current.canReapply).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { useState, useEffect, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import type { NodeUpdateStatus } from '@/components/FleetView/types';
type OwnedEligibility = { nodeId: number; value: boolean };
/**
* Authoritative canReapplyCompose for the active node from /fleet/update-status.
* Result is keyed by nodeId so a late response for a previous node cannot enable
* Save & Reapply after the operator switches nodes. Derived canReapply is only
* true when the owned result's nodeId matches the current activeNodeId.
*/
export function useActiveNodeReapplyEligibility(activeNodeId: number | null | undefined) {
const [owned, setOwned] = useState<OwnedEligibility | null>(null);
const generationRef = useRef(0);
useEffect(() => {
if (activeNodeId == null) {
generationRef.current += 1;
setOwned(null);
return;
}
const generation = ++generationRef.current;
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (cancelled || generation !== generationRef.current) return;
if (!res.ok) {
setOwned({ nodeId: activeNodeId, value: false });
return;
}
const data = await res.json();
if (cancelled || generation !== generationRef.current) return;
const nodes: NodeUpdateStatus[] = data.nodes ?? [];
const row = nodes.find(n => n.nodeId === activeNodeId);
setOwned({
nodeId: activeNodeId,
value: row?.canReapplyCompose === true,
});
} catch (error) {
if (cancelled || generation !== generationRef.current) return;
console.warn('[Editor] Failed to load reapply eligibility:', error);
setOwned({ nodeId: activeNodeId, value: false });
}
})();
return () => {
cancelled = true;
};
}, [activeNodeId]);
// Synchronous ownership check: after a node switch, a stale owned row for the
// previous node must not enable reapply on the new active node.
const canReapply =
activeNodeId != null
&& owned !== null
&& owned.nodeId === activeNodeId
&& owned.value === true;
return { canReapply, owned };
}
@@ -166,6 +166,14 @@ export function useOverlayState() {
const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []);
const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []);
/** Captured when Save & Reapply opens confirm; cleared on cancel, confirm, or ownership drift. */
const [composeReapplyCapture, setComposeReapplyCapture] = useState<{
nodeId: number;
nodeType: 'local' | 'remote';
nodeName: string;
stackFile: string;
} | null>(null);
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
const [diffPreview, setDiffPreview] = useState<DiffPreview | null>(null);
@@ -187,6 +195,7 @@ export function useOverlayState() {
preDeployAdvisory, setPreDeployAdvisory,
missingExternalNetworks, setMissingExternalNetworks,
selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected,
composeReapplyCapture, setComposeReapplyCapture,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} as const;
@@ -106,6 +106,8 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
preDeployAdvisory: null,
setPreDeployAdvisory: vi.fn(),
openSelfStackProtected: vi.fn(),
setComposeReapplyCapture: vi.fn(),
composeReapplyCapture: null,
setDiffPreview: vi.fn(),
stackToDelete: null,
closeDeleteDialog: vi.fn(),
@@ -131,6 +133,8 @@ function setup(over: {
setActiveNode?: Parameters<typeof useStackActions>[0]['setActiveNode'];
onDeletedOpenStack?: () => void;
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
isAdmin?: boolean;
canReapplyCompose?: boolean;
} = {}) {
const editorState = makeEditorState(over.editorState);
const stackListState = makeStackListState(over.stackList);
@@ -150,7 +154,7 @@ function setup(over: {
stackListState,
navState,
overlayState,
activeNode: over.activeNode ?? ({ id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode']),
activeNode: over.activeNode ?? ({ id: 1, name: 'Local', type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode']),
setActiveNode,
nodes: [],
runWithLog,
@@ -160,6 +164,8 @@ function setup(over: {
canEditStack: over.canEditStack ?? (() => true),
onDeletedOpenStack,
removeNotificationsForStack,
isAdmin: over.isAdmin ?? false,
canReapplyCompose: over.canReapplyCompose ?? false,
}),
);
return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack, removeNotificationsForStack };
@@ -1127,6 +1133,152 @@ describe('useStackActions.getStackMenuVisibility', () => {
expect(stackListState.setStackAction).not.toHaveBeenCalled();
});
it('opens reapply capture for eligible admin Save & Deploy on self-stack without posting deploy', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState } = setup({
isAdmin: true,
canReapplyCompose: true,
activeNode: { id: 7, name: 'Gateway', type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); });
expect(overlayState.setComposeReapplyCapture).toHaveBeenCalledWith({
nodeId: 7,
nodeType: 'local',
nodeName: 'Gateway',
stackFile: 'sencho.yml',
});
expect(overlayState.openSelfStackProtected).not.toHaveBeenCalled();
expect(apiFetch).not.toHaveBeenCalled();
});
it('does not open reapply capture for ordinary stacks when canReapplyCompose is true', async () => {
// Node eligibility alone must not retarget ordinary stacks; isSelfStackFile gates capture.
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ hasIssues: false }), { status: 200 }));
const { result, overlayState, stackListState } = setup({
isAdmin: true,
canReapplyCompose: true,
activeNode: { id: 7, name: 'Gateway', type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
stackList: {
selectedFile: 'web.yml',
stackSelfFlags: { 'web.yml': false },
},
});
await act(async () => {
await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent);
});
expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled();
expect(overlayState.openSelfStackProtected).not.toHaveBeenCalled();
expect(stackListState.setStackAction).toHaveBeenCalled();
});
it('opens protected dialog for self-stack deploy when reapply is not eligible', async () => {
const { result, overlayState } = setup({
isAdmin: true,
canReapplyCompose: false,
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled();
});
it('opens protected dialog for non-admin even when canReapplyCompose is true', async () => {
const { result, overlayState } = setup({
isAdmin: false,
canReapplyCompose: true,
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled();
});
it('cancels open reapply capture when the active node changes', async () => {
const setComposeReapplyCapture = vi.fn();
const activeNodeA = { id: 1, name: 'A', type: 'local' as const };
const { rerender } = renderHook(
({ activeNode }: { activeNode: typeof activeNodeA }) =>
useStackActions({
editorState: makeEditorState(),
stackListState: makeStackListState({
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
}),
navState: { activeView: 'editor', setActiveView: vi.fn() } as unknown as NavState,
overlayState: makeOverlay({
composeReapplyCapture: {
nodeId: 1,
nodeType: 'local',
nodeName: 'A',
stackFile: 'sencho.yml',
},
setComposeReapplyCapture,
}),
activeNode: activeNode as unknown as Parameters<typeof useStackActions>[0]['activeNode'],
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
canEditStack: () => true,
onDeletedOpenStack: vi.fn(),
isAdmin: true,
canReapplyCompose: true,
}),
{ initialProps: { activeNode: activeNodeA } },
);
rerender({ activeNode: { id: 2, name: 'B', type: 'local' as const } });
expect(setComposeReapplyCapture).toHaveBeenCalledWith(null);
});
it('cancels open reapply capture when the selected stack changes', async () => {
const setComposeReapplyCapture = vi.fn();
const { rerender } = renderHook(
({ selectedFile }: { selectedFile: string }) =>
useStackActions({
editorState: makeEditorState(),
stackListState: makeStackListState({
selectedFile,
stackSelfFlags: { 'sencho.yml': true, 'other.yml': true },
}),
navState: { activeView: 'editor', setActiveView: vi.fn() } as unknown as NavState,
overlayState: makeOverlay({
composeReapplyCapture: {
nodeId: 1,
nodeType: 'local',
nodeName: 'A',
stackFile: 'sencho.yml',
},
setComposeReapplyCapture,
}),
activeNode: { id: 1, name: 'A', type: 'local' } as unknown as Parameters<typeof useStackActions>[0]['activeNode'],
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
canEditStack: () => true,
onDeletedOpenStack: vi.fn(),
isAdmin: true,
canReapplyCompose: true,
}),
{ initialProps: { selectedFile: 'sencho.yml' } },
);
rerender({ selectedFile: 'other.yml' });
expect(setComposeReapplyCapture).toHaveBeenCalledWith(null);
});
it('opens the self-stack modal instead of calling rollback on a protected stack', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState, stackListState } = setup({
@@ -182,6 +182,10 @@ interface UseStackActionsOptions {
* Optional so unit tests that do not exercise delete can omit it.
*/
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
/** Admin role: required together with canReapplyCompose for Save & Reapply. */
isAdmin?: boolean;
/** Authoritative canReapplyCompose === true for the active node. */
canReapplyCompose?: boolean;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -406,6 +410,8 @@ export function useStackActions(options: UseStackActionsOptions) {
canOfferVolumeRemoval = false,
onDeletedOpenStack,
removeNotificationsForStack,
isAdmin = false,
canReapplyCompose = false,
} = options;
const pendingStackLoadRef = useRef<string | null>(null);
@@ -445,6 +451,24 @@ export function useStackActions(options: UseStackActionsOptions) {
containersRef.current = editorState.containers;
});
// Cancel an open Save & Reapply confirmation if the active node or selected
// stack diverges from the capture (never retarget a pending confirm).
useEffect(() => {
const capture = overlayState.composeReapplyCapture;
if (!capture) return;
if (
activeNode?.id !== capture.nodeId
|| stackListState.selectedFile !== capture.stackFile
) {
overlayState.setComposeReapplyCapture(null);
}
}, [
activeNode?.id,
stackListState.selectedFile,
overlayState.composeReapplyCapture,
overlayState.setComposeReapplyCapture,
]);
useEffect(() => {
return () => {
if (checkUpdatesIntervalRef.current !== null) {
@@ -1371,8 +1395,22 @@ export function useStackActions(options: UseStackActionsOptions) {
deployPendingRef.current
)
return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
const stackFile = stackListState.selectedFile;
if (isSelfStackFile(stackFile)) {
if (isAdmin && canReapplyCompose && activeNode) {
overlayState.setComposeReapplyCapture({
nodeId: activeNode.id,
nodeType: activeNode.type === 'local' ? 'local' : 'remote',
nodeName: activeNode.name,
stackFile,
});
return;
}
overlayState.openSelfStackProtected();
return;
}
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
// Snapshot the node once so the advisory fetch and the deploy stay bound to
// it even if the active node changes while the advisory dialog is open.
@@ -0,0 +1,8 @@
/** Toolbar/diff eligibility: admin + node reapply + selected file is self-stack. */
export function resolveCanSaveAndReapply(
isAdmin: boolean,
canReapplyCompose: boolean,
isSelfStack: boolean,
): boolean {
return isAdmin && canReapplyCompose && isSelfStack;
}