mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user