mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +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:
@@ -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>;
|
||||
|
||||
@@ -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 & 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;
|
||||
}
|
||||
@@ -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 & restart
|
||||
</>
|
||||
isReapply ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Reapply & restart
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Update & 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 -> Node Update to update Sencho.
|
||||
To manage it as a normal stack, move Sencho's compose project outside COMPOSE_DIR.
|
||||
This stack is the running Sencho instance. Destructive lifecycle actions
|
||||
stay protected. Eligible admins can use Save & Reapply in the Compose
|
||||
editor, or Fleet -> Node Updates, to recreate Sencho from its current
|
||||
Compose configuration. To manage it as a normal stack, move Sencho's
|
||||
compose project outside COMPOSE_DIR.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user