From 4fa532530e857d3d09e9c55b579b5b604126b2de Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 6 Aug 2026 09:22:53 -0400 Subject: [PATCH] fix(image-updates): explain persistent digest rebuilds after update (#1784) * fix(image-updates): explain persistent digest rebuilds after update When an update completes but a same-tag digest rebuild is still detected, the generic "update still detected" warning told operators nothing about why. The digest comparison already knows the remaining updates are digest-only (no higher tag), so recheckStack now returns a targeted warning naming the two daemon-side causes: a registry mirror or cache serving stale content, or a container still pinned to the previous image. The digest-rebuild badge surfaces (Anatomy banner, Fleet cards, mobile) now carry a tooltip with the same explanation, and the post-update warning is added to the pre-update refresh sanitization set. * fix(image-updates): surface digest warnings on editor and mobile paths Editor Update discarded recheckWarning, digest hints were hover-only, and service-scoped rechecks blamed the daemon when only sibling services remained stale. --- .../__tests__/image-update-service.test.ts | 137 +++++++++++++++++- .../stack-update-post-recheck.test.ts | 19 +++ backend/src/services/ImageUpdateService.ts | 50 ++++++- .../src/services/StackUpdateOrchestrator.ts | 8 +- docs/features/auto-update-policies.mdx | 3 + .../components/AutoUpdateReadinessView.tsx | 13 +- frontend/src/components/DigestRebuildHint.tsx | 41 ++++++ .../hooks/useStackActions.test.ts | 9 ++ .../EditorLayout/hooks/useStackActions.ts | 55 +++++-- .../src/components/StackAnatomyPanel.test.tsx | 6 +- frontend/src/components/StackAnatomyPanel.tsx | 22 ++- .../AutoUpdateReadinessView.test.tsx | 12 +- .../src/lib/updatePreviewActionability.ts | 5 + 13 files changed, 346 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/DigestRebuildHint.tsx diff --git a/backend/src/__tests__/image-update-service.test.ts b/backend/src/__tests__/image-update-service.test.ts index c6b9dc15..4c98d84c 100644 --- a/backend/src/__tests__/image-update-service.test.ts +++ b/backend/src/__tests__/image-update-service.test.ts @@ -153,7 +153,12 @@ vi.mock('../services/registry-api', async (importOriginal) => { // For this test we re-implement the function signatures to test via the // public checkImage method (which calls parseImageRef internally). -import { ImageUpdateService } from '../services/ImageUpdateService'; +import { + ImageUpdateService, + UPDATE_DIGEST_UNCHANGED_WARNING, + UPDATE_STILL_PRESENT_WARNING, + otherServicesStillPresentWarning, +} from '../services/ImageUpdateService'; import YAML from 'yaml'; // ── parseImageRef (tested indirectly via checkImage) ────────────────── @@ -1807,6 +1812,136 @@ services: ); }); + it('returns the digest-unchanged warning when every still-present update is a same-tag digest rebuild', async () => { + mockBuildEffectiveServiceModel.mockResolvedValueOnce({ + renderable: true, + services: [specFor('web', 'web:latest')], + }); + mockGetAllContainers.mockResolvedValue([ + { Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } }, + ]); + const service = ImageUpdateService.getInstance(); + (service as any).checkImage = vi.fn().mockResolvedValue({ + hasUpdate: true, + digestUpdate: true, + tagUpdate: false, + checkStatus: 'ok', + }); + + const result = await service.recheckStack(1, 'stackA'); + + expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_DIGEST_UNCHANGED_WARNING }); + }); + + it('names sibling services when a service-scoped recheck cleared the target but siblings remain', async () => { + mockBuildEffectiveServiceModel.mockResolvedValueOnce({ + renderable: true, + services: [specFor('web', 'web:latest'), specFor('worker', 'worker:latest')], + }); + mockGetAllContainers.mockResolvedValue([ + { Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } }, + { Id: 'c2', Image: 'worker:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'worker' } }, + ]); + const service = ImageUpdateService.getInstance(); + (service as any).checkImage = vi.fn().mockImplementation(async (_docker: unknown, ref: string) => ( + ref === 'worker:latest' + ? { hasUpdate: true, digestUpdate: true, tagUpdate: false, checkStatus: 'ok' } + : { hasUpdate: false, checkStatus: 'ok' } + )); + + const result = await service.recheckStack(1, 'stackA', { updatedService: 'web' }); + + expect(result).toEqual({ + outcome: 'still_present', + warning: otherServicesStillPresentWarning('web', ['worker']), + }); + expect(result.warning).not.toBe(UPDATE_DIGEST_UNCHANGED_WARNING); + }); + + it('keeps the digest-unchanged warning when the updated service itself is still digest-stale', async () => { + mockBuildEffectiveServiceModel.mockResolvedValueOnce({ + renderable: true, + services: [specFor('web', 'web:latest'), specFor('worker', 'worker:latest')], + }); + mockGetAllContainers.mockResolvedValue([ + { Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } }, + { Id: 'c2', Image: 'worker:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'worker' } }, + ]); + const service = ImageUpdateService.getInstance(); + (service as any).checkImage = vi.fn().mockImplementation(async (_docker: unknown, ref: string) => ( + ref === 'web:latest' + ? { hasUpdate: true, digestUpdate: true, tagUpdate: false, checkStatus: 'ok' } + : { hasUpdate: false, checkStatus: 'ok' } + )); + + const result = await service.recheckStack(1, 'stackA', { updatedService: 'web' }); + + expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_DIGEST_UNCHANGED_WARNING }); + }); + + it('returns the generic warning when one still-present update is a digest rebuild and another is a tag bump', async () => { + mockBuildEffectiveServiceModel.mockResolvedValueOnce({ + renderable: true, + services: [specFor('web', 'web:latest'), specFor('worker', 'worker:latest')], + }); + mockGetAllContainers.mockResolvedValue([ + { Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } }, + { Id: 'c2', Image: 'worker:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'worker' } }, + ]); + const service = ImageUpdateService.getInstance(); + (service as any).checkImage = vi.fn().mockImplementation(async (_docker: unknown, ref: string) => ( + ref === 'web:latest' + ? { hasUpdate: true, digestUpdate: true, tagUpdate: false, checkStatus: 'ok' } + : { hasUpdate: true, digestUpdate: false, tagUpdate: true, checkStatus: 'ok' } + )); + + const result = await service.recheckStack(1, 'stackA'); + + expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_STILL_PRESENT_WARNING }); + }); + + it('returns the generic warning when a single image has both a digest drift and a newer tag', async () => { + mockBuildEffectiveServiceModel.mockResolvedValueOnce({ + renderable: true, + services: [specFor('web', 'web:latest')], + }); + mockGetAllContainers.mockResolvedValue([ + { Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } }, + ]); + const service = ImageUpdateService.getInstance(); + (service as any).checkImage = vi.fn().mockResolvedValue({ + hasUpdate: true, + digestUpdate: true, + tagUpdate: true, + checkStatus: 'ok', + }); + + const result = await service.recheckStack(1, 'stackA'); + + expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_STILL_PRESENT_WARNING }); + }); + + it('returns the generic warning when the still-present update is a tag bump, not a digest-only rebuild', async () => { + mockBuildEffectiveServiceModel.mockResolvedValueOnce({ + renderable: true, + services: [specFor('web', 'web:latest')], + }); + mockGetAllContainers.mockResolvedValue([ + { Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } }, + ]); + const service = ImageUpdateService.getInstance(); + (service as any).checkImage = vi.fn().mockResolvedValue({ + hasUpdate: true, + digestUpdate: false, + tagUpdate: true, + checkStatus: 'ok', + }); + + const result = await service.recheckStack(1, 'stackA'); + + expect(result).toEqual({ outcome: 'still_present', warning: UPDATE_STILL_PRESENT_WARNING }); + }); + it('returns cleared when every checkable service is up to date', async () => { mockBuildEffectiveServiceModel.mockResolvedValueOnce({ renderable: true, diff --git a/backend/src/__tests__/stack-update-post-recheck.test.ts b/backend/src/__tests__/stack-update-post-recheck.test.ts index 81cbd9ef..8a21c3e2 100644 --- a/backend/src/__tests__/stack-update-post-recheck.test.ts +++ b/backend/src/__tests__/stack-update-post-recheck.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { UPDATE_DIGEST_UNCHANGED_WARNING } from '../services/ImageUpdateService'; const { mockExecute, @@ -166,6 +167,24 @@ describe('POST /api/stacks/:name/update post-compose verification', () => { ); }); + it('surfaces the digest-unchanged warning when the image digest did not move after update', async () => { + mockRecheckStack.mockImplementation(async () => { + callOrder.push('recheckStack'); + return { + outcome: 'still_present', + warning: UPDATE_DIGEST_UNCHANGED_WARNING, + }; + }); + + const res = await request(app) + .post('/api/stacks/web/update') + .set('Cookie', authCookie) + .send({ skip_scan: true }); + + expect(res.status).toBe(200); + expect(res.body.recheckWarning).toBe(UPDATE_DIGEST_UNCHANGED_WARNING); + }); + it('keeps HTTP 200 and success notification when recheck throws after Compose', async () => { mockRecheckStack.mockImplementation(async () => { callOrder.push('recheckStack'); diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 50c8d427..c3f66bd0 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -37,6 +37,27 @@ export const UPDATE_STILL_PRESENT_WARNING = export const UPDATE_VERIFICATION_INCOMPLETE_WARNING = 'The update command completed, but Sencho could not fully verify whether an image update remains.'; +// Mirrored verbatim in GENERIC_POST_UPDATE_WARNINGS in +// frontend/src/components/EditorLayout/hooks/useStackActions.ts; keep the copy in sync. +export const UPDATE_DIGEST_UNCHANGED_WARNING = + 'The update command completed, but the image digest was not updated. Your Docker daemon may cache older content through a registry mirror, or the container may still be pinned to the previous image. Check your daemon configuration or recreate the container with --force-recreate.'; + +/** Warning when a service-scoped update cleared the target but siblings still need updates. */ +export function otherServicesStillPresentWarning(updatedService: string, otherServices: string[]): string { + return `The update for "${updatedService}" completed, but Sencho still detects an available image update on ${formatServiceList(otherServices)}.`; +} + +function formatServiceList(names: string[]): string { + if (names.length <= 1) return names[0] ?? ''; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`; +} + +export interface RecheckStackOptions { + /** When set after a service-scoped update, attribution prefers siblings over daemon blame. */ + updatedService?: string; +} + export interface ImageCheckResult { hasUpdate: boolean; /** Same-tag registry digest drift; Compose pull can apply without pin change. */ @@ -1026,7 +1047,11 @@ export class ImageUpdateService { * after a manual full-stack update. On a render failure the prior row is * left untouched and a verification_failed result is returned. */ - public async recheckStack(nodeId: number, stackName: string): Promise { + public async recheckStack( + nodeId: number, + stackName: string, + options?: RecheckStackOptions, + ): Promise { // While detection is off, skip registry probes and do not write // stack_update_status (avoids stale findings after re-enable). if (!ImageUpdateService.isChecksEnabled()) { @@ -1092,6 +1117,16 @@ export class ImageUpdateService { const services = reductions.map((r) => r.status); const checkStatus = aggregateServiceCheckStatus(services); const hasUpdate = services.some((s) => s.hasUpdate); + // Every still-present update being a same-tag digest rebuild (no higher + // semver tag) signals the local content for that image did not move: the + // daemon may serve a cached/mirrored manifest, or the container may still + // run the previous image. After a service-scoped update, prefer naming + // sibling services that still need work over blaming the daemon for the + // service that was just updated. + const updatingEntries = [...imageUpdateMap.values()] + .filter((r) => r.hasUpdate && normalizeImageCheckStatus(r) !== 'not_checkable'); + const allDigestOnly = hasUpdate && updatingEntries.length > 0 + && updatingEntries.every((r) => r.digestUpdate === true && r.tagUpdate !== true); const lastError = stackStatusLastError(services); const now = Date.now(); @@ -1117,9 +1152,20 @@ export class ImageUpdateService { }; } if (hasUpdate) { + const updatedService = options?.updatedService; + if (updatedService) { + const staleNames = services.filter((s) => s.hasUpdate).map((s) => s.service); + const siblings = staleNames.filter((name) => name !== updatedService).sort(); + if (!staleNames.includes(updatedService) && siblings.length > 0) { + return { + outcome: 'still_present', + warning: otherServicesStillPresentWarning(updatedService, siblings), + }; + } + } return { outcome: 'still_present', - warning: UPDATE_STILL_PRESENT_WARNING, + warning: allDigestOnly ? UPDATE_DIGEST_UNCHANGED_WARNING : UPDATE_STILL_PRESENT_WARNING, }; } return { outcome: 'cleared', warning: null }; diff --git a/backend/src/services/StackUpdateOrchestrator.ts b/backend/src/services/StackUpdateOrchestrator.ts index 737ff31b..0e9af3eb 100644 --- a/backend/src/services/StackUpdateOrchestrator.ts +++ b/backend/src/services/StackUpdateOrchestrator.ts @@ -288,7 +288,9 @@ export class StackUpdateOrchestrator { ); } } - const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName); + const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName, { + updatedService: serviceName, + }); await DriftLedgerService.getInstance().reconcileServiceForStack(nodeId, stackName, serviceName); await this.refreshMeshIfEnabled(nodeId, stackName); @@ -434,7 +436,9 @@ export class StackUpdateOrchestrator { consumed = true; } - const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName); + const recheck = await ImageUpdateService.getInstance().recheckStack(nodeId, stackName, { + updatedService: serviceName, + }); await this.refreshMeshIfEnabled(nodeId, stackName); const warnings = [observed.gateWarning, recheck.warning].filter((w): w is string => !!w); diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index 2bac30d4..0c11f212 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -173,6 +173,9 @@ A stack that mixes registry images and `build:` services still gets a card, scor Compose still pins the older tag. Apply pulls and recreates that pinned tag only. To move to the next tag shown on the card, edit the Compose `image:` reference, then deploy. + + The badge means new image content is published behind the same tag. If an update does not clear it, your Docker daemon may be pulling through a registry mirror that still serves the old content, or the container may still run the previous image. Check your daemon configuration for a `registry-mirrors` entry, or recreate the service with `docker compose up -d --force-recreate` on the affected stack. + One or more nodes that are marked online in your fleet did not respond within the request timeout. Pending updates from those nodes are not shown until they come back. Check the node's status from the Fleet view and the network path between this Sencho instance and the unreachable node. diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index dfb5beb6..d5506e91 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -19,6 +19,7 @@ import { isTagOnlyAdvisory, isVerificationOnlyPreview, } from '@/lib/updatePreviewActionability'; +import { DigestRebuildHint } from '@/components/DigestRebuildHint'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useIsMobile } from '@/hooks/use-is-mobile'; @@ -247,9 +248,9 @@ function RiskBadge({ } if (bump === 'unknown') { return ( - + Digest rebuild - + ); } return ( @@ -368,9 +369,9 @@ function StackReadinessCard({ headline = (
{p.summary.current_tag} - + Rebuild available - +
); } else { @@ -671,7 +672,9 @@ export function MobileReadinessCard({ headline = (
{p.summary.current_tag} - Rebuild available + + Rebuild available +
); } else { diff --git a/frontend/src/components/DigestRebuildHint.tsx b/frontend/src/components/DigestRebuildHint.tsx new file mode 100644 index 00000000..bcd6033e --- /dev/null +++ b/frontend/src/components/DigestRebuildHint.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { DIGEST_REBUILD_HINT } from '@/lib/updatePreviewActionability'; +import { cn } from '@/lib/utils'; + +interface DigestRebuildHintProps { + children: ReactNode; + className?: string; +} + +/** + * Focusable control that surfaces DIGEST_REBUILD_HINT on click/tap and keyboard. + * Replaces hover-only title= spans so mobile and keyboard users can read the hint. + */ +export function DigestRebuildHint({ children, className }: DigestRebuildHintProps) { + return ( + + + + + + {DIGEST_REBUILD_HINT} + + + ); +} diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index e23659e8..65665ba1 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -998,6 +998,15 @@ describe('useStackActions recovery records', () => { expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); + it('toasts recheckWarning from a successful update response body', async () => { + const warning = 'Digest still detected after update.'; + routeApi(200, JSON.stringify({ status: 'Update completed', healthGateId: 'gate-1', recheckWarning: warning })); + const { result } = setup(); + await act(async () => { await result.current.updateStack(); }); + expect(toast.info).toHaveBeenCalledWith('Stack updated. Verifying health...'); + expect(toast.info).toHaveBeenCalledWith(warning); + }); + it('does not record a failure for a stack-op-in-progress 409', async () => { const inProgress = JSON.stringify({ code: 'stack_op_in_progress', diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 2edea6f7..7f0ab9df 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -49,17 +49,32 @@ type MissingExternalNetworksEnvelope = MissingExternalNetworksPayload & { declaredExternalCount: number; }; -/** healthGateId from a success body, or null when absent or unreadable. */ -const parseHealthGateId = async (response: Response): Promise => { +type UpdateSuccessBody = { + healthGateId: string | null; + recheckWarning?: string; +}; + +/** healthGateId (and optional recheckWarning) from a success body. */ +const parseUpdateSuccessBody = async (response: Response): Promise => { try { const body: unknown = await response.json(); - if (isRecord(body) && typeof body.healthGateId === 'string') return body.healthGateId; + if (!isRecord(body)) return { healthGateId: null }; + return { + healthGateId: typeof body.healthGateId === 'string' ? body.healthGateId : null, + recheckWarning: typeof body.recheckWarning === 'string' ? body.recheckWarning : undefined, + }; } catch (e) { // A success body should always parse; the warn surfaces a future // double-read bug instead of silently disabling the gate UI. console.warn('[HealthGate] could not read the success body:', e); + return { healthGateId: null }; } - return null; +}; + +/** healthGateId from a success body, or null when absent or unreadable. */ +const parseHealthGateId = async (response: Response): Promise => { + const { healthGateId } = await parseUpdateSuccessBody(response); + return healthGateId; }; // Sentinel stored in overlayState.pendingUnsavedLoad to mark that the pending @@ -80,14 +95,17 @@ const NODE_UNREACHABLE_FAILURE: FailureClassification = { const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); -// Mirrors ImageUpdateService's UPDATE_STILL_PRESENT_WARNING / UPDATE_VERIFICATION_INCOMPLETE_WARNING: -// that service's warning copy assumes an update was just applied, but -// checkUpdatesForStack runs before any update, so these two generic messages -// are replaced with accurate pre-update copy. A stack-specific reason (e.g. a -// compose render failure) is still forwarded as-is. +// Mirrors ImageUpdateService's post-update warning copy: UPDATE_STILL_PRESENT_WARNING, +// UPDATE_VERIFICATION_INCOMPLETE_WARNING, and UPDATE_DIGEST_UNCHANGED_WARNING. Those +// warnings assume an update was just applied, but checkUpdatesForStack runs before +// any update, so they are replaced with accurate pre-update copy. The set is a +// safety net for pairing changes: today only the verification-incomplete warning +// actually arrives outside the still_present branch, which is intercepted earlier. +// A stack-specific reason (e.g. a compose render failure) is still forwarded as-is. const GENERIC_POST_UPDATE_WARNINGS: ReadonlySet = new Set([ 'The update command completed, but Sencho still detects an available image update.', 'The update command completed, but Sencho could not fully verify whether an image update remains.', + 'The update command completed, but the image digest was not updated. Your Docker daemon may cache older content through a registry mirror, or the container may still be pinned to the previous image. Check your daemon configuration or recreate the container with --force-recreate.', ]); const SELF_STACK_PROTECTED_CODE = 'self_stack_protected'; @@ -1725,15 +1743,22 @@ export function useStackActions(options: UseStackActionsOptions) { }; } overlayState.setPolicyBlock(null); - const healthGateId = await parseHealthGateId(response); - // With a health gate observing, the operation finishing is not the - // final verdict yet; soften the toast so success is not claimed twice. - if (healthGateId && action === 'update') { - toast.info('Stack updated. Verifying health...'); + const { healthGateId, recheckWarning } = await parseUpdateSuccessBody(response); + if (action === 'update') { + // With a health gate observing, the operation finishing is not the + // final verdict yet; soften the toast so success is not claimed twice. + if (healthGateId) { + toast.info('Stack updated. Verifying health...'); + } else { + toast.success(successMessage); + } + // Same surface as service-scoped Apply / Fleet Apply Now: the backend + // may explain why a digest rebuild is still detected after Compose. + if (recheckWarning) toast.info(recheckWarning); + stackListState.fetchImageUpdates(); } else { toast.success(successMessage); } - if (action === 'update') stackListState.fetchImageUpdates(); await refreshSelectedContainers(stackName, stackFile); stackListState.recordActionSuccess(stackFile); return { ok: true as const, healthGateId }; diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index d4690a49..0024a857 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -5,7 +5,7 @@ * the update did not take effect. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); @@ -655,6 +655,10 @@ describe('StackAnatomyPanel digest verification failure', () => { expect(screen.getByText(/same-tag digest rebuild/i)).toBeInTheDocument(); expect(screen.queryByText(/review required/i)).toBeNull(); expect(screen.getByRole('button', { name: /^apply$/i })).toBeEnabled(); + const hint = screen.getByTestId('digest-rebuild-hint'); + expect(hint).toHaveTextContent(/same-tag digest rebuild/i); + await act(async () => { fireEvent.click(hint); }); + expect(await screen.findByTestId('digest-rebuild-hint-content')).toHaveTextContent(/same tag, newer content/i); }); it('holds a confirmed update for review even when the other image\'s own tag update masks its digest error into an overall ok check_status', async () => { diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index a61187ec..e51fb7c4 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -11,6 +11,7 @@ import { isReviewRequiredUpdatePreview, isTagOnlyAdvisory, } from '@/lib/updatePreviewActionability'; +import { DigestRebuildHint } from '@/components/DigestRebuildHint'; import { cn } from '@/lib/utils'; import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown'; import { usePreflightDismiss } from '@/hooks/usePreflightDismiss'; @@ -440,6 +441,8 @@ export default function StackAnatomyPanel({ const gitRebuildHint = hasBuildServices && activeGitSource ? 'After applying Git source changes, use Rebuild & Update to deploy the updated source.' : ''; + const changelogLine = updatePreview?.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : ''; + const bannerTailSegments = [buildHint, gitRebuildHint, changelogLine].filter(Boolean); const applyLabel = hasBuildServices ? (applying ? 'rebuilding...' : 'Rebuild & Update') : (applying ? 'applying...' : 'apply'); @@ -658,13 +661,18 @@ export default function StackAnatomyPanel({ )}
- {[ - bumpLabel, - bannerLeadIn, - buildHint, - gitRebuildHint, - updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '', - ].filter(Boolean).join(' · ')} + {/* The hint popover belongs to the digest-rebuild lead-in only: when a + review hold or tag advisory overrides bannerLeadIn, render the + plain joined line so the hint never rides on other copy. */} + {updateKind === 'digest' && hasUpdate && bannerLeadIn === 'same-tag digest rebuild' ? ( + <> + {bumpLabel && {bumpLabel} · } + {bannerLeadIn} + {bannerTailSegments.length > 0 && · {bannerTailSegments.join(' · ')}} + + ) : ( + [bumpLabel, bannerLeadIn, ...bannerTailSegments].filter(Boolean).join(' · ') + )}
{blocked && updatePreview.summary.blocked_reason && (
{updatePreview.summary.blocked_reason}
diff --git a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx index bfd3eec0..22c19732 100644 --- a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx +++ b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx @@ -317,9 +317,14 @@ describe('verification preview helpers', () => { }); }); -it('enables Apply for a safe, non-blocked update', () => { +it('enables Apply for a safe, non-blocked update', async () => { render(); expect(apply()).toBeEnabled(); + // Digest-rebuild headline opens an accessible popover (not hover-only title). + const hint = screen.getByTestId('digest-rebuild-hint'); + expect(hint).toHaveTextContent('Rebuild available'); + await act(async () => { fireEvent.click(hint); }); + expect(await screen.findByTestId('digest-rebuild-hint-content')).toHaveTextContent(/same tag, newer content/i); }); it('disables Apply for tag-only advisory updates', () => { @@ -652,6 +657,11 @@ describe('AutoUpdateReadinessView desktop Apply now', () => { render(); const serviceApply = await screen.findByRole('button', { name: /^Apply$/i }); + // Digest-rebuild headline opens an accessible popover (not hover-only title). + const hint = screen.getByTestId('digest-rebuild-hint'); + expect(hint).toHaveTextContent('Rebuild available'); + await act(async () => { fireEvent.click(hint); }); + expect(await screen.findByTestId('digest-rebuild-hint-content')).toHaveTextContent(/same tag, newer content/i); await act(async () => { fireEvent.click(serviceApply); }); await waitFor(() => { diff --git a/frontend/src/lib/updatePreviewActionability.ts b/frontend/src/lib/updatePreviewActionability.ts index 903a2eb9..c756aab4 100644 --- a/frontend/src/lib/updatePreviewActionability.ts +++ b/frontend/src/lib/updatePreviewActionability.ts @@ -3,6 +3,11 @@ * Tag-only availability is advisory: Compose pull does not rewrite pins. */ +/** Tooltip for digest-rebuild surfaces: what the badge means, and why an + * update may not clear it (daemon-side causes behind a persistent badge). */ +export const DIGEST_REBUILD_HINT = + 'Same tag, newer content. If Update does not clear this, your Docker daemon may be pulling through a mirror or the container may still be on the previous image. Check your daemon configuration.'; + export interface UpdatePreviewActionImage { service?: string; has_update?: boolean;