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.
This commit is contained in:
Anso
2026-08-06 09:22:53 -04:00
committed by GitHub
parent 575848e017
commit 4fa532530e
13 changed files with 346 additions and 34 deletions
@@ -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,
@@ -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');
+48 -2
View File
@@ -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<StackRecheckResult> {
public async recheckStack(
nodeId: number,
stackName: string,
options?: RecheckStackOptions,
): Promise<StackRecheckResult> {
// 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 };
@@ -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);
+3
View File
@@ -173,6 +173,9 @@ A stack that mixes registry images and `build:` services still gets a card, scor
<Accordion title='Apply now finished but the readiness card still shows a newer tag'>
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.
</Accordion>
<Accordion title='Apply now finished but the digest rebuild badge stays'>
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.
</Accordion>
<Accordion title='Banner says "X of Y nodes reachable"'>
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.
</Accordion>
@@ -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 (
<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">
<DigestRebuildHint 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">
Digest rebuild
</span>
</DigestRebuildHint>
);
}
return (
@@ -368,9 +369,9 @@ function StackReadinessCard({
headline = (
<div className="flex items-baseline gap-2 font-mono text-sm">
<span className="text-stat-subtitle">{p.summary.current_tag}</span>
<span className="text-brand text-[10px] leading-3 uppercase tracking-[0.18em]">
<DigestRebuildHint className="text-brand text-[10px] leading-3 uppercase tracking-[0.18em]">
Rebuild available
</span>
</DigestRebuildHint>
</div>
);
} else {
@@ -671,7 +672,9 @@ export function MobileReadinessCard({
headline = (
<div className="flex items-baseline gap-2 font-mono text-[13px]">
<span className="text-stat-subtitle">{p.summary.current_tag}</span>
<span className="text-[10px] uppercase tracking-[0.12em] text-brand">Rebuild available</span>
<DigestRebuildHint className="text-[10px] uppercase tracking-[0.12em] text-brand">
Rebuild available
</DigestRebuildHint>
</div>
);
} else {
@@ -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 (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline cursor-help text-left rounded-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
className,
)}
data-testid="digest-rebuild-hint"
>
{children}
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="p-3 font-mono text-xs leading-relaxed text-popover-foreground"
data-testid="digest-rebuild-hint-content"
>
{DIGEST_REBUILD_HINT}
</PopoverContent>
</Popover>
);
}
@@ -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',
@@ -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<string | null> => {
type UpdateSuccessBody = {
healthGateId: string | null;
recheckWarning?: string;
};
/** healthGateId (and optional recheckWarning) from a success body. */
const parseUpdateSuccessBody = async (response: Response): Promise<UpdateSuccessBody> => {
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<string | null> => {
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<number> = 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<string> = 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 };
@@ -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 () => {
+15 -7
View File
@@ -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({
</ul>
)}
<div className="mt-1 font-mono text-xs text-foreground/80 leading-relaxed">
{[
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 && <span>{bumpLabel} · </span>}
<DigestRebuildHint>{bannerLeadIn}</DigestRebuildHint>
{bannerTailSegments.length > 0 && <span> · {bannerTailSegments.join(' · ')}</span>}
</>
) : (
[bumpLabel, bannerLeadIn, ...bannerTailSegments].filter(Boolean).join(' · ')
)}
</div>
{blocked && updatePreview.summary.blocked_reason && (
<div className="mt-1 font-mono text-[10px] text-destructive">{updatePreview.summary.blocked_reason}</div>
@@ -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(<MobileReadinessCard card={card()} onApply={vi.fn()} />);
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(<AutoUpdateReadinessView />);
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(() => {
@@ -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;