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
@@ -32,22 +32,26 @@ function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
service: 'web',
image: 'nginx:1.25',
current_tag: '1.25',
next_tag: hasUpdate ? '1.26' : null,
next_tag: '1.25',
has_update: hasUpdate,
semver_bump: hasUpdate ? 'minor' : 'none',
digest_update: hasUpdate,
tag_update: false,
semver_bump: hasUpdate ? 'patch' : 'none',
check_status: 'ok',
},
],
summary: {
has_update: hasUpdate,
primary_image: 'nginx',
current_tag: '1.25',
next_tag: '1.26',
semver_bump: 'minor',
update_kind: hasUpdate ? 'tag' : 'none',
next_tag: '1.25',
semver_bump: hasUpdate ? 'patch' : 'none',
update_kind: hasUpdate ? 'digest' : 'none',
blocked: false,
blocked_reason: null,
has_build_services: hasBuild,
rebuild_available: hasBuild,
check_status: 'ok',
},
changelog: null,
};
@@ -118,6 +122,33 @@ describe('StackAnatomyPanel edit affordance', () => {
});
describe('StackAnatomyPanel update banner', () => {
it('hides apply when only a newer tag is available', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes({
build_services: [],
images: [{
service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26',
has_update: true, digest_update: false, tag_update: true, semver_bump: 'minor', check_status: 'ok',
}],
summary: {
has_update: true, primary_image: 'nginx', current_tag: '1.25', next_tag: '1.26',
semver_bump: 'minor', update_kind: 'tag', blocked: false, blocked_reason: null,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
changelog: null,
});
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
await waitFor(() => expect(screen.getByTestId('update-available-banner')).toBeInTheDocument());
expect(screen.getByText((t) => typeof t === 'string' && t.includes('newer tag') && t.includes('edit Compose pin'))).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'apply' })).not.toBeInTheDocument();
});
it('shows the apply button and fires onApplyUpdate when clicked', async () => {
const onApply = vi.fn();
render(panel(false, onApply));
@@ -135,9 +166,9 @@ describe('StackAnatomyPanel update banner', () => {
return jsonRes({
build_services: [],
images: [
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26', has_update: true, semver_bump: 'minor' },
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.4', has_update: true, semver_bump: 'minor' },
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, semver_bump: 'none' },
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.2', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, digest_update: false, tag_update: false, semver_bump: 'none', check_status: 'ok' },
],
summary: {
has_update: true,