fix: reconcile sticky update indicators with Anatomy preview (#1698)

* fix: reconcile sticky update indicators with Anatomy preview

Sidebar, Updates filter, and Fleet treated retained partial/failed
scanner has_update as confirmed. Keep raw state for retention/notifications,
project confirmed-only to APIs, show distinct incomplete indicators, and
clear sticky rows only after an authoritative-negative preview.

Closes #1685

* test: align sidebar truncate E2E with failed-over-retained precedence

Purple update indicators are confirmed-only; hasUpdate with a failed
check correctly shows the failed trailing icon.

* fix: clear confirmed update rows on authoritative-negative preview

Address audit SF-1/SF-2/SF-3: observation-watermark clears for older
ok+has_update rows (DB + memory gens), Fleet checkability parity with
backend not_checkable, and Updates chip confirmed-only regressions.

* fix: tombstone equal-generation writers on preview clear

Advance the per-stack write generation when clearing at the observation
watermark so a scanner reserved before preview cannot recreate the row
after an authoritative-negative reconcile.

* fix: clear sticky updates with digest and tag preview parity

Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear.

* test: set digestUpdate on auto-update checkImage mocks

Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path.

* fix: clear unused lint errors on sticky update branch

Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green.

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
Anso
2026-07-25 15:42:19 -04:00
committed by GitHub
parent 8b5407fcff
commit 0daddfde00
43 changed files with 2529 additions and 390 deletions
@@ -7,6 +7,14 @@ import { toast } from '@/components/ui/toast-store';
import { apiFetch, fetchForNode } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
import { isAuthoritativeNegativePreview } from '@/types/imageUpdates';
import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview';
import {
isActionableUpdatePreview,
isPreviewUncertain,
isServiceApplyActionable,
isTagOnlyAdvisory,
} from '@/lib/updatePreviewActionability';
import { useNodes } from '@/context/NodeContext';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { Masthead, Kicker } from '@/components/mobile/mobile-ui';
@@ -24,7 +32,11 @@ interface UpdatePreviewImage {
current_tag: string;
next_tag: string | null;
has_update: boolean;
digest_update?: boolean;
tag_update?: boolean;
semver_bump: SemverBump;
/** Absent on older remotes; backend uses !== 'not_checkable' for checkability. */
check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable';
}
type UpdateKind = 'tag' | 'digest' | 'none';
@@ -43,6 +55,8 @@ interface UpdatePreview {
blocked_reason: string | null;
has_build_services?: boolean;
rebuild_available?: boolean;
/** Absent on older remotes; treat missing as non-authoritative. */
check_status?: 'ok' | 'partial' | 'failed';
};
build_services?: string[];
rollback_target: string | null;
@@ -148,7 +162,25 @@ function formatClock(ts: number | null): string {
});
}
function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) {
function RiskBadge({
bump,
blocked,
uncertain,
tagOnly,
}: {
bump: SemverBump;
blocked: boolean;
uncertain?: boolean;
tagOnly?: boolean;
}) {
if (uncertain) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
Check uncertain
</span>
);
}
if (blocked || bump === 'major') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-destructive/40 bg-destructive/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-destructive">
@@ -157,6 +189,13 @@ function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) {
</span>
);
}
if (tagOnly) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-card-border bg-muted/30 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
Newer tag · edit Compose
</span>
);
}
if (bump === 'minor') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
@@ -242,7 +281,7 @@ function StackReadinessCard({
Auto: Off
</span>
)}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
</div>
</div>
@@ -302,7 +341,7 @@ function StackReadinessCard({
variant="outline"
className="h-6 gap-1 rounded-md px-2 text-[11px]"
onClick={() => onApplyService?.(stack, nodeId, img.service)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
>
{applyingService === img.service ? 'Applying...' : 'Apply'}
</Button>
@@ -329,7 +368,7 @@ function StackReadinessCard({
<Button
size="sm"
onClick={() => onApply(stack, nodeId)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
title={blocked ? (blockedReason ?? undefined) : undefined}
className="gap-1.5"
>
@@ -502,7 +541,7 @@ export function MobileReadinessCard({
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />Auto: Off
</span>
)}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
</div>
</div>
@@ -534,7 +573,7 @@ export function MobileReadinessCard({
variant="outline"
className="h-7 gap-1 rounded-md px-2 text-[11px]"
onClick={() => onApplyService?.(stack, nodeId, img.service)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
>
{applyingService === img.service ? 'Applying...' : 'Apply'}
</Button>
@@ -550,7 +589,7 @@ export function MobileReadinessCard({
size="sm"
variant={blocked ? 'outline' : 'default'}
onClick={() => onApply(stack, nodeId)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
className="gap-1.5"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
@@ -779,9 +818,11 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
const previews = await Promise.all(
flatPairs.map(async ({ nodeId, stack }) => {
try {
const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
if (!res.ok) return null;
return await res.json() as UpdatePreview;
const result = await fetchUpdatePreview(stack, {
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
});
if (!result.ok || !result.preview) return null;
return result.preview as UpdatePreview;
} catch {
return null;
}
@@ -796,12 +837,16 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
setGroups(initialGroups.map(g => ({
...g,
cards: g.cards.map(c => ({
...c,
preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null,
previewLoaded: true,
})),
})));
cards: g.cards
.map(c => ({
...c,
preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null,
previewLoaded: true,
}))
// Drop cards whose live preview authoritatively reports no update.
// Missing check_status (older remotes) or non-ok status keeps the card.
.filter(c => !isAuthoritativeNegativePreview(c.preview)),
})).filter(g => g.cards.length > 0));
} catch (err) {
if (token !== loadTokenRef.current) return;
toast.error((err as Error)?.message || 'Failed to load readiness');
@@ -920,13 +965,25 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
}
// Reload authoritative preview so summary / Apply affordances stay accurate.
try {
const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
if (res.ok) {
const next = await res.json() as UpdatePreview;
setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true });
const result = await fetchUpdatePreview(stack, {
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
});
if (result.ok && result.preview) {
const next = result.preview as UpdatePreview;
if (isAuthoritativeNegativePreview(next)) {
setGroups(prev => prev
.map(g => g.nodeId === nodeId
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
: g)
.filter(g => g.cards.length > 0));
} else {
setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true });
}
} else {
console.error(`[AutoUpdateReadinessView] post-apply update-preview failed (${result.status})`);
}
} catch {
// Preview refresh is best-effort; the update itself already succeeded.
} catch (err) {
console.error('[AutoUpdateReadinessView] post-apply update-preview refresh failed', err);
}
return {
ok: true as const,
@@ -985,7 +1042,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
c.autoUpdateEnabled
&& c.previewLoaded
&& c.preview !== null
&& !c.preview.summary.blocked,
&& isActionableUpdatePreview(c.preview),
).length;
return { total: t, ready: r };
}, [flatCards]);