fix(fleet): verify update status before removing readiness cards (#1697)

* fix(fleet): verify update status before removing readiness cards

Full-stack Apply now rechecks persisted status after the health gate starts, reloads the live preview before dropping a card, and invalidates the hub fleet aggregation so cleared updates cannot resurrect from a stale cache.

Closes #1686

* fix(fleet): align persisted update status with preview semver detection

Share digest-plus-tag detection so post-Apply sidebar status matches Fleet and Anatomy.

* fix(fleet): keep tag-only updates advisory for Compose automation

Expose digestUpdate vs tagUpdate from checkImage so scheduled and API auto-update only apply same-tag digest drift Compose can pull.

* docs: clarify scheduled auto-update applies digest drift only

Document that higher pinned tags stay advisory until Compose is changed, matching schedule and Run Now behavior.

* docs: require Compose pin edits for higher-tag advisories

Stop recommending Apply now or Update as remedies that cannot rewrite a pinned image tag.

* docs: clarify Apply now pulls pinned tags only

Align the detection-cadence bullet with digest-rebuild vs higher-tag guidance.

* fix(fleet): keep tag advisories after apply and scheduled updates

Tag-only previews were treated as cleared on Fleet reload, and scheduled/
Run Now paths wiped status without rechecking. Align post-update verification
with the manual Apply path (health gate first, recheck, no blind clear) and
block digest apply when sibling image checks failed.

* fix(fleet): clear eslint unused-arg and containers assignment
This commit is contained in:
Anso
2026-07-26 03:09:21 -04:00
committed by GitHub
parent bb7c76ba46
commit 719180f156
16 changed files with 1320 additions and 74 deletions
@@ -100,6 +100,9 @@ export interface StackCard {
// Name of the service currently applying a per-service update on this card,
// or null when none is in flight. Distinct from `applying` (full-stack).
applyingService: string | null;
// Post-Apply verification note when Compose succeeded but clearance could
// not be confirmed (distinct from a failed preview fetch).
verificationNote: string | null;
}
interface NodeGroup {
@@ -275,9 +278,10 @@ function StackReadinessCard({
onApply: (stack: string, nodeId: number) => void;
onApplyService?: (stack: string, nodeId: number, serviceName: string) => void;
}) {
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled } = card;
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled, verificationNote } = card;
const loading = !previewLoaded;
const failed = previewLoaded && preview === null;
const uncertain = previewLoaded && !!verificationNote;
const failed = previewLoaded && preview === null && !verificationNote;
const blocked = preview?.summary.blocked ?? false;
const bump = preview?.summary.semver_bump ?? 'none';
const updatingImages = preview?.images.filter(i => i.has_update) ?? [];
@@ -328,6 +332,10 @@ function StackReadinessCard({
{loading ? (
<div className="font-mono text-xs text-stat-subtitle/80">Checking registry...</div>
) : uncertain ? (
<div className="font-mono text-xs text-warning">
{verificationNote}
</div>
) : failed ? (
<div className="font-mono text-xs text-destructive/80">
Preview failed. Registry may be unreachable.
@@ -584,8 +592,9 @@ export function MobileReadinessCard({
onApply: (stack: string, nodeId: number) => void;
onApplyService?: (stack: string, nodeId: number, serviceName: string) => void;
}) {
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled } = card;
const failed = previewLoaded && preview === null;
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, applyingService, autoUpdateEnabled, verificationNote } = card;
const uncertain = previewLoaded && !!verificationNote;
const failed = previewLoaded && preview === null && !verificationNote;
const blocked = preview?.summary.blocked ?? false;
const bump = preview?.summary.semver_bump ?? 'none';
const updatingImages = preview?.images.filter(i => i.has_update) ?? [];
@@ -628,6 +637,8 @@ export function MobileReadinessCard({
{!previewLoaded ? (
<div className="font-mono text-xs text-stat-subtitle/80">Checking registry...</div>
) : uncertain ? (
<div className="font-mono text-xs text-warning">{verificationNote}</div>
) : failed ? (
<div className="font-mono text-xs text-destructive/80">Preview failed. Registry may be unreachable.</div>
) : (() => {
@@ -901,6 +912,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
applying: false,
applyingService: null,
autoUpdateEnabled: scheduledTask !== null,
verificationNote: null,
};
});
initialGroups.push({
@@ -1148,8 +1160,26 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
...g,
cards: g.cards.map(c => predicate(c) ? { ...c, ...patch } : c),
})));
const matchCard = (c: StackCard) => c.stack === stack && c.nodeId === nodeId;
const removeCard = () => setGroups(prev => prev
.map(g => g.nodeId === nodeId
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
: g)
.filter(g => g.cards.length > 0));
const retainPreviewFailed = () => setCardField(matchCard, {
applying: false,
preview: null,
previewLoaded: true,
verificationNote: null,
});
const retainUncertain = (note: string) => setCardField(matchCard, {
applying: false,
preview: null,
previewLoaded: true,
verificationNote: note,
});
setCardField(c => c.stack === stack && c.nodeId === nodeId, { applying: true });
setCardField(matchCard, { applying: true, verificationNote: null });
const loadingId = toast.loading(`Applying update to ${stack}...`);
try {
const res = await fetchForNode(
@@ -1161,15 +1191,57 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
const data = await res.json().catch(() => ({ error: 'Update failed' }));
throw new Error(data.error ?? 'Update failed');
}
toast.success(`${stack} updated successfully`);
setGroups(prev => prev
.map(g => g.nodeId === nodeId
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
: g)
.filter(g => g.cards.length > 0));
const body = await res.json().catch(() => ({})) as { recheckWarning?: unknown };
const recheckWarning = typeof body.recheckWarning === 'string' ? body.recheckWarning : undefined;
if (recheckWarning) toast.info(recheckWarning);
else toast.success(`${stack} updated successfully`);
// Authoritative live preview decides card removal. When it disagrees with
// a backend recheckWarning (preview cleared, persisted check uncertain),
// keep an uncertain card so Fleet does not diverge from the sidebar.
try {
const previewRes = await fetchForNode(
`/stacks/${encodeURIComponent(stack)}/update-preview`,
nodeId,
);
if (!previewRes.ok) {
retainPreviewFailed();
return;
}
const next = await previewRes.json() as UpdatePreview;
if (typeof next?.summary?.has_update !== 'boolean') {
retainPreviewFailed();
return;
}
// Drop only when the live preview proves nothing remains (tag-only
// advisories stay pending via isClearedUpdatePreview).
const cleared = isAuthoritativeNegativePreview(next) || isClearedUpdatePreview(next);
if (!cleared) {
if (next.summary.has_update && !recheckWarning) {
toast.info(
'The update command completed, but Sencho still detects an available image update.',
);
}
setCardField(matchCard, {
applying: false,
preview: next,
previewLoaded: true,
verificationNote: recheckWarning ?? null,
});
return;
}
if (recheckWarning) {
retainUncertain(recheckWarning);
return;
}
removeCard();
} catch (previewErr) {
console.error('[AutoUpdate] post-Apply preview reconciliation failed', previewErr);
retainPreviewFailed();
}
} catch (err) {
toast.error((err as Error)?.message || 'Update failed');
setCardField(c => c.stack === stack && c.nodeId === nodeId, { applying: false });
setCardField(matchCard, { applying: false });
} finally {
toast.dismiss(loadingId);
}