mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user