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:
Anso
2026-07-22 20:00:45 -04:00
committed by GitHub
parent 394e68671d
commit 698b7d0713
9 changed files with 644 additions and 14 deletions
@@ -0,0 +1,132 @@
/**
* DELETE /api/stacks/:stackName must purge notification_history for that
* (node_id, stack_name) so the bell panel, Activity ticker, Stack Activity,
* and Networking recent activity no longer show the deleted stack.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ComposeService: typeof import('../services/ComposeService').ComposeService;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let MeshService: typeof import('../services/MeshService').MeshService;
let NotificationService: typeof import('../services/NotificationService').NotificationService;
let adminCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ ComposeService } = await import('../services/ComposeService'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ MeshService } = await import('../services/MeshService'));
({ NotificationService } = await import('../services/NotificationService'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined);
vi.spyOn(MeshService.getInstance(), 'optOutStack').mockResolvedValue(undefined);
const raw = DatabaseService.getInstance().getDb();
raw.prepare('DELETE FROM notification_history').run();
});
function seedNotif(
nodeId: number,
opts: { stack_name?: string; message?: string; timestamp?: number },
): void {
DatabaseService.getInstance().addNotificationHistory(nodeId, {
level: 'error',
message: opts.message ?? 'test',
timestamp: opts.timestamp ?? Date.now(),
stack_name: opts.stack_name ?? 'web',
category: 'deploy_failure',
});
}
function stackNamesForNode(nodeId: number): Array<string | null> {
const rows = DatabaseService.getInstance()
.getDb()
.prepare('SELECT stack_name FROM notification_history WHERE node_id = ? ORDER BY id')
.all(nodeId) as Array<{ stack_name: string | null }>;
return rows.map((r) => r.stack_name);
}
describe('DELETE /api/stacks/:stackName purges notifications', () => {
it('removes only the target node/stack rows and emits one invalidation', async () => {
const db = DatabaseService.getInstance();
const nodeA = db.getNodes()[0].id;
const nodeB = db.addNode({
name: 'other-node',
type: 'remote',
api_url: 'http://other:1852',
api_token: 't',
compose_dir: '/tmp',
is_default: false,
});
seedNotif(nodeA, { stack_name: 'web', message: 'a-web' });
seedNotif(nodeA, { stack_name: 'db', message: 'a-db' });
// Unattached (null stack_name) via SQL; addNotificationHistory omits null.
db.getDb()
.prepare(
`INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, category)
VALUES (?, 'info', 'a-unattached', ?, 0, NULL, 'system')`,
)
.run(nodeA, Date.now());
seedNotif(nodeB, { stack_name: 'web', message: 'b-web' });
const broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent');
const res = await request(app).delete('/api/stacks/web').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(stackNamesForNode(nodeA).sort((a, b) => String(a).localeCompare(String(b)))).toEqual([
'db',
null,
]);
expect(stackNamesForNode(nodeB)).toEqual(['web']);
const notifInvalidations = broadcastSpy.mock.calls
.map((c) => c[0])
.filter(
(e) =>
e.type === 'state-invalidate' &&
e.scope === 'notifications' &&
e.action === 'stack-deleted',
);
expect(notifInvalidations).toHaveLength(1);
expect(notifInvalidations[0]).toMatchObject({
type: 'state-invalidate',
scope: 'notifications',
action: 'stack-deleted',
nodeId: nodeA,
stackName: 'web',
});
expect(typeof notifInvalidations[0].ts).toBe('number');
});
it('emits no notification invalidation when delete fails before success', async () => {
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockRejectedValue(new Error('fs boom'));
const broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent');
const db = DatabaseService.getInstance();
const nodeId = db.getNodes()[0].id;
seedNotif(nodeId, { stack_name: 'web' });
const res = await request(app).delete('/api/stacks/web').set('Cookie', adminCookie);
expect(res.status).toBe(500);
expect(stackNamesForNode(nodeId)).toEqual(['web']);
const notifInvalidations = broadcastSpy.mock.calls
.map((c) => c[0])
.filter((e) => e.scope === 'notifications' && e.action === 'stack-deleted');
expect(notifInvalidations).toHaveLength(0);
});
});
+6
View File
@@ -4046,6 +4046,12 @@ export class DatabaseService {
stmt.run(nodeId);
}
/** Purge stack-scoped notification history when a stack is deleted. */
public deleteNotificationsForStack(nodeId: number, stackName: string): number {
const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ? AND stack_name = ?');
return stmt.run(nodeId, stackName).changes;
}
public updateNotificationDispatchError(id: number, error: string): void {
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
}
@@ -18,6 +18,7 @@ import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
import { MeshService } from './MeshService';
import { NotificationService } from './NotificationService';
import { StackOpLockService, stackOpSkipMessage } from './StackOpLockService';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
@@ -248,6 +249,7 @@ export class DeployedStackDeletionService {
db.deleteStackExposure(nodeId, stackName);
db.deleteStackProjectEnvFiles(nodeId, stackName);
db.deleteStackScans(nodeId, stackName);
db.deleteNotificationsForStack(nodeId, stackName);
} catch (dbErr) {
console.error(
'[DeployedStackDeletion] Secondary DB cleanup failed for %s; recovery rows already retired:',
@@ -272,6 +274,14 @@ export class DeployedStackDeletionService {
}
await this.sweepReadyIntent(intentId);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'notifications',
action: 'stack-deleted',
nodeId,
stackName,
ts: Date.now(),
});
return { ok: true };
}
+1 -1
View File
@@ -554,7 +554,7 @@ How long Sencho keeps historical data on this node before pruning it.
| Setting | Default | Max | Description |
|---------|---------|-----|-------------|
| **Container metrics** | 24 hrs | 8,760 (1 year) | How long to keep per-container CPU, RAM, and network history for dashboard charts. |
| **Notification log** | 30 days | 365 | How long to keep alert and notification history. |
| **Notification log** | 30 days | 365 | Maximum how long to keep alert and notification history. Stack-associated entries are also removed when that stack is deleted. |
| **Scan history per digest** | 50 scans | 1,000 | How many vulnerability scans to keep per image digest (or per image reference when no digest is stored). Older scans beyond the cap are pruned. |
| **Remove scans for deleted images and stacks** | On | - | When on, scan results are deleted once their image is gone from this node or their stack is deleted, so the Security Overview stays tied to what still exists. Turn it off to keep scan history for removed images and stacks. |
| **Audit log** | 90 days | 365 | How long to keep audit trail entries. Requires Admiral. |
+2
View File
@@ -256,6 +256,7 @@ export default function EditorLayout() {
markAllRead,
deleteNotification,
clearAllNotifications,
removeNotificationsForStack,
} = useNotifications({
nodes,
onStateInvalidate: scheduleStateInvalidateRefresh,
@@ -289,6 +290,7 @@ export default function EditorLayout() {
},
canOfferVolumeRemoval,
onDeletedOpenStack: () => onDeletedOpenStackRef.current(),
removeNotificationsForStack,
});
// Wire the ref now that stackActions is available
@@ -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');