fix: reconcile sticky update indicators with Anatomy preview (#1698)

* fix: reconcile sticky update indicators with Anatomy preview

Sidebar, Updates filter, and Fleet treated retained partial/failed
scanner has_update as confirmed. Keep raw state for retention/notifications,
project confirmed-only to APIs, show distinct incomplete indicators, and
clear sticky rows only after an authoritative-negative preview.

Closes #1685

* test: align sidebar truncate E2E with failed-over-retained precedence

Purple update indicators are confirmed-only; hasUpdate with a failed
check correctly shows the failed trailing icon.

* fix: clear confirmed update rows on authoritative-negative preview

Address audit SF-1/SF-2/SF-3: observation-watermark clears for older
ok+has_update rows (DB + memory gens), Fleet checkability parity with
backend not_checkable, and Updates chip confirmed-only regressions.

* fix: tombstone equal-generation writers on preview clear

Advance the per-stack write generation when clearing at the observation
watermark so a scanner reserved before preview cannot recreate the row
after an authoritative-negative reconcile.

* fix: clear sticky updates with digest and tag preview parity

Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear.

* test: set digestUpdate on auto-update checkImage mocks

Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path.

* fix: clear unused lint errors on sticky update branch

Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green.

* fix: use inflight holder for CacheService prefer-const

Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
Anso
2026-07-25 15:42:19 -04:00
committed by GitHub
parent 8b5407fcff
commit 0daddfde00
43 changed files with 2529 additions and 390 deletions
@@ -7,6 +7,14 @@ import { toast } from '@/components/ui/toast-store';
import { apiFetch, fetchForNode } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
import { isAuthoritativeNegativePreview } from '@/types/imageUpdates';
import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview';
import {
isActionableUpdatePreview,
isPreviewUncertain,
isServiceApplyActionable,
isTagOnlyAdvisory,
} from '@/lib/updatePreviewActionability';
import { useNodes } from '@/context/NodeContext';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { Masthead, Kicker } from '@/components/mobile/mobile-ui';
@@ -24,7 +32,11 @@ interface UpdatePreviewImage {
current_tag: string;
next_tag: string | null;
has_update: boolean;
digest_update?: boolean;
tag_update?: boolean;
semver_bump: SemverBump;
/** Absent on older remotes; backend uses !== 'not_checkable' for checkability. */
check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable';
}
type UpdateKind = 'tag' | 'digest' | 'none';
@@ -43,6 +55,8 @@ interface UpdatePreview {
blocked_reason: string | null;
has_build_services?: boolean;
rebuild_available?: boolean;
/** Absent on older remotes; treat missing as non-authoritative. */
check_status?: 'ok' | 'partial' | 'failed';
};
build_services?: string[];
rollback_target: string | null;
@@ -148,7 +162,25 @@ function formatClock(ts: number | null): string {
});
}
function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) {
function RiskBadge({
bump,
blocked,
uncertain,
tagOnly,
}: {
bump: SemverBump;
blocked: boolean;
uncertain?: boolean;
tagOnly?: boolean;
}) {
if (uncertain) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
<AlertTriangle className="h-3 w-3" strokeWidth={1.5} />
Check uncertain
</span>
);
}
if (blocked || bump === 'major') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-destructive/40 bg-destructive/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-destructive">
@@ -157,6 +189,13 @@ function RiskBadge({ bump, blocked }: { bump: SemverBump; blocked: boolean }) {
</span>
);
}
if (tagOnly) {
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">
Newer tag · edit Compose
</span>
);
}
if (bump === 'minor') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-warning">
@@ -242,7 +281,7 @@ function StackReadinessCard({
Auto: Off
</span>
)}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
</div>
</div>
@@ -302,7 +341,7 @@ function StackReadinessCard({
variant="outline"
className="h-6 gap-1 rounded-md px-2 text-[11px]"
onClick={() => onApplyService?.(stack, nodeId, img.service)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
>
{applyingService === img.service ? 'Applying...' : 'Apply'}
</Button>
@@ -329,7 +368,7 @@ function StackReadinessCard({
<Button
size="sm"
onClick={() => onApply(stack, nodeId)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
title={blocked ? (blockedReason ?? undefined) : undefined}
className="gap-1.5"
>
@@ -502,7 +541,7 @@ export function MobileReadinessCard({
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />Auto: Off
</span>
)}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} uncertain={isPreviewUncertain(preview)} tagOnly={isTagOnlyAdvisory(preview)} />}
</div>
</div>
@@ -534,7 +573,7 @@ export function MobileReadinessCard({
variant="outline"
className="h-7 gap-1 rounded-md px-2 text-[11px]"
onClick={() => onApplyService?.(stack, nodeId, img.service)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isServiceApplyActionable(preview, img.service)}
>
{applyingService === img.service ? 'Applying...' : 'Apply'}
</Button>
@@ -550,7 +589,7 @@ export function MobileReadinessCard({
size="sm"
variant={blocked ? 'outline' : 'default'}
onClick={() => onApply(stack, nodeId)}
disabled={blocked || applying || applyingService !== null}
disabled={blocked || applying || applyingService !== null || !isActionableUpdatePreview(preview)}
className="gap-1.5"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
@@ -779,9 +818,11 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
const previews = await Promise.all(
flatPairs.map(async ({ nodeId, stack }) => {
try {
const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
if (!res.ok) return null;
return await res.json() as UpdatePreview;
const result = await fetchUpdatePreview(stack, {
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
});
if (!result.ok || !result.preview) return null;
return result.preview as UpdatePreview;
} catch {
return null;
}
@@ -796,12 +837,16 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
setGroups(initialGroups.map(g => ({
...g,
cards: g.cards.map(c => ({
...c,
preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null,
previewLoaded: true,
})),
})));
cards: g.cards
.map(c => ({
...c,
preview: previewByKey.get(`${c.nodeId}::${c.stack}`) ?? null,
previewLoaded: true,
}))
// Drop cards whose live preview authoritatively reports no update.
// Missing check_status (older remotes) or non-ok status keeps the card.
.filter(c => !isAuthoritativeNegativePreview(c.preview)),
})).filter(g => g.cards.length > 0));
} catch (err) {
if (token !== loadTokenRef.current) return;
toast.error((err as Error)?.message || 'Failed to load readiness');
@@ -920,13 +965,25 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
}
// Reload authoritative preview so summary / Apply affordances stay accurate.
try {
const res = await fetchForNode(`/stacks/${encodeURIComponent(stack)}/update-preview`, nodeId);
if (res.ok) {
const next = await res.json() as UpdatePreview;
setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true });
const result = await fetchUpdatePreview(stack, {
fetchImpl: (path, init) => fetchForNode(path, nodeId, init),
});
if (result.ok && result.preview) {
const next = result.preview as UpdatePreview;
if (isAuthoritativeNegativePreview(next)) {
setGroups(prev => prev
.map(g => g.nodeId === nodeId
? { ...g, cards: g.cards.filter(c => c.stack !== stack) }
: g)
.filter(g => g.cards.length > 0));
} else {
setCardField(c => c.stack === stack && c.nodeId === nodeId, { preview: next, previewLoaded: true });
}
} else {
console.error(`[AutoUpdateReadinessView] post-apply update-preview failed (${result.status})`);
}
} catch {
// Preview refresh is best-effort; the update itself already succeeded.
} catch (err) {
console.error('[AutoUpdateReadinessView] post-apply update-preview refresh failed', err);
}
return {
ok: true as const,
@@ -985,7 +1042,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
c.autoUpdateEnabled
&& c.previewLoaded
&& c.preview !== null
&& !c.preview.summary.blocked,
&& isActionableUpdatePreview(c.preview),
).length;
return { total: t, ready: r };
}, [flatCards]);
@@ -52,6 +52,7 @@ import type { useAuth } from '@/context/AuthContext';
import type { ContainerInfo, ContainerStatsEntry, StackAction } from './EditorView';
import type { EffectiveServiceSpec } from '@/types/effectiveServices';
import type { StackServiceUpdateStatus } from '@/types/imageUpdates';
import { isConfirmedServiceUpdate } from '@/types/imageUpdates';
const extractUptime = (status: string | undefined): string | null => {
if (!status) return null;
@@ -733,7 +734,7 @@ export function ContainersHealth({
const group = safeContainers.filter(c => c.Service === spec.name);
const status = serviceUpdateStatuses.find(s => s.service === spec.name);
const busy = serviceUpdateInProgress?.service === spec.name;
const hasUpdate = status?.hasUpdate === true;
const hasUpdate = status ? isConfirmedServiceUpdate(status) : false;
const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update';
const showUpdateAction = spec.declaredImage !== null || spec.hasBuild;
const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused');
@@ -142,6 +142,40 @@ describe('useNotifications', () => {
expect(onStateInvalidate).toHaveBeenCalledTimes(1);
});
it('fires onImageUpdatesChange on update-status-reconciled', () => {
const onImageUpdatesChange = vi.fn();
renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate', scope: 'image-updates', nodeId: 1,
stackName: 'foo', action: 'update-status-reconciled', ts: 1000,
}),
});
});
expect(onImageUpdatesChange).toHaveBeenCalledTimes(1);
});
it('ignores unrelated image-updates actions for the refresh callback', () => {
const onImageUpdatesChange = vi.fn();
renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate', scope: 'image-updates', nodeId: 1,
stackName: 'foo', action: 'other', ts: 1000,
}),
});
});
expect(onImageUpdatesChange).not.toHaveBeenCalled();
});
it('does not fire onImageUpdatesChange on a generic state-invalidate', () => {
const onStateInvalidate = vi.fn();
const onImageUpdatesChange = vi.fn();
@@ -11,6 +11,11 @@ interface UseNotificationsOptions {
onImageUpdatesChange: () => void;
}
/** Local stack-updated and preview-reconcile clears both refresh the update map. */
function isImageUpdatesRefreshAction(action: unknown): boolean {
return action === 'stack-updated' || action === 'update-status-reconciled';
}
export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }: UseNotificationsOptions) {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [tickerConnected, setTickerConnected] = useState(false);
@@ -189,7 +194,7 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
onStateInvalidateRef.current();
if (msg.scope === 'notifications') {
reconcileNotificationsInvalidateRef.current(msg);
} else if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
} else if (msg.scope === 'image-updates' && isImageUpdatesRefreshAction(msg.action)) {
onImageUpdatesChangeRef.current();
}
}
@@ -271,7 +276,7 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
// Remote payloads use the remote's local DB node ID. Hub UI state
// is keyed by rn.id, so always reconcile with the hub node ID.
reconcileNotificationsInvalidateRef.current({ ...msg, nodeId: rn.id });
} else if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
} else if (msg.scope === 'image-updates' && isImageUpdatesRefreshAction(msg.action)) {
onImageUpdatesChangeRef.current();
}
}
@@ -16,6 +16,11 @@ vi.mock('@/context/NodeContext', () => ({
useNodes: () => useNodesMock(),
}));
const useImageUpdatesMock = vi.fn();
vi.mock('@/hooks/useImageUpdates', () => ({
useImageUpdates: (...args: unknown[]) => useImageUpdatesMock(...args),
}));
import { useStackListState } from './useStackListState';
function okJson(payload: unknown): Response {
@@ -32,10 +37,16 @@ function notFound(): Response {
beforeEach(() => {
apiFetchMock.mockReset();
useNodesMock.mockReset();
useImageUpdatesMock.mockReset();
useNodesMock.mockReturnValue({
activeNode: { id: 1, name: 'Local', type: 'local' },
nodes: [{ id: 1, name: 'Local', type: 'local' }],
});
useImageUpdatesMock.mockReturnValue({
stackUpdates: {},
refresh: vi.fn(),
sidebarIndicators: true,
});
});
describe('useStackListState.refreshStacks failure classification', () => {
@@ -138,3 +149,50 @@ describe('useStackListState.refreshStacks failure classification', () => {
expect(result.current.files).toEqual(['web.yml']);
});
});
describe('useStackListState Updates chip confirmed-only', () => {
async function loadStacks() {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') {
return Promise.resolve(okJson(['ok.yml', 'partial.yml', 'failed.yml']));
}
if (endpoint === '/stacks/statuses') {
return Promise.resolve(okJson({
'ok.yml': { status: 'running' },
'partial.yml': { status: 'running' },
'failed.yml': { status: 'running' },
}));
}
return Promise.resolve(notFound());
});
useImageUpdatesMock.mockReturnValue({
stackUpdates: {
'ok.yml': { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1 },
'partial.yml': { hasUpdate: true, checkStatus: 'partial', lastError: 'timeout', checkedAt: 1 },
'failed.yml': { hasUpdate: true, checkStatus: 'failed', lastError: 'unreachable', checkedAt: 1 },
},
refresh: vi.fn(),
sidebarIndicators: true,
});
const { result } = renderHook(() => useStackListState());
await act(async () => {
await result.current.refreshStacks();
});
return result;
}
it('counts only ok+true stacks under Updates', async () => {
const result = await loadStacks();
expect(result.current.filterCounts.updates).toBe(1);
});
it('filters the Updates chip to confirmed stacks only', async () => {
const result = await loadStacks();
await act(async () => {
result.current.setFilterChip('updates');
});
expect(result.current.chipFilteredFiles).toEqual(['ok.yml']);
});
});
@@ -19,6 +19,7 @@ import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackAction
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import { isConfirmedImageUpdate } from '@/types/imageUpdates';
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
import type { StackAction, StackActionResult } from '../EditorView';
import type { Label as StackLabel } from '../../label-types';
@@ -431,18 +432,23 @@ export function useStackListState() {
[files, searchQuery],
);
const hasConfirmedSidebarUpdate = (file: string): boolean => {
const info = sidebarStackUpdates[file];
return info != null && isConfirmedImageUpdate(info);
};
const filterCounts = useMemo(() => ({
all: filteredFiles.length,
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
updates: filteredFiles.filter(hasConfirmedSidebarUpdate).length,
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
const chipFilteredFiles = useMemo(() => {
if (filterChip === 'all') return filteredFiles;
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
if (filterChip === 'updates') return filteredFiles.filter(hasConfirmedSidebarUpdate);
return filteredFiles;
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
@@ -32,22 +32,26 @@ function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
service: 'web',
image: 'nginx:1.25',
current_tag: '1.25',
next_tag: hasUpdate ? '1.26' : null,
next_tag: '1.25',
has_update: hasUpdate,
semver_bump: hasUpdate ? 'minor' : 'none',
digest_update: hasUpdate,
tag_update: false,
semver_bump: hasUpdate ? 'patch' : 'none',
check_status: 'ok',
},
],
summary: {
has_update: hasUpdate,
primary_image: 'nginx',
current_tag: '1.25',
next_tag: '1.26',
semver_bump: 'minor',
update_kind: hasUpdate ? 'tag' : 'none',
next_tag: '1.25',
semver_bump: hasUpdate ? 'patch' : 'none',
update_kind: hasUpdate ? 'digest' : 'none',
blocked: false,
blocked_reason: null,
has_build_services: hasBuild,
rebuild_available: hasBuild,
check_status: 'ok',
},
changelog: null,
};
@@ -118,6 +122,33 @@ describe('StackAnatomyPanel edit affordance', () => {
});
describe('StackAnatomyPanel update banner', () => {
it('hides apply when only a newer tag is available', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
return jsonRes({
build_services: [],
images: [{
service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26',
has_update: true, digest_update: false, tag_update: true, semver_bump: 'minor', check_status: 'ok',
}],
summary: {
has_update: true, primary_image: 'nginx', current_tag: '1.25', next_tag: '1.26',
semver_bump: 'minor', update_kind: 'tag', blocked: false, blocked_reason: null,
has_build_services: false, rebuild_available: false, check_status: 'ok',
},
changelog: null,
});
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
await waitFor(() => expect(screen.getByTestId('update-available-banner')).toBeInTheDocument());
expect(screen.getByText((t) => typeof t === 'string' && t.includes('newer tag') && t.includes('edit Compose pin'))).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'apply' })).not.toBeInTheDocument();
});
it('shows the apply button and fires onApplyUpdate when clicked', async () => {
const onApply = vi.fn();
render(panel(false, onApply));
@@ -135,9 +166,9 @@ describe('StackAnatomyPanel update banner', () => {
return jsonRes({
build_services: [],
images: [
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.26', has_update: true, semver_bump: 'minor' },
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.4', has_update: true, semver_bump: 'minor' },
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, semver_bump: 'none' },
{ service: 'web', image: 'nginx:1.25', current_tag: '1.25', next_tag: '1.25', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
{ service: 'cache', image: 'redis:7.2', current_tag: '7.2', next_tag: '7.2', has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok' },
{ service: 'db', image: 'postgres:16', current_tag: '16', next_tag: null, has_update: false, digest_update: false, tag_update: false, semver_bump: 'none', check_status: 'ok' },
],
summary: {
has_update: true,
+59 -26
View File
@@ -4,6 +4,12 @@ import { Button } from './ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { ScrollableTabRow } from './ui/ScrollableTabRow';
import { apiFetch } from '@/lib/api';
import { fetchUpdatePreview } from '@/lib/fetchUpdatePreview';
import {
isActionableUpdatePreview,
isPreviewUncertain,
isTagOnlyAdvisory,
} from '@/lib/updatePreviewActionability';
import { cn } from '@/lib/utils';
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
@@ -46,7 +52,10 @@ interface UpdatePreviewImage {
current_tag: string;
next_tag: string | null;
has_update: boolean;
digest_update?: boolean;
tag_update?: boolean;
semver_bump: SemverBump;
check_status?: 'ok' | 'partial' | 'failed' | 'not_checkable';
}
interface UpdatePreviewSummary {
@@ -60,6 +69,7 @@ interface UpdatePreviewSummary {
blocked_reason: string | null;
has_build_services: boolean;
rebuild_available: boolean;
check_status?: 'ok' | 'partial' | 'failed';
}
interface UpdatePreview {
@@ -249,15 +259,15 @@ export default function StackAnatomyPanel({
let cancelled = false;
const run = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
const result = await fetchUpdatePreview(stackName);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
setUpdatePreview(data);
if (result.ok && result.preview) {
setUpdatePreview(result.preview as UpdatePreview);
} else {
setUpdatePreview(null);
}
} catch {
} catch (err) {
console.error('[StackAnatomyPanel] update-preview load failed', err);
if (!cancelled) setUpdatePreview(null);
}
};
@@ -279,16 +289,15 @@ export default function StackAnatomyPanel({
let cancelled = false;
const run = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
const result = await fetchUpdatePreview(stackName);
if (cancelled) return;
if (!res.ok) {
if (!result.ok) {
// Re-check failed: keep the banner already shown rather than hiding a
// possibly-still-pending update. The apply action reports its own outcome.
console.error(`[StackAnatomyPanel] update-preview re-check returned ${res.status}; keeping the existing banner`);
console.error(`[StackAnatomyPanel] update-preview re-check returned ${result.status}; keeping the existing banner`);
return;
}
const data = await res.json();
if (!cancelled) setUpdatePreview(data);
if (!cancelled && result.preview) setUpdatePreview(result.preview as UpdatePreview);
} catch (err) {
console.error('[StackAnatomyPanel] update-preview re-check failed:', err);
}
@@ -363,7 +372,10 @@ export default function StackAnatomyPanel({
const hasUpdate = Boolean(updatePreview?.summary.has_update);
const hasBuildServices = Boolean(updatePreview?.summary.has_build_services);
const rebuildAvailable = Boolean(updatePreview?.summary.rebuild_available);
const previewCheckStatus = updatePreview?.summary.check_status;
const previewUncertain = isPreviewUncertain(updatePreview);
const showUpdateBanner = hasUpdate || rebuildAvailable;
const showCheckStatusBanner = previewUncertain && !showUpdateBanner;
const updateKind = updatePreview?.summary.update_kind ?? 'none';
const blocked = Boolean(updatePreview?.summary.blocked);
const updatedImages = (updatePreview?.images ?? []).filter((img) => img.has_update);
@@ -381,21 +393,27 @@ export default function StackAnatomyPanel({
? 'border-warning/40 text-warning hover:bg-warning/10'
: 'border-success/40 text-success hover:bg-success/10';
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
const bannerLeadIn = blocked
? 'review required'
: hasUpdate && updateKind === 'digest'
? 'same-tag digest rebuild'
: hasUpdate && hasBuildServices
? 'registry update + local rebuild'
: rebuildAvailable && !hasUpdate
? 'local build / rebuild required'
: bump === 'patch'
? 'safe to apply'
: bump === 'minor'
? 'review recommended'
: bump === 'major'
? 'breaking changes possible'
: '';
const tagOnlyAdvisory = isTagOnlyAdvisory(updatePreview);
const canApplyPreview = isActionableUpdatePreview(updatePreview);
let bannerLeadIn = '';
if (blocked) {
bannerLeadIn = 'review required';
} else if (tagOnlyAdvisory) {
bannerLeadIn = 'newer tag · edit Compose pin';
} else if (hasUpdate && updateKind === 'digest') {
bannerLeadIn = 'same-tag digest rebuild';
} else if (hasUpdate && hasBuildServices) {
bannerLeadIn = 'registry update + local rebuild';
} else if (rebuildAvailable && !hasUpdate) {
bannerLeadIn = 'local build / rebuild required';
} else if (bump === 'patch') {
bannerLeadIn = 'safe to apply';
} else if (bump === 'minor') {
bannerLeadIn = 'review recommended';
} else if (bump === 'major') {
bannerLeadIn = 'breaking changes possible';
}
const buildServiceNames = updatePreview?.build_services ?? [];
const buildHint = hasBuildServices
? `Rebuilds ${buildServiceNames.length} local build service${buildServiceNames.length === 1 ? '' : 's'} from Dockerfile context; may take longer and needs network access for base images.`
@@ -576,6 +594,21 @@ export default function StackAnatomyPanel({
</Row>
</>
)}
{showCheckStatusBanner && updatePreview && (
<div
data-testid="update-check-status-banner"
className="mt-3 mb-3 rounded-lg border border-warning/40 bg-warning/[0.06] p-3 text-warning"
>
<div className="font-mono text-xs uppercase tracking-wide">
{previewCheckStatus === 'failed' ? 'Update check failed' : 'Update check incomplete'}
</div>
<div className="mt-1 font-mono text-xs text-foreground/80 leading-relaxed">
{previewCheckStatus === 'failed'
? 'Registry checks could not verify image status. Retained update indicators may be stale.'
: 'Some image checks did not complete. Status is uncertain until a full check succeeds.'}
</div>
</div>
)}
{showUpdateBanner && updatePreview && (
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
<div className="flex items-start justify-between gap-2">
@@ -616,7 +649,7 @@ export default function StackAnatomyPanel({
<div className="mt-1 font-mono text-[10px] text-destructive">{updatePreview.summary.blocked_reason}</div>
)}
</div>
{canEdit && !blocked && (
{canEdit && !blocked && canApplyPreview && (
<Button
type="button"
size="sm"
@@ -37,6 +37,7 @@ vi.mock('@/context/NodeContext', () => ({
import { apiFetch, fetchForNode } from '@/lib/api';
import { requestServiceUpdate } from '@/lib/serviceUpdate';
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
import { isAuthoritativeNegativePreview } from '@/types/imageUpdates';
function card(over: Partial<StackCard> = {}): StackCard {
return {
@@ -49,16 +50,20 @@ function card(over: Partial<StackCard> = {}): StackCard {
scheduledTask: null,
preview: {
stack_name: 'nextcloud',
images: [],
images: [{
service: 'app', image: 'nextcloud:27.1.4', current_tag: '27.1.4', next_tag: '27.1.4',
has_update: true, digest_update: true, tag_update: false, semver_bump: 'patch', check_status: 'ok',
}],
summary: {
has_update: true,
primary_image: 'nextcloud',
current_tag: '27.1.4',
next_tag: '27.1.5',
next_tag: '27.1.4',
semver_bump: 'patch',
update_kind: 'tag',
update_kind: 'digest',
blocked: false,
blocked_reason: null,
check_status: 'ok',
},
rollback_target: null,
changelog: 'Fixes. Security patch.',
@@ -74,6 +79,39 @@ it('enables Apply for a safe, non-blocked update', () => {
expect(apply()).toBeEnabled();
});
it('disables Apply for tag-only advisory updates', () => {
render(
<MobileReadinessCard
card={card({
preview: {
stack_name: 'nextcloud',
images: [{
service: 'app', image: 'nextcloud:27.1.4', current_tag: '27.1.4', next_tag: '27.1.5',
has_update: true, digest_update: false, tag_update: true, semver_bump: 'patch', check_status: 'ok',
}],
summary: {
has_update: true,
primary_image: 'nextcloud',
current_tag: '27.1.4',
next_tag: '27.1.5',
semver_bump: 'patch',
update_kind: 'tag',
blocked: false,
blocked_reason: null,
check_status: 'ok',
},
rollback_target: null,
changelog: 'Fixes.',
},
})}
onApply={vi.fn()}
/>,
);
expect(screen.getByText(/Newer tag/i)).toBeInTheDocument();
expect(apply()).toBeDisabled();
});
it('disables Apply when the update is blocked (major bump)', () => {
render(
<MobileReadinessCard
@@ -117,9 +155,12 @@ it('offers per-service Apply when build-only companions make the stack multi-ser
service: 'app',
image: 'nextcloud:27',
current_tag: '27.1.4',
next_tag: '27.1.5',
next_tag: '27.1.4',
has_update: true,
digest_update: true,
tag_update: false,
semver_bump: 'patch',
check_status: 'ok',
}],
build_services: ['cron'],
summary: {
@@ -128,10 +169,11 @@ it('offers per-service Apply when build-only companions make the stack multi-ser
current_tag: '27.1.4',
next_tag: '27.1.5',
semver_bump: 'patch',
update_kind: 'tag',
update_kind: 'digest',
blocked: false,
blocked_reason: null,
has_build_services: true,
check_status: 'ok',
},
rollback_target: null,
changelog: 'Fixes.',
@@ -200,9 +242,12 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
service: 'app',
image: 'nextcloud:27',
current_tag: '27.1.4',
next_tag: '27.1.5',
next_tag: '27.1.4',
has_update: true,
digest_update: true,
tag_update: false,
semver_bump: 'patch' as const,
check_status: 'ok' as const,
},
{
service: 'redis',
@@ -210,18 +255,22 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
current_tag: '7.2',
next_tag: '7.2',
has_update: false,
digest_update: false,
tag_update: false,
semver_bump: 'none' as const,
check_status: 'ok' as const,
},
],
summary: {
has_update: true,
primary_image: 'nextcloud',
current_tag: '27.1.4',
next_tag: '27.1.5',
next_tag: '27.1.4',
semver_bump: 'patch' as const,
update_kind: 'tag' as const,
update_kind: 'digest' as const,
blocked: false,
blocked_reason: null,
check_status: 'ok' as const,
},
rollback_target: null,
changelog: 'Fixes.',
@@ -229,7 +278,7 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
const refreshedPreview = {
...multiPreview,
images: multiPreview.images.map((img) => (
img.service === 'app' ? { ...img, has_update: false, current_tag: '27.1.5', next_tag: '27.1.5' } : img
img.service === 'app' ? { ...img, has_update: false, digest_update: false, current_tag: '27.1.4', next_tag: '27.1.4' } : img
)),
summary: { ...multiPreview.summary, has_update: false, current_tag: '27.1.5' },
};
@@ -455,3 +504,40 @@ describe('AutoUpdateReadinessView cadence fetch race', () => {
expect(screen.getByText(/Recheck available in/)).toBeInTheDocument();
});
});
describe('isAuthoritativeNegativePreview (Fleet card drop parity)', () => {
it('drops when a checkable image has ok + no update', () => {
expect(isAuthoritativeNegativePreview({
images: [{ check_status: 'ok' }],
summary: { has_update: false, check_status: 'ok' },
})).toBe(true);
});
it('retains not_checkable-only negative previews', () => {
expect(isAuthoritativeNegativePreview({
images: [{ check_status: 'not_checkable' }],
summary: { has_update: false, check_status: 'ok' },
})).toBe(false);
});
it('clears when every image is ok even if summary check_status is omitted', () => {
expect(isAuthoritativeNegativePreview({
images: [{ check_status: 'ok' }],
summary: { has_update: false },
})).toBe(true);
});
it('retains when image check_status is missing', () => {
expect(isAuthoritativeNegativePreview({
images: [{}],
summary: { has_update: false, check_status: 'ok' },
})).toBe(false);
});
it('retains empty image lists even with ok summary', () => {
expect(isAuthoritativeNegativePreview({
images: [],
summary: { has_update: false, check_status: 'ok' },
})).toBe(false);
});
});
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import type { StackStatusEntry, MetricPoint, StackCpuSeries, StackStatusesLoadStatus } from './types';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import { isConfirmedImageUpdate, isConfirmedServiceUpdate } from '@/types/imageUpdates';
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
import { classifyRow, type RowState } from './classifyRow';
import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailableLabel';
@@ -119,6 +120,7 @@ export function StackHealthTable({
const series = stackCpuSeries[name];
const peakCpu = series?.peakValue ?? agg?.cpu ?? 0;
const state = classifyRow(entry.status, peakCpu);
const updateInfo = stackUpdates[file];
return {
file,
name,
@@ -132,9 +134,9 @@ export function StackHealthTable({
runningSince: entry.runningSince ?? null,
source: entry.source ?? 'local',
mainPort: entry.mainPort ?? null,
hasUpdate: stackUpdates[file]?.hasUpdate ?? false,
outdatedServices: (stackUpdates[file]?.services ?? [])
.filter((s) => s.hasUpdate)
hasUpdate: updateInfo != null && isConfirmedImageUpdate(updateInfo),
outdatedServices: (updateInfo?.services ?? [])
.filter((s) => isConfirmedServiceUpdate(s))
.map((s) => s.service),
};
});
+46 -6
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import type { CheckStatus } from '@/types/imageUpdates';
import { isConfirmedImageUpdate } from '@/types/imageUpdates';
import { Checkbox } from '@/components/ui/checkbox';
import type { Label } from '@/components/label-types';
import { cn } from '@/lib/utils';
@@ -23,8 +24,8 @@ interface StackRowProps {
hasUpdate: boolean;
/** Outdated service names for the update tooltip; empty keeps the generic label. */
outdatedServices?: string[];
// Last image-update check outcome. 'failed' surfaces a muted "couldn't check"
// indicator so an undeterminable check is not mistaken for "up to date".
// Last image-update check outcome. Incomplete/failed checks with hasUpdate
// use a distinct indicator so they are not mistaken for a confirmed update.
checkStatus?: CheckStatus;
lastError?: string;
hasGitPending: boolean;
@@ -48,6 +49,36 @@ function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
);
}
function appendErrorDetail(base: string, lastError?: string): string {
if (!lastError) return base;
return `${base} ${lastError}`;
}
function partialUpdateTooltip(hasUpdate: boolean, lastError?: string): string {
if (hasUpdate) {
// Neutral copy: partial + hasUpdate can mean newly detected OR retained;
// provenance is not persisted on the wire.
return appendErrorDetail(
'The last check was incomplete; an update was detected or retained, but the full stack could not be verified.',
lastError,
);
}
return appendErrorDetail(
'The last image-update check was incomplete; update status could not be fully verified.',
lastError,
);
}
function failedCheckTooltip(hasUpdate: boolean, lastError?: string): string {
if (hasUpdate) {
return appendErrorDetail(
'Previous update status retained; the last check failed.',
lastError,
);
}
return lastError ? `Update check failed: ${lastError}` : 'Update check failed';
}
export function StackRow(props: StackRowProps) {
const {
file, displayName, status, running, total, isBusy, isActive,
@@ -55,6 +86,10 @@ export function StackRow(props: StackRowProps) {
bulkMode = false, isSelected = false, onToggleSelect,
} = props;
const confirmedUpdate = isConfirmedImageUpdate({ hasUpdate, checkStatus });
const partialIncomplete = checkStatus === 'partial';
const failedCheck = checkStatus === 'failed';
const handleClick = () => {
if (bulkMode) onToggleSelect?.(file);
else onSelect(file);
@@ -106,9 +141,9 @@ export function StackRow(props: StackRowProps) {
{/* Stack name */}
<span className="flex-1 truncate font-mono text-sm min-w-0">{displayName}</span>
{/* Fixed trailing icon slot: update dot > check-failed > git pending */}
{/* Trailing: confirmed update > partial incomplete > failed > git pending */}
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0" data-testid="stack-row-trailing">
{hasUpdate ? (
{confirmedUpdate ? (
<RowTooltip
trigger={(
<span className="relative inline-flex w-2 h-2" data-testid="stack-trailing-update">
@@ -118,10 +153,15 @@ export function StackRow(props: StackRowProps) {
)}
label={updateAvailableLabel(outdatedServices)}
/>
) : checkStatus === 'failed' ? (
) : partialIncomplete ? (
<RowTooltip
trigger={<span data-testid="stack-trailing-check-partial"><AlertCircle className="w-3 h-3 text-warning-foreground/80" strokeWidth={1.5} /></span>}
label={partialUpdateTooltip(hasUpdate, lastError)}
/>
) : failedCheck ? (
<RowTooltip
trigger={<span data-testid="stack-trailing-check-failed"><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
label={lastError ? `Update check failed: ${lastError}` : 'Update check failed'}
label={failedCheckTooltip(hasUpdate, lastError)}
/>
) : hasGitPending ? (
<RowTooltip
@@ -111,13 +111,34 @@ describe('StackRow', () => {
expect(container.querySelector('.bg-update')).toBeNull();
});
it('prefers the update dot over the check-failed indicator', () => {
it('prefers the failed indicator over the update dot when checkStatus is failed', () => {
const { container } = render(<StackRow {...base({ status: 'running', hasUpdate: true, checkStatus: 'failed' })} />);
expect(container.querySelector('.bg-update')).toBeNull();
expect(screen.getByTestId('stack-trailing-check-failed')).toBeInTheDocument();
});
it('shows a partial indicator (not purple) for incomplete checks with hasUpdate', async () => {
const { container } = render(<StackRow {...base({
hasUpdate: true,
checkStatus: 'partial',
lastError: 'ghcr.io unreachable',
})} />);
expect(container.querySelector('.bg-update')).toBeNull();
expect(screen.getByTestId('stack-trailing-check-partial')).toBeInTheDocument();
fireEvent.pointerMove(screen.getByTestId('stack-trailing-check-partial'));
const tips = await screen.findAllByText(/last check was incomplete/i);
expect(tips.length).toBeGreaterThan(0);
expect(screen.queryByText(/previous result was retained/i)).toBeNull();
expect((await screen.findAllByText(/ghcr.io unreachable/i)).length).toBeGreaterThan(0);
});
it('shows the purple update dot only for confirmed ok+hasUpdate', () => {
const { container } = render(<StackRow {...base({ hasUpdate: true, checkStatus: 'ok' })} />);
expect(container.querySelector('.bg-update')).not.toBeNull();
});
it('names outdated services in the update tooltip', async () => {
render(<StackRow {...base({ hasUpdate: true, outdatedServices: ['api', 'worker'] })} />);
render(<StackRow {...base({ hasUpdate: true, checkStatus: 'ok', outdatedServices: ['api', 'worker'] })} />);
fireEvent.pointerMove(screen.getByTestId('stack-trailing-update'));
expect((await screen.findAllByText('Update available: api, worker')).length).toBeGreaterThan(0);
});