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