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
@@ -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;