mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
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:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user