mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
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:
@@ -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 & 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);
|
||||
});
|
||||
});
|
||||
+68
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user