mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06: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:
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user