fix: distinguish failed image-update checks from "up to date" (#1470)

* fix: distinguish failed image-update checks from "up to date"

The image-update detector collapsed every failure (registry unreachable,
missing auth, rate limit, unresolved local digest) into hasUpdate:false and
dropped the captured reason, so a failed check was indistinguishable from a
current image and never raised a notification, even while a manual stack
update still pulled a newer image.

Detection now records a tri-state per stack (ok / partial / failed) with the
failure reason, exposed via a new GET /api/image-updates/detail (the boolean
GET / is unchanged so fleet aggregation is unaffected). A fully-failed check
preserves the last known has_update, so a transient outage neither erases a
real update nor flaps the notification state. The sidebar shows a muted
"couldn't check" indicator with the reason on hover, and the Update board
lists stacks whose check failed in a "could not be checked" advisory.

Detector hardening: the manifest digest lookup issues HEAD first (falling back
to GET) so it no longer draws down Docker Hub's anonymous pull-rate budget, and
local RepoDigest matching is normalized so official library/* images resolve
their digest instead of falling through to a silent "no update".

* fix: preserve confirmed updates through partial checks; tighten failure surfacing

Address review findings on the tri-state image-update detection:

- A partial check (some images errored) no longer erases a previously
  confirmed update; only a fully-ok check can lower has_update, so a single
  image's registry blip cannot drop the stack's update and re-fire the
  notification on recovery. Adds a regression test.
- The image-level catch stores getErrorMessage(e) rather than raw String(e),
  since that value surfaces verbatim in the sidebar tooltip and readiness
  advisory.
- useImageUpdates and the readiness detail fetch now log unexpected non-ok
  responses instead of silently leaving stale state.
- Remove an unused checkFailedCount derivation (the row indicator is driven by
  the checkStatus prop).
- Reword the recordStackCheckFailure docstring and the HEAD-first comment.
This commit is contained in:
Anso
2026-06-26 16:16:34 -04:00
committed by GitHub
parent e3b3c3b857
commit d9b7911f12
19 changed files with 820 additions and 38 deletions
+13 -3
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import { GitBranch, Loader2 } from 'lucide-react';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import type { CheckStatus } from '@/types/imageUpdates';
import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/components/animate-ui/primitives/animate/cursor';
import { Checkbox } from '@/components/ui/checkbox';
import { LabelDot } from '@/components/LabelPill';
@@ -20,6 +21,10 @@ interface StackRowProps {
isActive: boolean;
labels: Label[];
hasUpdate: boolean;
// Last image-update check outcome. 'failed' surfaces a muted "couldn't check"
// indicator so an undeterminable check is not mistaken for "up to date".
checkStatus?: CheckStatus;
lastError?: string;
hasGitPending: boolean;
onSelect: (file: string) => void;
kebabSlot: ReactNode;
@@ -47,7 +52,7 @@ const MAX_VISIBLE_LABELS = 3;
export function StackRow(props: StackRowProps) {
const {
file, displayName, status, running, total, isBusy, isActive, labels,
hasUpdate, hasGitPending, onSelect, kebabSlot,
hasUpdate, checkStatus, lastError, hasGitPending, onSelect, kebabSlot,
bulkMode = false, isSelected = false, onToggleSelect,
} = props;
@@ -115,7 +120,7 @@ export function StackRow(props: StackRowProps) {
</span>
)}
{/* Fixed trailing icon slot: update dot takes priority over git pending */}
{/* Fixed trailing icon slot: update dot > check-failed > git pending */}
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0">
{hasUpdate ? (
<RowTooltip
@@ -127,6 +132,11 @@ export function StackRow(props: StackRowProps) {
)}
label="Update available"
/>
) : checkStatus === 'failed' ? (
<RowTooltip
trigger={<AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} />}
label={lastError ? `Update check failed: ${lastError}` : 'Update check failed'}
/>
) : hasGitPending ? (
<RowTooltip
trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />}