mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
fix: purge deleted-stack notifications from panel and ticker (#1674)
* fix: purge deleted-stack notifications from panel and ticker Stack delete already cascaded scans and drift but left notification_history rows, so the bell and Activity ticker kept showing the deleted stack. Purge those rows in the shared deletion lifecycle, invalidate connected clients, and drop node-scoped in-memory rows immediately. * fix: target remote notification purge by hub node id Remote stack-deleted invalidations always reconcile with hub rn.id, and notification refetch preserves failed node slices instead of wiping them.
This commit is contained in:
@@ -22,6 +22,9 @@ const makeNotif = (overrides: Partial<NotificationItem> = {}): NotificationItem
|
||||
id: 1, level: 'info', message: 'test', timestamp: 1000, is_read: 0, ...overrides,
|
||||
});
|
||||
|
||||
const nodeMessageKeys = (items: NotificationItem[]): string[] =>
|
||||
items.map((n) => `${n.nodeId}:${n.message}`).sort();
|
||||
|
||||
class MockWS {
|
||||
static instances: MockWS[] = [];
|
||||
static readonly CONNECTING = 0;
|
||||
@@ -251,4 +254,362 @@ describe('useNotifications', () => {
|
||||
expect(MockWS.instances).toHaveLength(2);
|
||||
await waitFor(() => expect(fetchForNode).toHaveBeenCalledWith('/notifications', 2));
|
||||
});
|
||||
|
||||
it('removeNotificationsForStack drops only the matching node and stack', async () => {
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
|
||||
const remote = makeRemoteNode('online', { id: 2, name: 'Remote-B' });
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes: [localNode, remote], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
payload: makeNotif({ id: 1, stack_name: 'web', message: 'a-web' }),
|
||||
}),
|
||||
});
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
payload: makeNotif({ id: 2, stack_name: 'db', message: 'a-db' }),
|
||||
}),
|
||||
});
|
||||
MockWS.instances[1]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
payload: makeNotif({ id: 3, stack_name: 'web', message: 'b-web' }),
|
||||
}),
|
||||
});
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
payload: makeNotif({ id: 4, message: 'a-unattached' }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.notifications).toHaveLength(4);
|
||||
|
||||
act(() => {
|
||||
result.current.removeNotificationsForStack(localNode.id, 'web');
|
||||
});
|
||||
|
||||
expect(result.current.notifications.map((n) => `${n.nodeId}:${n.stack_name ?? ''}:${n.message}`).sort()).toEqual([
|
||||
'1::a-unattached',
|
||||
'1:db:a-db',
|
||||
'2:web:b-web',
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
result.current.removeNotificationsForStack(localNode.id, 'web');
|
||||
});
|
||||
expect(result.current.notifications).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('refetches notifications on scope=notifications invalidate after mount fetch', async () => {
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
|
||||
const nodes = [localNode];
|
||||
const onStateInvalidate = vi.fn();
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
|
||||
const afterMount = (apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c) => c[0] === '/notifications',
|
||||
).length;
|
||||
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate',
|
||||
scope: 'notifications',
|
||||
// No stack-deleted action: this case only asserts refetch delta.
|
||||
nodeId: 1,
|
||||
ts: 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const after = (apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c) => c[0] === '/notifications',
|
||||
).length;
|
||||
expect(after).toBe(afterMount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('remote notifications invalidate triggers one additional remote fetch', async () => {
|
||||
const remote = makeRemoteNode('online', { id: 2, name: 'sencho-sat-qa' });
|
||||
const nodes = [localNode, remote];
|
||||
const onStateInvalidate = vi.fn();
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
|
||||
(fetchForNode as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ id: 9, level: 'error', message: 'remote row', timestamp: 2000, is_read: 0, stack_name: 'db' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }),
|
||||
);
|
||||
await waitFor(() => expect(fetchForNode).toHaveBeenCalledWith('/notifications', 2));
|
||||
const afterMount = (fetchForNode as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c) => c[0] === '/notifications' && c[1] === 2,
|
||||
).length;
|
||||
|
||||
act(() => {
|
||||
MockWS.instances[1]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate',
|
||||
scope: 'notifications',
|
||||
nodeId: 2,
|
||||
ts: 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const after = (fetchForNode as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c) => c[0] === '/notifications' && c[1] === 2,
|
||||
).length;
|
||||
expect(after).toBe(afterMount + 1);
|
||||
});
|
||||
await waitFor(() => expect(result.current.notifications.some((n) => n.nodeId === 2 && n.nodeName === 'sencho-sat-qa')).toBe(true));
|
||||
});
|
||||
|
||||
it('unrelated state-invalidate scopes do not refetch notifications', async () => {
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
|
||||
const nodes = [localNode];
|
||||
renderHook(() =>
|
||||
useNotifications({ nodes, onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
|
||||
const afterMount = (apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(c) => c[0] === '/notifications',
|
||||
).length;
|
||||
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate',
|
||||
scope: 'stack',
|
||||
nodeId: 1,
|
||||
stackName: 'web',
|
||||
action: 'start',
|
||||
ts: 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mock.calls.filter((c) => c[0] === '/notifications').length,
|
||||
).toBe(afterMount);
|
||||
});
|
||||
|
||||
it('remote stack-deleted purge uses hub rn.id when payload nodeId collides with local', async () => {
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
|
||||
(fetchForNode as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => [] });
|
||||
const remote = makeRemoteNode('online', { id: 7, name: 'Remote-7' });
|
||||
const nodes = [localNode, remote];
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes, onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
await waitFor(() => expect(fetchForNode).toHaveBeenCalledWith('/notifications', 7));
|
||||
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
payload: makeNotif({ id: 1, stack_name: 'web', message: 'local-web' }),
|
||||
}),
|
||||
});
|
||||
MockWS.instances[1]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
payload: makeNotif({ id: 2, stack_name: 'web', message: 'remote-web' }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(nodeMessageKeys(result.current.notifications)).toEqual(['1:local-web', '7:remote-web']);
|
||||
|
||||
// Production remotes broadcast their own local DB id (often 1), which collides
|
||||
// with the hub local node. The remote socket must purge hub rn.id=7 only.
|
||||
act(() => {
|
||||
MockWS.instances[1]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate',
|
||||
scope: 'notifications',
|
||||
action: 'stack-deleted',
|
||||
nodeId: 1,
|
||||
stackName: 'web',
|
||||
ts: Date.now(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
expect(nodeMessageKeys(result.current.notifications)).toEqual(['1:local-web']);
|
||||
});
|
||||
|
||||
it('preserves cached notifications for a failed refetch leg after invalidate', async () => {
|
||||
const remote = makeRemoteNode('online', { id: 7, name: 'Remote-7' });
|
||||
// Stable identity so the nodes effect does not re-fetch on every render.
|
||||
const nodes = [localNode, remote];
|
||||
let remoteFail = false;
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 10, level: 'info', message: 'local-kept', timestamp: 5000, is_read: 0, stack_name: 'other' },
|
||||
],
|
||||
});
|
||||
(fetchForNode as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
if (remoteFail) {
|
||||
return Promise.resolve({ ok: false, status: 502, json: async () => [] });
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 20, level: 'error', message: 'remote-web', timestamp: 4000, is_read: 0, stack_name: 'web' },
|
||||
{ id: 21, level: 'info', message: 'remote-db', timestamp: 3000, is_read: 0, stack_name: 'db' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes, onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.notifications.some((n) => n.message === 'remote-web')).toBe(true));
|
||||
expect(nodeMessageKeys(result.current.notifications)).toEqual([
|
||||
'1:local-kept',
|
||||
'7:remote-db',
|
||||
'7:remote-web',
|
||||
]);
|
||||
|
||||
remoteFail = true;
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: 11, level: 'info', message: 'local-kept-v2', timestamp: 6000, is_read: 0, stack_name: 'other' },
|
||||
],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWS.instances[1]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate',
|
||||
scope: 'notifications',
|
||||
action: 'stack-deleted',
|
||||
nodeId: 1,
|
||||
stackName: 'web',
|
||||
ts: Date.now(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for post-invalidate merge (local slice replaced), not only optimistic purge.
|
||||
await waitFor(() => {
|
||||
expect(result.current.notifications.some((n) => n.message === 'local-kept-v2')).toBe(true);
|
||||
});
|
||||
expect(result.current.notifications.some((n) => n.message === 'remote-web')).toBe(false);
|
||||
expect(nodeMessageKeys(result.current.notifications)).toEqual([
|
||||
'1:local-kept-v2',
|
||||
'7:remote-db',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not restore a deleted stack notification from a stale pre-removal fetch', async () => {
|
||||
let resolveStale!: (value: { ok: boolean; status: number; json: () => Promise<unknown[]> }) => void;
|
||||
const stalePromise = new Promise<{ ok: boolean; status: number; json: () => Promise<unknown[]> }>((resolve) => {
|
||||
resolveStale = resolve;
|
||||
});
|
||||
let call = 0;
|
||||
let releaseStale = false;
|
||||
(apiFetch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
call += 1;
|
||||
if (!releaseStale && call >= 2) return stalePromise;
|
||||
if (releaseStale) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{ id: 2, level: 'info', message: 'fresh-db', timestamp: 4000, is_read: 0, stack_name: 'db' },
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => [] });
|
||||
});
|
||||
|
||||
// Stable identity so the nodes effect does not re-fetch on every render.
|
||||
const nodes = [localNode];
|
||||
const onStateInvalidate = vi.fn();
|
||||
const onImageUpdatesChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }),
|
||||
);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
|
||||
const afterMount = call;
|
||||
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
payload: makeNotif({ id: 1, stack_name: 'web', message: 'keep-until-remove' }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(true);
|
||||
|
||||
// Start an in-flight fetch without act: React's act waits on the deferred promise.
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate',
|
||||
scope: 'notifications',
|
||||
action: 'stack-deleted',
|
||||
nodeId: 1,
|
||||
stackName: 'other',
|
||||
ts: Date.now(),
|
||||
}),
|
||||
});
|
||||
await waitFor(() => expect(call).toBeGreaterThan(afterMount));
|
||||
|
||||
act(() => {
|
||||
result.current.removeNotificationsForStack(localNode.id, 'web');
|
||||
});
|
||||
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(false);
|
||||
|
||||
resolveStale({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{ id: 1, level: 'error', message: 'stale-web', timestamp: 3000, is_read: 0, stack_name: 'web' },
|
||||
],
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(false);
|
||||
|
||||
releaseStale = true;
|
||||
act(() => {
|
||||
MockWS.instances[0]?.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'state-invalidate',
|
||||
scope: 'notifications',
|
||||
action: 'stack-deleted',
|
||||
nodeId: 1,
|
||||
stackName: 'web',
|
||||
ts: Date.now(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notifications.some((n) => n.stack_name === 'db' && n.message === 'fresh-db')).toBe(true);
|
||||
});
|
||||
expect(result.current.notifications.some((n) => n.stack_name === 'web')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,12 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
// Its spans instrument only that first fetch so later polls do not pollute the
|
||||
// report.
|
||||
const notificationsReadyRef = useRef(false);
|
||||
// Monotonic generation so a slow pre-removal fetch cannot overwrite state
|
||||
// after removeNotificationsForStack (or a newer fetch) has already run.
|
||||
const notificationFetchGeneration = useRef(0);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
const generation = ++notificationFetchGeneration.current;
|
||||
try {
|
||||
const currentNodes = nodesRef.current;
|
||||
const localNode = currentNodes.find(n => n.type === 'local');
|
||||
@@ -41,7 +45,10 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
const localPromise = apiFetch('/notifications', { localOnly: true } as Parameters<typeof apiFetch>[1]);
|
||||
const remotePromises = remoteNodes.map(n => fetchForNode('/notifications', n.id));
|
||||
|
||||
const all: NotificationItem[] = [];
|
||||
// Only successful legs replace their node slice. A failed/non-OK/decode-failed
|
||||
// leg is not treated as an empty list; that node's cached rows stay until a
|
||||
// later successful response (including an intentional empty list).
|
||||
const successfulSlices = new Map<number, NotificationItem[]>();
|
||||
|
||||
const instrument = !notificationsReadyRef.current;
|
||||
const headersSpan = instrument ? beginSpan('fetch_headers', { background: true }) : null;
|
||||
@@ -55,8 +62,15 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
if (bodySpan !== null) endSpan(bodySpan);
|
||||
bodySpan = null;
|
||||
const dispatchSpan = instrument ? beginSpan('state_dispatch', { background: true }) : null;
|
||||
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
|
||||
const localId = localNode?.id ?? -1;
|
||||
const localName = localNode?.name ?? 'Local';
|
||||
successfulSlices.set(
|
||||
localId,
|
||||
data.map(n => ({ ...n, nodeId: localId, nodeName: localName })),
|
||||
);
|
||||
if (dispatchSpan !== null) endSpan(dispatchSpan);
|
||||
} else {
|
||||
console.error(`[Notifications] local fetch HTTP ${localRes.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
|
||||
@@ -71,17 +85,38 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
|
||||
// Remotes settle in the background; they never gate the milestone above.
|
||||
const remoteNodeResults = await Promise.allSettled(remotePromises);
|
||||
for (let i = 0; i < remoteNodes.length; i++) {
|
||||
for (const [i, rn] of remoteNodes.entries()) {
|
||||
const result = remoteNodeResults[i];
|
||||
if (result?.status === 'fulfilled' && result.value.ok) {
|
||||
if (result.status !== 'fulfilled') {
|
||||
console.error(`[Notifications] remote fetch error (${rn.name}):`, result.reason);
|
||||
continue;
|
||||
}
|
||||
if (!result.value.ok) {
|
||||
console.error(`[Notifications] remote fetch HTTP ${result.value.status} (${rn.name})`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const data = await result.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||||
const rn = remoteNodes[i];
|
||||
data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name }));
|
||||
successfulSlices.set(
|
||||
rn.id,
|
||||
data.map(n => ({ ...n, nodeId: rn.id, nodeName: rn.name })),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[Notifications] remote fetch decode error (${rn.name}):`, e);
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.timestamp - a.timestamp);
|
||||
setNotifications(all);
|
||||
if (generation !== notificationFetchGeneration.current) return;
|
||||
// Keep failed/offline node slices; drop rows for nodes no longer in the roster.
|
||||
const activeNodeIds = new Set(currentNodes.map(n => n.id));
|
||||
setNotifications(prev => {
|
||||
const preserved = prev.filter(n =>
|
||||
n.nodeId == null
|
||||
|| (activeNodeIds.has(n.nodeId) && !successfulSlices.has(n.nodeId)),
|
||||
);
|
||||
const replaced = [...successfulSlices.values()].flat();
|
||||
return [...preserved, ...replaced].sort((a, b) => b.timestamp - a.timestamp);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Notifications] fetch error:', e);
|
||||
}
|
||||
@@ -90,6 +125,33 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
const fetchNotificationsRef = useRef(fetchNotifications);
|
||||
fetchNotificationsRef.current = fetchNotifications;
|
||||
|
||||
const purgeStackNotifications = (nodeId: number, stackName: string) => {
|
||||
notificationFetchGeneration.current += 1;
|
||||
setNotifications(prev =>
|
||||
prev.filter(n => n.nodeId !== nodeId || n.stack_name !== stackName),
|
||||
);
|
||||
};
|
||||
|
||||
const tryPurgeDeletedStackFromInvalidate = (
|
||||
msg: { action?: unknown; nodeId?: unknown; stackName?: unknown },
|
||||
fallbackNodeId?: number,
|
||||
) => {
|
||||
if (msg.action !== 'stack-deleted' || typeof msg.stackName !== 'string') return;
|
||||
const nodeId = typeof msg.nodeId === 'number' ? msg.nodeId : fallbackNodeId;
|
||||
if (nodeId === undefined) return;
|
||||
purgeStackNotifications(nodeId, msg.stackName);
|
||||
};
|
||||
|
||||
const reconcileNotificationsInvalidate = (
|
||||
msg: { action?: unknown; nodeId?: unknown; stackName?: unknown },
|
||||
fallbackNodeId?: number,
|
||||
) => {
|
||||
tryPurgeDeletedStackFromInvalidate(msg, fallbackNodeId);
|
||||
void fetchNotificationsRef.current();
|
||||
};
|
||||
const reconcileNotificationsInvalidateRef = useRef(reconcileNotificationsInvalidate);
|
||||
reconcileNotificationsInvalidateRef.current = reconcileNotificationsInvalidate;
|
||||
|
||||
// Local notification WebSocket with exponential-backoff reconnect.
|
||||
useEffect(() => {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -125,7 +187,9 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
} else if (msg.type === 'state-invalidate') {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
|
||||
onStateInvalidateRef.current();
|
||||
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
|
||||
if (msg.scope === 'notifications') {
|
||||
reconcileNotificationsInvalidateRef.current(msg);
|
||||
} else if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
|
||||
onImageUpdatesChangeRef.current();
|
||||
}
|
||||
}
|
||||
@@ -203,7 +267,11 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
} else if (msg.type === 'state-invalidate') {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: { ...msg, nodeId: rn.id } }));
|
||||
onStateInvalidateRef.current();
|
||||
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
|
||||
if (msg.scope === 'notifications') {
|
||||
// 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') {
|
||||
onImageUpdatesChangeRef.current();
|
||||
}
|
||||
}
|
||||
@@ -324,5 +392,6 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
markAllRead,
|
||||
deleteNotification,
|
||||
clearAllNotifications,
|
||||
removeNotificationsForStack: purgeStackNotifications,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ function setup(over: {
|
||||
activeNode?: Parameters<typeof useStackActions>[0]['activeNode'];
|
||||
setActiveNode?: Parameters<typeof useStackActions>[0]['setActiveNode'];
|
||||
onDeletedOpenStack?: () => void;
|
||||
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
|
||||
} = {}) {
|
||||
const editorState = makeEditorState(over.editorState);
|
||||
const stackListState = makeStackListState(over.stackList);
|
||||
@@ -141,6 +142,7 @@ function setup(over: {
|
||||
const overlayState = makeOverlay(over.overlay);
|
||||
const setActiveNode = over.setActiveNode ?? vi.fn();
|
||||
const onDeletedOpenStack = over.onDeletedOpenStack ?? vi.fn();
|
||||
const removeNotificationsForStack = over.removeNotificationsForStack ?? vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStackActions({
|
||||
@@ -157,9 +159,10 @@ function setup(over: {
|
||||
hasUpdateGuard: over.hasUpdateGuard ?? false,
|
||||
canEditStack: over.canEditStack ?? (() => true),
|
||||
onDeletedOpenStack,
|
||||
removeNotificationsForStack,
|
||||
}),
|
||||
);
|
||||
return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack };
|
||||
return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack, removeNotificationsForStack };
|
||||
}
|
||||
|
||||
describe('useStackActions.saveFile', () => {
|
||||
@@ -1617,4 +1620,40 @@ describe('useStackActions.deleteStack', () => {
|
||||
expect(onDeletedOpenStack).not.toHaveBeenCalled();
|
||||
expect(stackListState.refreshStacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls removeNotificationsForStack with node id and canonical name on success', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const removeNotificationsForStack = vi.fn();
|
||||
const { result } = setup({
|
||||
overlay: { stackToDelete: 'web.yml' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
navState: { activeView: 'editor' },
|
||||
activeNode: { id: 7, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
|
||||
removeNotificationsForStack,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(removeNotificationsForStack).toHaveBeenCalledTimes(1);
|
||||
expect(removeNotificationsForStack).toHaveBeenCalledWith(7, 'web');
|
||||
});
|
||||
|
||||
it('does not call removeNotificationsForStack on a non-OK delete', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response('boom', { status: 500 }));
|
||||
const removeNotificationsForStack = vi.fn();
|
||||
const { result } = setup({
|
||||
overlay: { stackToDelete: 'web.yml' },
|
||||
stackList: { selectedFile: 'web.yml', files: ['web.yml'] },
|
||||
navState: { activeView: 'editor' },
|
||||
removeNotificationsForStack,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteStack(false);
|
||||
});
|
||||
|
||||
expect(removeNotificationsForStack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,12 @@ interface UseStackActionsOptions {
|
||||
* owns that state, and an optional callback would silently skip it.
|
||||
*/
|
||||
onDeletedOpenStack: () => void;
|
||||
/**
|
||||
* Drop in-memory notifications for a deleted stack on the active node.
|
||||
* Caller must pass the node id and canonical stack basename (no .yml/.yaml).
|
||||
* Optional so unit tests that do not exercise delete can omit it.
|
||||
*/
|
||||
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -399,6 +405,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
canEditStack,
|
||||
canOfferVolumeRemoval = false,
|
||||
onDeletedOpenStack,
|
||||
removeNotificationsForStack,
|
||||
} = options;
|
||||
|
||||
const pendingStackLoadRef = useRef<string | null>(null);
|
||||
@@ -1895,6 +1902,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
stackListState.files.find(
|
||||
f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete,
|
||||
) ?? stackToDelete;
|
||||
const canonicalName = deleteKey.replace(/\.(yml|yaml)$/, '');
|
||||
if (stackListState.isStackBusy(deleteKey)) return;
|
||||
stackListState.setStackAction(deleteKey, 'delete');
|
||||
try {
|
||||
@@ -1914,11 +1922,14 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
toast.success('Stack deleted successfully!');
|
||||
overlayState.closeDeleteDialog();
|
||||
const selected = stackListState.selectedFile;
|
||||
const stripExt = (name: string) => name.replace(/\.(yml|yaml)$/, '');
|
||||
const nodeId = activeNode?.id;
|
||||
if (nodeId != null && removeNotificationsForStack) {
|
||||
removeNotificationsForStack(nodeId, canonicalName);
|
||||
}
|
||||
// Always clear a deleted selection, even when another top-level view is
|
||||
// visible (Resources, Networking, etc.). Leaving that view is gated on
|
||||
// the editor being the active surface below.
|
||||
if (selected != null && stripExt(selected) === stripExt(deleteKey)) {
|
||||
if (selected != null && selected.replace(/\.(yml|yaml)$/, '') === canonicalName) {
|
||||
resetEditorState();
|
||||
if (navState.activeView === 'editor') {
|
||||
navState.setActiveView('dashboard');
|
||||
|
||||
Reference in New Issue
Block a user