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
+68
View File
@@ -0,0 +1,68 @@
import { apiFetch } from './api';
export interface FetchUpdatePreviewOptions {
/** When true (default), POST to reconcile sticky state. Falls back to GET on 404/405. */
reconcile?: boolean;
/** Optional node-scoped fetch (Fleet). When omitted, uses hub apiFetch. */
fetchImpl?: (path: string, init?: RequestInit) => Promise<Response>;
}
export interface FetchUpdatePreviewResult {
ok: boolean;
status: number;
preview: unknown | null;
/** True only when POST succeeded and the body reported reconciled. */
reconciled: boolean;
/** True when POST was unsupported and GET was used instead. */
usedGetFallback: boolean;
}
async function asPreviewResult(
res: Response,
opts: { usedGetFallback: boolean; readReconciledFlag: boolean },
): Promise<FetchUpdatePreviewResult> {
if (!res.ok) {
return {
ok: false,
status: res.status,
preview: null,
reconciled: false,
usedGetFallback: opts.usedGetFallback,
};
}
const body = await res.json() as { reconciled?: unknown };
return {
ok: true,
status: res.status,
preview: body,
reconciled: opts.readReconciledFlag && body.reconciled === true,
usedGetFallback: opts.usedGetFallback,
};
}
/**
* Prefer POST /stacks/:name/update-preview (reconcile). On 404/405 only, fall
* back to GET and treat as not reconciled. Ordinary 5xx / network errors do
* not fall back.
*/
export async function fetchUpdatePreview(
stackName: string,
options: FetchUpdatePreviewOptions = {},
): Promise<FetchUpdatePreviewResult> {
const reconcile = options.reconcile !== false;
const fetchImpl = options.fetchImpl ?? ((path: string, init?: RequestInit) => apiFetch(path, init));
const path = `/stacks/${encodeURIComponent(stackName)}/update-preview`;
if (!reconcile) {
const res = await fetchImpl(path, { method: 'GET' });
return asPreviewResult(res, { usedGetFallback: false, readReconciledFlag: false });
}
const postRes = await fetchImpl(path, { method: 'POST' });
if (postRes.status === 404 || postRes.status === 405) {
const getRes = await fetchImpl(path, { method: 'GET' });
return asPreviewResult(getRes, { usedGetFallback: true, readReconciledFlag: false });
}
return asPreviewResult(postRes, { usedGetFallback: false, readReconciledFlag: true });
}
@@ -0,0 +1,91 @@
/**
* Shared Fleet/Anatomy gates for update-preview summaries.
* Tag-only availability is advisory: Compose pull does not rewrite pins.
*/
export interface UpdatePreviewActionImage {
service?: string;
has_update?: boolean;
digest_update?: boolean;
tag_update?: boolean;
check_status?: string | null;
}
export interface UpdatePreviewActionSummary {
has_update?: boolean;
rebuild_available?: boolean;
blocked?: boolean;
check_status?: string | null;
/** Present on current nodes; used for older remotes without digest/tag flags. */
update_kind?: string | null;
}
export interface UpdatePreviewActionInput {
images?: UpdatePreviewActionImage[];
summary: UpdatePreviewActionSummary;
}
function summaryCheckOk(summary: UpdatePreviewActionSummary): boolean {
return (summary.check_status ?? 'ok') === 'ok';
}
function imageParityFlagsPresent(images: UpdatePreviewActionImage[]): boolean {
return images.some((i) => i.digest_update !== undefined || i.tag_update !== undefined);
}
/** True when the stack has a Compose-executable update (digest drift or rebuild). */
export function hasExecutableUpdate(preview: UpdatePreviewActionInput | null | undefined): boolean {
if (!preview) return false;
if (preview.summary.rebuild_available) return true;
const images = preview.images ?? [];
if (images.some((i) => i.digest_update === true)) return true;
// Older remotes omit digest_update/tag_update; fall back to update_kind.
if (!imageParityFlagsPresent(images)) {
return Boolean(preview.summary.has_update && preview.summary.update_kind !== 'tag');
}
return false;
}
/** True when only newer tags exist (no digest/rebuild action Compose can apply). */
export function isTagOnlyAdvisory(preview: UpdatePreviewActionInput | null | undefined): boolean {
if (!preview?.summary.has_update) return false;
if (preview.summary.rebuild_available) return false;
const images = preview.images ?? [];
if (images.some((i) => i.digest_update === true)) return false;
if (images.some((i) => i.tag_update === true)) return true;
if (!imageParityFlagsPresent(images)) {
return preview.summary.update_kind === 'tag';
}
return images.some((i) => i.has_update === true);
}
/** Confirmed update or rebuild that may be applied from Fleet. */
export function isActionableUpdatePreview(
preview: UpdatePreviewActionInput | null | undefined,
): boolean {
if (!preview) return false;
if (preview.summary.blocked) return false;
if (!summaryCheckOk(preview.summary)) return false;
return hasExecutableUpdate(preview);
}
/** Per-service Apply: digest update for that service. */
export function isServiceApplyActionable(
preview: UpdatePreviewActionInput | null | undefined,
serviceName: string,
): boolean {
if (!preview) return false;
if (preview.summary.blocked) return false;
if (!summaryCheckOk(preview.summary)) return false;
const match = (preview.images ?? []).find((i) => i.service === serviceName);
if (!match) return false;
if (match.digest_update === true) return true;
if (match.digest_update !== undefined || match.tag_update !== undefined) return false;
return Boolean(match.has_update && preview.summary.update_kind !== 'tag');
}
export function isPreviewUncertain(preview: UpdatePreviewActionInput | null | undefined): boolean {
if (!preview) return false;
const status = preview.summary.check_status;
return status === 'partial' || status === 'failed';
}