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
@@ -3,6 +3,7 @@ import { DiffEditor } from '@/lib/monacoLoader';
import { Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import type { ComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel';
export interface ComposeDiffPreviewDialogProps {
open: boolean;
@@ -12,7 +13,7 @@ export interface ComposeDiffPreviewDialogProps {
language: 'yaml' | 'ini';
original: string;
modified: string;
actionLabel: 'Save' | 'Save & deploy';
actionLabel: ComposeDiffActionLabel;
confirming: boolean;
isDarkMode: boolean;
onConfirm: () => void | Promise<void>;
+15 -1
View File
@@ -23,6 +23,9 @@ import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch';
import { useNotifications } from './EditorLayout/hooks/useNotifications';
import { useContainerStats } from './EditorLayout/hooks/useContainerStats';
import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu';
import { useActiveNodeReapplyEligibility } from './EditorLayout/hooks/useActiveNodeReapplyEligibility';
import { resolveCanSaveAndReapply } from './EditorLayout/resolveCanSaveAndReapply';
import { useComposeReapplyAction } from './FleetView/hooks/useComposeReapplyAction';
import { NodeSwitcher } from './NodeSwitcher';
import {
GlobalCommandPalette,
@@ -186,6 +189,12 @@ export default function EditorLayout() {
createDialogOpen, setCreateDialogOpen,
} = overlayState;
const { canReapply: canReapplyCompose } = useActiveNodeReapplyEligibility(activeNode?.id);
const composeReapply = useComposeReapplyAction();
const isSelfStackSelected = selectedFile ? stackSelfFlags[selectedFile] === true : false;
// Ordinary stacks keep Save & Deploy even when the node supports compose reapply.
const canSaveAndReapply = resolveCanSaveAndReapply(isAdmin, canReapplyCompose, isSelfStackSelected);
// Which mode the create dialog opens on (always empty after import tab removal).
const [createDialogInitialMode, setCreateDialogInitialMode] = useState<CreateMode>('empty');
const [adoptDialogOpen, setAdoptDialogOpen] = useState(false);
@@ -292,6 +301,8 @@ export default function EditorLayout() {
canOfferVolumeRemoval,
onDeletedOpenStack: () => onDeletedOpenStackRef.current(),
removeNotificationsForStack,
isAdmin,
canReapplyCompose,
});
// Wire the ref now that stackActions is available
@@ -692,7 +703,8 @@ export default function EditorLayout() {
requestDeleteStack={stackActions.requestDeleteStack}
requestTakeDownStack={stackActions.requestTakeDownStack}
showTakeDown={selectedFile ? stackActions.getStackMenuVisibility(selectedFile).showTakeDown : false}
isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false}
isSelfStack={isSelfStackSelected}
canSaveAndReapply={canSaveAndReapply}
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
onRefreshState={async () => {
if (!selectedFile) return;
@@ -1050,6 +1062,8 @@ export default function EditorLayout() {
gitSourceOpen={gitSourceOpen}
setGitSourceOpen={setGitSourceOpen}
canSelfUpdate={hasCapability('self-update')}
composeReapply={composeReapply}
canSaveAndReapply={canSaveAndReapply}
canOfferVolumeRemoval={canOfferVolumeRemoval}
onOpenFleetNodeUpdates={() => {
if (isMobile) {
@@ -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;
}
+32 -11
View File
@@ -74,9 +74,13 @@ export function FleetView({
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
const overview = useFleetOverview({ prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
// The local node's status backs the confirm dialog copy (pin + target ref).
const localUpdateConfirmStatus = updateStatus.localUpdateConfirm !== null
? updateStatus.updateStatuses.find(s => s.nodeId === updateStatus.localUpdateConfirm)
// Confirm dialogs: local update uses pin/target copy; reapply covers local
// and remote nodes with mode-specific wording. Prefer reapply when both set.
const confirmMode = updateStatus.reapplyConfirm !== null ? 'reapply' as const : 'update' as const;
const confirmNodeId = updateStatus.reapplyConfirm ?? updateStatus.localUpdateConfirm;
const confirmOpen = confirmNodeId !== null;
const confirmStatus = confirmNodeId !== null
? updateStatus.updateStatuses.find(s => s.nodeId === confirmNodeId)
: undefined;
const topology = useTopologyPreferences();
const { exporting, exportDossier } = useFleetDossierExport();
@@ -340,7 +344,10 @@ export function FleetView({
</Tabs>
{updateStatus.reconnecting && (
<ReconnectingOverlay preUpdateStartedAt={updateStatus.preUpdateStartedAt} />
<ReconnectingOverlay
preUpdateStartedAt={updateStatus.preUpdateStartedAt}
mode={updateStatus.reconnectMode}
/>
)}
<NodeUpdatesSheet
@@ -353,19 +360,33 @@ export function FleetView({
initialTab={initialUpdatesTab}
fetchUpdateStatus={updateStatus.fetchUpdateStatus}
triggerNodeUpdate={updateStatus.triggerNodeUpdate}
triggerNodeReapply={updateStatus.triggerNodeReapply}
retryNodeUpdate={updateStatus.retryNodeUpdate}
dismissNodeUpdate={updateStatus.dismissNodeUpdate}
triggerUpdateAll={updateStatus.triggerUpdateAll}
/>
<LocalUpdateConfirmDialog
open={updateStatus.localUpdateConfirm !== null}
onOpenChange={(open) => { if (!open) updateStatus.setLocalUpdateConfirm(null); }}
onConfirm={updateStatus.confirmLocalUpdate}
imagePinKind={localUpdateConfirmStatus?.imagePinKind}
composeImageRef={localUpdateConfirmStatus?.composeImageRef}
targetImageRef={localUpdateConfirmStatus?.targetImageRef}
targetVersion={localUpdateConfirmStatus?.latestVersion}
open={confirmOpen}
mode={confirmMode}
nodeType={
confirmMode === 'reapply'
? (updateStatus.reapplyConfirmTarget?.type ?? 'local')
: (confirmStatus?.type ?? 'local')
}
onOpenChange={(open) => {
if (!open) {
updateStatus.setLocalUpdateConfirm(null);
updateStatus.setReapplyConfirm(null);
}
}}
onConfirm={confirmMode === 'reapply'
? updateStatus.confirmReapply
: updateStatus.confirmLocalUpdate}
imagePinKind={confirmStatus?.imagePinKind}
composeImageRef={confirmStatus?.composeImageRef}
targetImageRef={confirmStatus?.targetImageRef}
targetVersion={confirmStatus?.latestVersion}
/>
{NodeActionModals}
@@ -1,4 +1,5 @@
import { Download } from 'lucide-react';
import { Download, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { ConfirmModal } from '@/components/ui/modal';
import { formatVersion } from '@/lib/version';
import type { ImagePinKind } from './types';
@@ -7,6 +8,9 @@ interface LocalUpdateConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
mode?: 'update' | 'reapply';
/** Distinguishes local vs remote reapply copy. Ignored for update mode. */
nodeType?: 'local' | 'remote';
imagePinKind?: ImagePinKind | null;
composeImageRef?: string | null;
targetImageRef?: string | null;
@@ -14,33 +18,72 @@ interface LocalUpdateConfirmDialogProps {
}
export function LocalUpdateConfirmDialog({
open, onOpenChange, onConfirm, imagePinKind, composeImageRef, targetImageRef, targetVersion,
open, onOpenChange, onConfirm, mode = 'update', nodeType = 'local',
imagePinKind, composeImageRef, targetImageRef, targetVersion,
}: LocalUpdateConfirmDialogProps) {
const isReapply = mode === 'reapply';
const isRemoteReapply = isReapply && nodeType === 'remote';
const versionLabel = formatVersion(targetVersion) ?? 'the latest release';
let kicker = 'LOCAL · UPDATE';
if (isRemoteReapply) kicker = 'REMOTE · REAPPLY';
else if (isReapply) kicker = 'LOCAL · REAPPLY';
let body: ReactNode;
if (isRemoteReapply) {
body = (
<p className="text-sm text-stat-subtitle">
Recreates this remote Sencho service from its current Compose configuration.
No newer Sencho version is selected, and Sencho will not rewrite the
configured image reference. The node will restart; Fleet tracks reconnection.
</p>
);
} else if (isReapply) {
body = (
<p className="text-sm text-stat-subtitle">
Recreates this Sencho service from its current Compose configuration.
No newer Sencho version is selected, and Sencho will not rewrite the
configured image reference. The dashboard may briefly disconnect and
reconnects automatically when the restart completes.
</p>
);
} else if (imagePinKind === 'semver' && composeImageRef && targetImageRef) {
body = (
<p className="text-sm text-stat-subtitle">
This install pins <code className="text-stat-value">{composeImageRef}</code>. Updating rewrites it to{' '}
<code className="text-stat-value">{targetImageRef}</code> and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
);
} else {
body = (
<p className="text-sm text-stat-subtitle">
Pulls Sencho {versionLabel} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
);
}
return (
<ConfirmModal
open={open}
onOpenChange={onOpenChange}
kicker="LOCAL · UPDATE"
title="Update local node"
kicker={kicker}
title={isReapply ? 'Reapply configuration' : 'Update local node'}
confirmLabel={
<>
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Update &amp; restart
</>
isReapply ? (
<>
<RefreshCw className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Reapply &amp; restart
</>
) : (
<>
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Update &amp; restart
</>
)
}
onConfirm={onConfirm}
>
{imagePinKind === 'semver' && composeImageRef && targetImageRef ? (
<p className="text-sm text-stat-subtitle">
This install pins <code className="text-stat-value">{composeImageRef}</code>. Updating rewrites it to{' '}
<code className="text-stat-value">{targetImageRef}</code> and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
) : (
<p className="text-sm text-stat-subtitle">
Pulls Sencho {versionLabel} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
)}
{body}
</ConfirmModal>
);
}
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { MarkdownContent } from '@/components/ui/MarkdownContent';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatVersion, isValidVersion } from '@/lib/version';
@@ -29,6 +30,7 @@ interface NodeUpdatesSheetProps {
initialTab?: 'nodes' | 'changelog';
fetchUpdateStatus: () => Promise<void>;
triggerNodeUpdate: (nodeId: number) => void;
triggerNodeReapply: (nodeId: number) => void;
retryNodeUpdate: (nodeId: number) => void;
dismissNodeUpdate: (nodeId: number) => void;
triggerUpdateAll: () => Promise<void>;
@@ -37,7 +39,7 @@ interface NodeUpdatesSheetProps {
export function NodeUpdatesSheet({
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin,
initialTab = 'nodes',
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
fetchUpdateStatus, triggerNodeUpdate, triggerNodeReapply, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
}: NodeUpdatesSheetProps) {
const [search, setSearch] = useState('');
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
@@ -410,12 +412,17 @@ export function NodeUpdatesSheet({
<UpdateStatusBadge
status={s.updateStatus}
error={s.error}
onRetry={isAdmin ? () => retryNodeUpdate(s.nodeId) : undefined}
operationKind={s.operationKind}
onRetry={isAdmin ? () => (
s.operationKind === 'reapply_configuration'
? triggerNodeReapply(s.nodeId)
: retryNodeUpdate(s.nodeId)
) : undefined}
onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined}
/>
)}
{!s.updateStatus && !s.updateAvailable && !s.skipActive && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
<Badge className="text-[10px] px-1.5 py-0 h-5 shrink-0 whitespace-nowrap bg-success-muted text-success border-success/30">
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
</Badge>
)}
@@ -456,6 +463,41 @@ export function NodeUpdatesSheet({
)}
</Button>
)}
{isAdmin && !s.updateStatus && s.canReapplyCompose && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0 text-muted-foreground hover:text-stat-value"
onClick={() => triggerNodeReapply(s.nodeId)}
disabled={updatingNodeId === s.nodeId}
aria-label={updatingNodeId === s.nodeId ? 'Reapplying configuration' : 'Reapply configuration'}
>
{updatingNodeId === s.nodeId ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{updatingNodeId === s.nodeId ? 'Reapplying…' : 'Reapply configuration'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isAdmin && !s.updateStatus && s.canReapplyCompose === false && (
<span
className="text-[10px] text-muted-foreground/70 max-w-[9rem] text-right leading-tight"
title={s.type === 'local'
? 'This node is not Compose-managed, so configuration reapply is unavailable.'
: 'This node does not advertise Compose self-management, or is unreachable.'}
>
Reapply unavailable
</span>
)}
{showSkip(s) && (
<Button
variant="ghost"
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
interface ReconnectingOverlayProps {
/** Gateway boot timestamp captured pre-update. Null falls back to offline-then-online detection. */
preUpdateStartedAt: number | null;
/** Distinguishes version-update copy from compose reapply copy. */
mode?: 'update' | 'reapply';
}
// Mirrors the backend UPDATE_TIMEOUT_MS (5 minutes) in routes/fleet.ts. Past
@@ -13,9 +15,13 @@ interface ReconnectingOverlayProps {
// run longer than the auto-reload budget.
const RECONNECT_TIMEOUT_SECONDS = 5 * 60;
export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayProps) {
export function ReconnectingOverlay({
preUpdateStartedAt,
mode = 'update',
}: ReconnectingOverlayProps) {
const [elapsed, setElapsed] = useState(0);
const timedOut = elapsed >= RECONNECT_TIMEOUT_SECONDS;
const isReapply = mode === 'reapply';
useEffect(() => {
const timer = setInterval(() => setElapsed(s => s + 1), 1000);
@@ -62,7 +68,9 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
<AlertTriangle className="w-10 h-10 text-warning mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Taking longer than expected</h2>
<p className="text-sm text-muted-foreground max-w-sm">
Sencho has not come back online yet. A large image pull can take a while, so the update may still be finishing. Reload to check, or inspect the Docker host if it persists.
{isReapply
? 'Sencho has not come back online yet. The recreate may still be finishing. Reload to check, or inspect the Docker host if it persists.'
: 'Sencho has not come back online yet. A large image pull can take a while, so the update may still be finishing. Reload to check, or inspect the Docker host if it persists.'}
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Reload to check
@@ -71,9 +79,13 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
) : (
<>
<Loader2 className="w-10 h-10 text-muted-foreground animate-spin mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Updating Sencho...</h2>
<h2 className="text-lg font-medium">
{isReapply ? 'Reapplying configuration...' : 'Updating Sencho...'}
</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server is pulling the update and restarting. This page will reload automatically.
{isReapply
? 'The server is recreating from its current Compose configuration and restarting. This page will reload automatically.'
: 'The server is pulling the update and restarting. This page will reload automatically.'}
</p>
<p className="text-xs text-muted-foreground tabular-nums">{elapsed}s elapsed</p>
</>
@@ -8,17 +8,19 @@ interface UpdateStatusBadgeProps {
error?: string | null;
onRetry?: () => void;
onDismiss?: () => void;
operationKind?: NodeUpdateStatus['operationKind'];
}
export function UpdateStatusBadge({ status, error, onRetry, onDismiss }: UpdateStatusBadgeProps) {
export function UpdateStatusBadge({ status, error, onRetry, onDismiss, operationKind }: UpdateStatusBadgeProps) {
const isReapply = operationKind === 'reapply_configuration';
if (status === 'updating') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-brand/15 text-brand border-brand/30 shrink-0">
<Loader2 className="w-2.5 h-2.5 mr-0.5 animate-spin" /> Updating
<Loader2 className="w-2.5 h-2.5 mr-0.5 animate-spin" /> {isReapply ? 'Reapplying' : 'Updating'}
</Badge>
);
if (status === 'completed') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-success-muted text-success border-success/30 shrink-0">
<Check className="w-2.5 h-2.5 mr-0.5" /> Updated
<Check className="w-2.5 h-2.5 mr-0.5" /> {isReapply ? 'Reapplied' : 'Updated'}
</Badge>
);
if (status === 'timeout' || status === 'failed') {
@@ -31,8 +33,8 @@ export function UpdateStatusBadge({ status, error, onRetry, onDismiss }: UpdateS
<button
onClick={(e) => { e.stopPropagation(); onRetry(); }}
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Retry update"
aria-label="Retry update"
title={isReapply ? 'Retry reapply' : 'Retry update'}
aria-label={isReapply ? 'Retry reapply' : 'Retry update'}
>
<RotateCcw className="w-3 h-3" strokeWidth={1.5} />
</button>
@@ -46,4 +46,38 @@ describe('LocalUpdateConfirmDialog', () => {
expect(screen.getByText(/Pulls Sencho v0\.94\.0/i)).toBeInTheDocument();
expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument();
});
it('explains local reapply without a version change or image rewrite', () => {
render(
<LocalUpdateConfirmDialog
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
mode="reapply"
nodeType="local"
/>,
);
expect(screen.getByRole('heading', { name: /Reapply configuration/i })).toBeInTheDocument();
expect(screen.getByText(/current Compose configuration/i)).toBeInTheDocument();
expect(screen.getByText(/No newer Sencho version is selected/i)).toBeInTheDocument();
expect(screen.getByText(/will not rewrite the configured image reference/i)).toBeInTheDocument();
expect(screen.getByText(/briefly disconnect/i)).toBeInTheDocument();
});
it('explains remote reapply with REMOTE kicker and restart acknowledgement', () => {
render(
<LocalUpdateConfirmDialog
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
mode="reapply"
nodeType="remote"
/>,
);
expect(screen.getByRole('heading', { name: /Reapply configuration/i })).toBeInTheDocument();
expect(screen.getByText(/Recreates this remote Sencho service/i)).toBeInTheDocument();
expect(screen.getByText(/No newer Sencho version is selected/i)).toBeInTheDocument();
expect(screen.getByText(/will not rewrite the configured image reference/i)).toBeInTheDocument();
expect(screen.getByText(/The node will restart/i)).toBeInTheDocument();
});
});
@@ -27,6 +27,7 @@ function baseProps(overrides: Partial<React.ComponentProps<typeof NodeUpdatesShe
isAdmin: true,
fetchUpdateStatus: vi.fn(async () => {}),
triggerNodeUpdate: vi.fn(),
triggerNodeReapply: vi.fn(),
retryNodeUpdate: vi.fn(),
dismissNodeUpdate: vi.fn(),
triggerUpdateAll: vi.fn(async () => {}),
@@ -363,4 +364,26 @@ describe('NodeUpdatesSheet', () => {
expect(screen.queryByLabelText('Retry update')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
});
it('shows an icon-only Reapply control on an up-to-date Compose-managed node', () => {
const triggerNodeReapply = vi.fn();
const statuses: NodeUpdateStatus[] = [
{
nodeId: 1,
name: 'Local',
type: 'local',
version: '1.1.0',
latestVersion: '1.1.0',
updateAvailable: false,
updateStatus: null,
canReapplyCompose: true,
},
];
render(<NodeUpdatesSheet {...baseProps({ updateStatuses: statuses, triggerNodeReapply })} />);
expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument();
const reapply = screen.getByRole('button', { name: 'Reapply configuration' });
expect(reapply).not.toHaveTextContent(/Reapply configuration/);
fireEvent.click(reapply);
expect(triggerNodeReapply).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useComposeReapplyAction } from '../useComposeReapplyAction';
const apiFetchMock = vi.fn();
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
success: (...a: unknown[]) => toastSuccess(...a),
error: (...a: unknown[]) => toastError(...a),
},
}));
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('useComposeReapplyAction', () => {
beforeEach(() => {
apiFetchMock.mockReset();
toastSuccess.mockReset();
toastError.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('openConfirm does not POST until confirmReapply', async () => {
const { result } = renderHook(() => useComposeReapplyAction());
act(() => {
result.current.openConfirm({ nodeId: 2, type: 'remote', name: 'Edge' });
});
expect(result.current.confirmTarget?.nodeId).toBe(2);
expect(apiFetchMock).not.toHaveBeenCalled();
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
await act(async () => { await result.current.confirmReapply(); });
expect(apiFetchMock).toHaveBeenCalledWith(
'/fleet/nodes/2/reapply-compose',
expect.objectContaining({ method: 'POST', localOnly: true }),
);
expect(toastSuccess).toHaveBeenCalled();
expect(result.current.confirmTarget).toBeNull();
});
it('cancelConfirm clears without POST', () => {
const { result } = renderHook(() => useComposeReapplyAction());
act(() => {
result.current.openConfirm({ nodeId: 1, type: 'local', name: 'Local' });
result.current.cancelConfirm();
});
expect(result.current.confirmTarget).toBeNull();
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('starts local reconnect after a successful local POST', async () => {
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 42 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)));
const { result } = renderHook(() => useComposeReapplyAction());
await act(async () => {
await result.current.runReapply({ nodeId: 1, type: 'local', name: 'Local' });
});
expect(result.current.reconnecting).toBe(true);
expect(result.current.preUpdateStartedAt).toBe(42);
});
it('clears reconnect when tracker reports local failure', async () => {
vi.useFakeTimers();
apiFetchMock
.mockResolvedValueOnce(okJson({ message: 'ok' }))
.mockResolvedValue(okJson({
nodes: [{
type: 'local',
updateStatus: 'failed',
operationKind: 'reapply_configuration',
error: 'Compose config invalid',
}],
}));
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 1 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)));
const { result } = renderHook(() => useComposeReapplyAction());
await act(async () => {
await result.current.runReapply({ nodeId: 1, type: 'local', name: 'Local' });
});
expect(result.current.reconnecting).toBe(true);
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(result.current.reconnecting).toBe(false);
expect(toastError).toHaveBeenCalledWith('Compose config invalid');
});
it('ignores a second confirm while dispatch is pending', async () => {
let release!: (value: Response) => void;
const held = new Promise<Response>((resolve) => { release = resolve; });
apiFetchMock.mockImplementation(() => held);
const { result } = renderHook(() => useComposeReapplyAction());
const first = act(async () => {
await result.current.runReapply({ nodeId: 2, type: 'remote', name: 'Edge' });
});
await act(async () => {
await result.current.runReapply({ nodeId: 2, type: 'remote', name: 'Edge' });
});
expect(apiFetchMock).toHaveBeenCalledTimes(1);
release(okJson({ message: 'ok' }));
await first;
});
});
@@ -173,6 +173,54 @@ describe('useFleetUpdateStatus', () => {
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('triggerNodeReapply on a remote node opens confirm and does not POST until confirmed', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockClear();
await act(async () => { await result.current.triggerNodeReapply(2); });
expect(result.current.reapplyConfirm).toBe(2);
expect(apiFetchMock).not.toHaveBeenCalled();
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
await act(async () => { await result.current.confirmReapply(); });
expect(apiFetchMock).toHaveBeenCalledWith(
'/fleet/nodes/2/reapply-compose',
expect.objectContaining({ method: 'POST', localOnly: true }),
);
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Edge'));
expect(result.current.reapplyConfirm).toBeNull();
});
it('triggerNodeReapply on a local node opens confirm then starts local reconnect flow', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockClear();
await act(async () => { await result.current.triggerNodeReapply(1); });
expect(result.current.reapplyConfirm).toBe(1);
expect(apiFetchMock).not.toHaveBeenCalled();
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
)));
await act(async () => { await result.current.confirmReapply(); });
expect(apiFetchMock).toHaveBeenCalledWith(
'/fleet/nodes/1/reapply-compose',
expect.objectContaining({ method: 'POST', localOnly: true }),
);
expect(result.current.reconnecting).toBe(true);
expect(result.current.reconnectMode).toBe('reapply');
vi.unstubAllGlobals();
});
it('confirmLocalUpdate forwards targetVersion when latestVersion is valid', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
@@ -0,0 +1,140 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { NodeUpdateStatus } from '../types';
export type ComposeReapplyTarget = {
nodeId: number;
type: 'local' | 'remote';
name: string;
};
function parseReapplyError(err: Record<string, unknown>, fallback: string): string {
const nested = err?.data as Record<string, unknown> | undefined;
const message = err?.message ?? err?.error ?? nested?.error;
return typeof message === 'string' && message ? message : fallback;
}
async function readBootStartedAt(): Promise<number | null> {
try {
const healthRes = await fetch('/api/health');
if (!healthRes.ok) return null;
const data = await healthRes.json();
return typeof data?.startedAt === 'number' ? data.startedAt : null;
} catch {
return null;
}
}
export type UseComposeReapplyActionOptions = {
/** Refresh fleet statuses after a successful remote dispatch. */
onRemoteSuccess?: () => void;
};
/**
* Shared confirm → dispatch → reconnect workflow for compose reapply.
* Used by Fleet Node Updates and the Compose editor Save & Reapply path.
*/
export function useComposeReapplyAction(options: UseComposeReapplyActionOptions = {}) {
const { onRemoteSuccess } = options;
const onRemoteSuccessRef = useRef(onRemoteSuccess);
onRemoteSuccessRef.current = onRemoteSuccess;
const [confirmTarget, setConfirmTarget] = useState<ComposeReapplyTarget | null>(null);
const [busyNodeId, setBusyNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
const [preUpdateStartedAt, setPreUpdateStartedAt] = useState<number | null>(null);
const dispatchingRef = useRef(false);
const openConfirm = useCallback((target: ComposeReapplyTarget) => {
setConfirmTarget(target);
}, []);
const cancelConfirm = useCallback(() => {
setConfirmTarget(null);
}, []);
const runReapply = useCallback(async (target: ComposeReapplyTarget) => {
if (dispatchingRef.current) return;
dispatchingRef.current = true;
setBusyNodeId(target.nodeId);
const path = `/fleet/nodes/${target.nodeId}/reapply-compose`;
const init = { method: 'POST', localOnly: true } as const;
try {
if (target.type === 'local') {
const bootBefore = await readBootStartedAt();
const res = await apiFetch(path, init);
if (res.ok) {
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseReapplyError(err, 'Failed to trigger local compose reapply.'));
}
return;
}
const res = await apiFetch(path, init);
if (res.ok) {
toast.success(`Compose reapply initiated on ${target.name}.`);
onRemoteSuccessRef.current?.();
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseReapplyError(err, 'Failed to trigger compose reapply.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
dispatchingRef.current = false;
setBusyNodeId(null);
}
}, []);
const confirmReapply = useCallback(async () => {
const target = confirmTarget;
setConfirmTarget(null);
if (!target) return;
await runReapply(target);
}, [confirmTarget, runReapply]);
// While reconnecting, poll fleet update-status so a validation/helper failure
// before restart dismisses the overlay instead of waiting for the timeout.
useEffect(() => {
if (!reconnecting) return;
const poll = setInterval(async () => {
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (!res.ok) return;
const data = await res.json();
const nodes: NodeUpdateStatus[] = data.nodes ?? [];
const local = nodes.find(s => s.type === 'local');
if (local && (local.updateStatus === 'failed' || local.updateStatus === 'timeout')) {
setReconnecting(false);
setPreUpdateStartedAt(null);
toast.error(local.error || 'Local compose reapply failed. The server did not restart.');
onRemoteSuccessRef.current?.();
}
} catch (error) {
console.warn('[ComposeReapply] Reconnect status poll failed:', error);
}
}, 3000);
return () => clearInterval(poll);
}, [reconnecting]);
return {
confirmTarget,
openConfirm,
cancelConfirm,
confirmReapply,
runReapply,
busyNodeId,
dispatching: busyNodeId !== null,
reconnecting,
preUpdateStartedAt,
reconnectMode: 'reapply' as const,
setReconnecting,
setPreUpdateStartedAt,
};
}
@@ -3,6 +3,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { isValidVersion } from '@/lib/version';
import { PINNED_UPDATE_BLOCKED_FALLBACK, type NodeUpdateStatus } from '../types';
import { useComposeReapplyAction } from './useComposeReapplyAction';
/** POST body for an update trigger: forward the target release when it is a
* valid version so the receiving node can repin a semver pin to it; omit
@@ -28,12 +29,25 @@ function toastIfUpdateBlocked(status: NodeUpdateStatus | undefined): boolean {
return true;
}
async function readBootStartedAt(): Promise<number | null> {
try {
const healthRes = await fetch('/api/health');
if (!healthRes.ok) return null;
const data = await healthRes.json();
return typeof data?.startedAt === 'number' ? data.startedAt : null;
} catch {
// Fall back to offline-then-online detection in the reconnect overlay.
return null;
}
}
export function useFleetUpdateStatus() {
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
const [preUpdateStartedAt, setPreUpdateStartedAt] = useState<number | null>(null);
const [localUpdateConfirm, setLocalUpdateConfirm] = useState<number | null>(null);
const [reconnectMode, setReconnectMode] = useState<'update' | 'reapply'>('update');
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [checkingUpdates, setCheckingUpdates] = useState(false);
@@ -65,6 +79,69 @@ export function useFleetUpdateStatus() {
}
}, []);
const reapplyAction = useComposeReapplyAction({ onRemoteSuccess: fetchUpdateStatus });
const {
openConfirm: openReapplyConfirm,
cancelConfirm: cancelReapplyConfirm,
confirmReapply,
confirmTarget: reapplyConfirmTarget,
busyNodeId: reapplyBusyNodeId,
reconnecting: reapplyReconnecting,
preUpdateStartedAt: reapplyPreStartedAt,
} = reapplyAction;
const postRemoteAction = useCallback(async (
nodeId: number,
path: string,
init: RequestInit & { localOnly: true },
successMsg: string,
failFallback: string,
) => {
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(path, init);
if (res.ok) {
toast.success(successMsg);
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, failFallback));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [fetchUpdateStatus]);
const startLocalRestart = useCallback(async (
nodeId: number,
path: string,
init: RequestInit & { localOnly: true },
mode: 'update' | 'reapply',
failFallback: string,
) => {
setUpdatingNodeId(nodeId);
try {
// Capture pre-restart boot timestamp so the overlay can detect a real
// restart vs a false "online" response from the still-running process.
const bootBefore = await readBootStartedAt();
const res = await apiFetch(path, init);
if (res.ok) {
setReconnectMode(mode);
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, failFallback));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, []);
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
// A pin we cannot repin (digest/unknown) has no update action; the button
@@ -75,22 +152,14 @@ export function useFleetUpdateStatus() {
return;
}
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status));
if (res.ok) {
toast.success(`Update initiated on ${status?.name ?? 'node'}.`);
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, 'Failed to trigger update.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [fetchUpdateStatus]);
await postRemoteAction(
nodeId,
`/fleet/nodes/${nodeId}/update`,
updateRequestInit(status),
`Update initiated on ${status?.name ?? 'node'}.`,
'Failed to trigger update.',
);
}, [postRemoteAction]);
const confirmLocalUpdate = useCallback(async () => {
const nodeId = localUpdateConfirm;
@@ -99,35 +168,27 @@ export function useFleetUpdateStatus() {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
if (toastIfUpdateBlocked(status)) return;
setUpdatingNodeId(nodeId);
try {
// Capture pre-update boot timestamp so the overlay can detect a real restart
// vs a false "online" response from the still-running old process mid-pull.
let bootBefore: number | null = null;
try {
const healthRes = await fetch('/api/health');
if (healthRes.ok) {
const data = await healthRes.json();
if (typeof data?.startedAt === 'number') bootBefore = data.startedAt;
}
} catch { /* fall back to offline-then-online detection */ }
await startLocalRestart(
nodeId,
`/fleet/nodes/${nodeId}/update`,
updateRequestInit(status),
'update',
'Failed to trigger local update.',
);
}, [localUpdateConfirm, startLocalRestart]);
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status));
if (res.ok) {
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
// A blocked pin returns 409 fast (before any 202), so the overlay
// never starts here; surface the reason through the toast path.
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, 'Failed to trigger local update.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
const triggerNodeReapply = useCallback((nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
if (!status) {
toast.error('Node status is unavailable. Recheck updates and try again.');
return;
}
}, [localUpdateConfirm]);
openReapplyConfirm({
nodeId,
type: status.type === 'local' ? 'local' : 'remote',
name: status.name,
});
}, [openReapplyConfirm]);
const triggerUpdateAll = useCallback(async () => {
try {
@@ -176,13 +237,7 @@ export function useFleetUpdateStatus() {
setCheckingUpdates(false);
}, [fetchUpdateStatus]);
// While the reconnect overlay is up, poll the local node's update status.
// A pull/patch failure leaves the old gateway alive (no restart), so the
// overlay's health poll would sit for the full 5-minute timeout. Detecting
// the resolved `failed` status here dismisses the overlay fast and surfaces
// the error, instead of leaving the operator on the spinner. A genuine
// restart makes this endpoint unreachable (caught, keeps polling) and the
// overlay's own health poll reloads the page on success.
// Version-update reconnect failure poll (reapply uses useComposeReapplyAction).
useEffect(() => {
if (!reconnecting) return;
const poll = setInterval(async () => {
@@ -199,27 +254,37 @@ export function useFleetUpdateStatus() {
toast.error(local.error || 'Local update failed. The server did not restart.');
}
} catch (error) {
// Expected while the process restarts; the overlay's health poll
// drives the reload on success.
console.warn('[Fleet] Reconnect status poll failed:', error);
}
}, 3000);
return () => clearInterval(poll);
}, [reconnecting]);
const reapplyConfirm = reapplyConfirmTarget?.nodeId ?? null;
const setReapplyConfirm = useCallback((nodeId: number | null) => {
if (nodeId === null) cancelReapplyConfirm();
}, [cancelReapplyConfirm]);
return {
updateStatuses,
updatingNodeId,
reconnecting,
preUpdateStartedAt,
updatingNodeId: updatingNodeId ?? reapplyBusyNodeId,
// Prefer reapply reconnect when active so overlay mode stays correct.
reconnecting: reconnecting || reapplyReconnecting,
preUpdateStartedAt: reapplyReconnecting ? reapplyPreStartedAt : preUpdateStartedAt,
reconnectMode: reapplyReconnecting ? 'reapply' as const : reconnectMode,
localUpdateConfirm,
reapplyConfirm,
reapplyConfirmTarget,
showUpdateModal,
checkingUpdates,
setShowUpdateModal,
setLocalUpdateConfirm,
setReapplyConfirm,
fetchUpdateStatus,
triggerNodeUpdate,
confirmLocalUpdate,
triggerNodeReapply,
confirmReapply,
triggerUpdateAll,
dismissNodeUpdate,
retryNodeUpdate,
@@ -61,6 +61,10 @@ export interface NodeUpdateStatus {
updateBlockedReason?: string | null;
/** Coarse image channel from meta/update-status. Hardened digests still POST. */
imageChannel?: 'community' | 'hardened' | 'unknown' | null;
/** Active fleet self-management operation, when a tracker is present. */
operationKind?: 'update' | 'reapply_configuration' | null;
/** True when this Compose-managed node can reapply its on-disk configuration. */
canReapplyCompose?: boolean;
}
export type ViewMode = 'grid' | 'topology';
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ComposeDiffPreviewDialog } from '../ComposeDiffPreviewDialog';
import { resolveComposeDiffActionLabel } from '../resolveComposeDiffActionLabel';
vi.mock('@/lib/monacoLoader', () => ({
DiffEditor: () => <div data-testid="diff-editor" />,
}));
describe('resolveComposeDiffActionLabel', () => {
it('returns Save for save-only mode', () => {
expect(resolveComposeDiffActionLabel('save', false)).toBe('Save');
expect(resolveComposeDiffActionLabel('save', true)).toBe('Save');
});
it('returns Save when mode is undefined', () => {
expect(resolveComposeDiffActionLabel(undefined, true)).toBe('Save');
});
it('returns Save & deploy for ordinary save-and-deploy', () => {
expect(resolveComposeDiffActionLabel('save-and-deploy', false)).toBe('Save & deploy');
});
it('returns Save & reapply when self-stack reapply is eligible', () => {
expect(resolveComposeDiffActionLabel('save-and-deploy', true)).toBe('Save & reapply');
});
});
describe('ComposeDiffPreviewDialog', () => {
it('renders the Save & reapply confirm CTA', () => {
render(
<ComposeDiffPreviewDialog
open
onOpenChange={vi.fn()}
stackName="sencho"
fileName="docker-compose.yml"
language="yaml"
original="a"
modified="b"
actionLabel="Save & reapply"
confirming={false}
isDarkMode={false}
onConfirm={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Save & reapply' })).toBeInTheDocument();
});
});
@@ -0,0 +1,11 @@
export type ComposeDiffActionLabel = 'Save' | 'Save & deploy' | 'Save & reapply';
/** Maps diff preview mode + self-stack eligibility to the confirm CTA label. */
export function resolveComposeDiffActionLabel(
mode: 'save' | 'save-and-deploy' | undefined,
canSaveAndReapply: boolean,
): ComposeDiffActionLabel {
if (mode !== 'save-and-deploy') return 'Save';
if (canSaveAndReapply) return 'Save & reapply';
return 'Save & deploy';
}
@@ -40,8 +40,11 @@ export function SelfStackProtectedDialog({
}}
>
<p className="text-sm text-stat-subtitle">
This stack is the running Sencho instance. Use Fleet -&gt; Node Update to update Sencho.
To manage it as a normal stack, move Sencho&apos;s compose project outside COMPOSE_DIR.
This stack is the running Sencho instance. Destructive lifecycle actions
stay protected. Eligible admins can use Save &amp; Reapply in the Compose
editor, or Fleet -&gt; Node Updates, to recreate Sencho from its current
Compose configuration. To manage it as a normal stack, move Sencho&apos;s
compose project outside COMPOSE_DIR.
</p>
</ConfirmModal>
);