mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
fix: repin semver compose tags during fleet self-update (#1587)
* fix: repin semver compose tags during fleet self-update Fleet updates failed when docker-compose.yml pinned a semver tag because recreate reused the on-disk pin. Pull the target image first, rewrite semver pins via the update helper, and block digest or unresolved pins with fast 409s. * fix: update OFFLINE_META shape in capability and node-registry meta tests
This commit is contained in:
@@ -58,6 +58,10 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
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)
|
||||
: undefined;
|
||||
const topology = useTopologyPreferences();
|
||||
const { exporting, exportDossier } = useFleetDossierExport();
|
||||
|
||||
@@ -318,6 +322,10 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
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}
|
||||
/>
|
||||
|
||||
{NodeActionModals}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import type { ImagePinKind } from './types';
|
||||
|
||||
interface LocalUpdateConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
imagePinKind?: ImagePinKind | null;
|
||||
composeImageRef?: string | null;
|
||||
targetImageRef?: string | null;
|
||||
targetVersion?: string | null;
|
||||
}
|
||||
|
||||
export function LocalUpdateConfirmDialog({ open, onOpenChange, onConfirm }: LocalUpdateConfirmDialogProps) {
|
||||
export function LocalUpdateConfirmDialog({
|
||||
open, onOpenChange, onConfirm, imagePinKind, composeImageRef, targetImageRef, targetVersion,
|
||||
}: LocalUpdateConfirmDialogProps) {
|
||||
const versionLabel = formatVersion(targetVersion) ?? 'the latest release';
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
@@ -22,9 +31,16 @@ export function LocalUpdateConfirmDialog({ open, onOpenChange, onConfirm }: Loca
|
||||
}
|
||||
onConfirm={onConfirm}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Pulls the latest Sencho image and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
|
||||
</p>
|
||||
{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>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes, type Node } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
import { PinnedUpdateBadge } from './PinnedUpdateBadge';
|
||||
import { StackSection } from './NodeCardStackList';
|
||||
import type { Label as StackLabel } from '../label-types';
|
||||
import type { FleetNode, NodeUpdateStatus } from './types';
|
||||
@@ -227,11 +228,14 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
|
||||
/>
|
||||
)}
|
||||
{updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && (
|
||||
{updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
|
||||
Update available
|
||||
</Badge>
|
||||
)}
|
||||
{updateStatus?.updateBlocked && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && (
|
||||
<PinnedUpdateBadge reason={updateStatus.updateBlockedReason} />
|
||||
)}
|
||||
{updateStatus?.skipActive && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-muted text-muted-foreground border-card-border/40 shrink-0">
|
||||
Skipped
|
||||
@@ -310,7 +314,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
)}
|
||||
|
||||
{/* Update button (mutating action: admin only, matches the requireAdmin route guard) */}
|
||||
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && onUpdate && isAdmin && (
|
||||
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && onUpdate && isAdmin && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatVersion, isValidVersion } from '@/lib/version';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
import { PinnedUpdateBadge } from './PinnedUpdateBadge';
|
||||
import type { NodeUpdateStatus } from './types';
|
||||
|
||||
interface NodeUpdatesSheetProps {
|
||||
@@ -434,7 +435,13 @@ export function NodeUpdatesSheet({
|
||||
Unskip
|
||||
</Button>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && !s.skipActive && isAdmin && (
|
||||
{s.updateBlocked && s.updateAvailable && !s.updateStatus && !s.skipActive && (
|
||||
<PinnedUpdateBadge
|
||||
reason={s.updateBlockedReason}
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40"
|
||||
/>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && !s.skipActive && !s.updateBlocked && isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -460,7 +467,7 @@ export function NodeUpdatesSheet({
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
{s.updateAvailable && !s.updateStatus && !s.skipActive && !isAdmin && (
|
||||
{s.updateAvailable && !s.updateStatus && !s.skipActive && !s.updateBlocked && !isAdmin && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
|
||||
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
|
||||
</Badge>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Ban } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PINNED_UPDATE_BLOCKED_FALLBACK } from './types';
|
||||
|
||||
interface PinnedUpdateBadgeProps {
|
||||
reason?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PinnedUpdateBadge({
|
||||
reason,
|
||||
className = 'text-[10px] px-1.5 py-0 h-4 bg-muted text-muted-foreground border-card-border/40 shrink-0',
|
||||
}: PinnedUpdateBadgeProps) {
|
||||
return (
|
||||
<Badge className={className} title={reason ?? PINNED_UPDATE_BLOCKED_FALLBACK}>
|
||||
<Ban className="w-2.5 h-2.5 mr-0.5" strokeWidth={1.5} /> Pinned
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ 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>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
The server is pulling the latest image 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>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/components/ui/modal', () => ({
|
||||
ConfirmModal: ({ open, title, children, confirmLabel }: {
|
||||
open: boolean; title: string; children: React.ReactNode; confirmLabel: React.ReactNode;
|
||||
}) => open ? (
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
<button type="button">{confirmLabel}</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { LocalUpdateConfirmDialog } from '../LocalUpdateConfirmDialog';
|
||||
|
||||
describe('LocalUpdateConfirmDialog', () => {
|
||||
it('explains semver repinning when compose and target refs are known', () => {
|
||||
render(
|
||||
<LocalUpdateConfirmDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
imagePinKind="semver"
|
||||
composeImageRef="saelix/sencho:0.93.3"
|
||||
targetImageRef="saelix/sencho:0.94.0"
|
||||
targetVersion="0.94.0"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/rewrites it to/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('saelix/sencho:0.93.3')).toBeInTheDocument();
|
||||
expect(screen.getByText('saelix/sencho:0.94.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the generic pull copy for a floating tag', () => {
|
||||
render(
|
||||
<LocalUpdateConfirmDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
imagePinKind="floating"
|
||||
targetVersion="0.94.0"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Pulls Sencho v0\.94\.0/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -116,10 +116,16 @@ describe('NodeCard', () => {
|
||||
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the update button for a non-admin but still shows the read-only badge', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||
it('hides the update button and shows Pinned when updateBlocked', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
render(
|
||||
<NodeCard
|
||||
{...baseProps(onlineNode())}
|
||||
updateStatus={{ ...updateAvailableStatus, updateBlocked: true, updateBlockedReason: 'Digest pin.' }}
|
||||
onUpdate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Update available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,6 +336,17 @@ describe('NodeUpdatesSheet', () => {
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides the Update button and shows a Pinned badge when updateBlocked', () => {
|
||||
const blocked: NodeUpdateStatus = {
|
||||
...STATUSES[1],
|
||||
updateBlocked: true,
|
||||
updateBlockedReason: 'Digest pin blocks automatic update.',
|
||||
};
|
||||
render(<NodeUpdatesSheet {...baseProps({ updateStatuses: [STATUSES[0], blocked, STATUSES[2]] })} />);
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides every mutating affordance for a non-admin but keeps the read-only table', () => {
|
||||
render(<NodeUpdatesSheet {...baseProps({ isAdmin: false })} />);
|
||||
// Read-only status remains visible
|
||||
|
||||
@@ -24,6 +24,11 @@ const STATUSES: NodeUpdateStatus[] = [
|
||||
{ nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
|
||||
];
|
||||
|
||||
const BLOCKED_STATUS: NodeUpdateStatus = {
|
||||
nodeId: 3, name: 'Pinned', type: 'remote', version: '1.0.0', latestVersion: '1.1.0',
|
||||
updateAvailable: true, updateStatus: null, updateBlocked: true, updateBlockedReason: 'Digest pin blocks update.',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
toastSuccess.mockReset();
|
||||
@@ -142,6 +147,78 @@ describe('useFleetUpdateStatus', () => {
|
||||
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('2 nodes'));
|
||||
});
|
||||
|
||||
it('triggerNodeUpdate on a blocked node toasts and does not POST', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: [...STATUSES, BLOCKED_STATUS] }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
apiFetchMock.mockClear();
|
||||
|
||||
await act(async () => { await result.current.triggerNodeUpdate(3); });
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Digest pin blocks update.');
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirmLocalUpdate forwards targetVersion when latestVersion is valid', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
|
||||
await act(async () => { await result.current.triggerNodeUpdate(1); });
|
||||
expect(result.current.localUpdateConfirm).toBe(1);
|
||||
|
||||
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.confirmLocalUpdate(); });
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith(
|
||||
'/fleet/nodes/1/update',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ targetVersion: '1.1.0' }),
|
||||
}),
|
||||
);
|
||||
expect(result.current.reconnecting).toBe(true);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('dismisses the reconnecting overlay when the local update resolves failed', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
await act(async () => { await result.current.fetchUpdateStatus(); });
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
|
||||
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)));
|
||||
apiFetchMock.mockResolvedValueOnce(okJson({ message: 'ok' }));
|
||||
|
||||
await act(async () => { await result.current.triggerNodeUpdate(1); });
|
||||
await act(async () => { await result.current.confirmLocalUpdate(); });
|
||||
expect(result.current.reconnecting).toBe(true);
|
||||
|
||||
const failedLocal = {
|
||||
...STATUSES[0],
|
||||
updateStatus: 'failed' as const,
|
||||
error: 'Pull failed',
|
||||
};
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: [failedLocal, STATUSES[1]] }));
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
|
||||
|
||||
expect(result.current.reconnecting).toBe(false);
|
||||
expect(toastError).toHaveBeenCalledWith('Pull failed');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('checkUpdates opens the modal and toggles the checking flag', async () => {
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
|
||||
const { result } = renderHook(() => useFleetUpdateStatus());
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import type { NodeUpdateStatus } from '../types';
|
||||
import { isValidVersion } from '@/lib/version';
|
||||
import { PINNED_UPDATE_BLOCKED_FALLBACK, type NodeUpdateStatus } from '../types';
|
||||
|
||||
/** 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
|
||||
* otherwise so the backend falls back to its compare target. */
|
||||
function updateRequestInit(status: NodeUpdateStatus | undefined): RequestInit & { localOnly: true } {
|
||||
const base = { method: 'POST', localOnly: true } as const;
|
||||
return isValidVersion(status?.latestVersion)
|
||||
? { ...base, body: JSON.stringify({ targetVersion: status!.latestVersion }) }
|
||||
: base;
|
||||
}
|
||||
|
||||
function parseUpdateError(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;
|
||||
}
|
||||
|
||||
function toastIfUpdateBlocked(status: NodeUpdateStatus | undefined): boolean {
|
||||
if (!status?.updateBlocked) return false;
|
||||
toast.error(status.updateBlockedReason ?? PINNED_UPDATE_BLOCKED_FALLBACK);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useFleetUpdateStatus() {
|
||||
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
|
||||
@@ -42,6 +65,9 @@ export function useFleetUpdateStatus() {
|
||||
|
||||
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
|
||||
// is disabled upstream, but guard here so a stale click cannot POST.
|
||||
if (toastIfUpdateBlocked(status)) return;
|
||||
if (status?.type === 'local') {
|
||||
setLocalUpdateConfirm(nodeId);
|
||||
return;
|
||||
@@ -49,13 +75,13 @@ export function useFleetUpdateStatus() {
|
||||
|
||||
setUpdatingNodeId(nodeId);
|
||||
try {
|
||||
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
|
||||
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(err?.message || err?.error || err?.data?.error || 'Failed to trigger update.');
|
||||
toast.error(parseUpdateError(err, 'Failed to trigger update.'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
@@ -68,6 +94,8 @@ export function useFleetUpdateStatus() {
|
||||
const nodeId = localUpdateConfirm;
|
||||
setLocalUpdateConfirm(null);
|
||||
if (!nodeId) return;
|
||||
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
|
||||
if (toastIfUpdateBlocked(status)) return;
|
||||
|
||||
setUpdatingNodeId(nodeId);
|
||||
try {
|
||||
@@ -82,13 +110,15 @@ export function useFleetUpdateStatus() {
|
||||
}
|
||||
} catch { /* fall back to offline-then-online detection */ }
|
||||
|
||||
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
|
||||
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(err?.message || err?.error || err?.data?.error || 'Failed to trigger local update.');
|
||||
toast.error(parseUpdateError(err, 'Failed to trigger local update.'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
@@ -110,7 +140,7 @@ export function useFleetUpdateStatus() {
|
||||
fetchUpdateStatus();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger fleet update.');
|
||||
toast.error(parseUpdateError(err, 'Failed to trigger fleet update.'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
@@ -137,6 +167,37 @@ 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.
|
||||
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 ?? [];
|
||||
setUpdateStatuses(prev => JSON.stringify(prev) === JSON.stringify(nodes) ? prev : 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 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]);
|
||||
|
||||
return {
|
||||
updateStatuses,
|
||||
updatingNodeId,
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface FleetNode {
|
||||
pilot_last_seen?: number | null;
|
||||
}
|
||||
|
||||
export type ImagePinKind = 'floating' | 'semver' | 'digest' | 'unknown';
|
||||
|
||||
/** Shown when the backend omits a node-specific block reason. */
|
||||
export const PINNED_UPDATE_BLOCKED_FALLBACK =
|
||||
'This node cannot be updated automatically while its image is pinned this way.';
|
||||
|
||||
export interface NodeUpdateStatus {
|
||||
nodeId: number;
|
||||
name: string;
|
||||
@@ -42,6 +48,17 @@ export interface NodeUpdateStatus {
|
||||
error?: string | null;
|
||||
skipActive?: boolean;
|
||||
skippedVersion?: string | null;
|
||||
/** How this node's Sencho image is pinned. Present for the local node and,
|
||||
* as the safe subset, for remotes that advertise it; null/absent otherwise. */
|
||||
imagePinKind?: ImagePinKind | null;
|
||||
/** The compose-declared image ref. Local node only (authenticated route). */
|
||||
composeImageRef?: string | null;
|
||||
/** The ref a semver pin will be rewritten to. Local node only. */
|
||||
targetImageRef?: string | null;
|
||||
/** True when the pin (digest/unknown) cannot be updated automatically. */
|
||||
updateBlocked?: boolean;
|
||||
/** Human-readable block reason. Local node only. */
|
||||
updateBlockedReason?: string | null;
|
||||
}
|
||||
|
||||
export type ViewMode = 'grid' | 'topology';
|
||||
|
||||
Reference in New Issue
Block a user