mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user