mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user