feat(notifications): add structured category enum to dispatcher and history (#774)

Introduce a NotificationCategory string-literal union (11 values) and
thread it through dispatchAlert as a required second argument. All
callers (DockerEventService, AutoHealService, ImageUpdateService,
MonitorService, PolicyEnforcement, policyGate, SchedulerService,
imageUpdates route) pass an explicit category at every call site,
giving TypeScript compile-time enforcement that no new emit site can
be added without choosing a category.

DatabaseService gains an idempotent migration that adds a nullable
category TEXT column to notification_history; existing rows keep
category=NULL (displayed as Uncategorized in the UI). The
getNotificationHistory method accepts an optional category filter
that is forwarded from the GET /api/notifications/history route via
a ?category= query param.

NotificationPanel gains a category Select dropdown so users can
filter history by category. The frontend types mirror the backend
union so API responses are type-safe end-to-end.

All 75 test files (1410 tests) updated to the new 4-arg dispatchAlert
signature and passing.
This commit is contained in:
Anso
2026-04-25 13:55:07 -04:00
committed by GitHub
parent a74564fd61
commit 44dba59cab
20 changed files with 250 additions and 134 deletions
@@ -124,9 +124,9 @@ describe('DockerEventService - die classification', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'web',
expect.objectContaining({ containerName: 'web' }),
);
});
@@ -149,7 +149,7 @@ describe('DockerEventService - die classification', () => {
await vi.advanceTimersByTimeAsync(600);
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('Crash'));
typeof c[2] === 'string' && c[2].includes('Crash'));
expect(crashCall).toBeUndefined();
});
@@ -174,7 +174,7 @@ describe('DockerEventService - die classification', () => {
await vi.advanceTimersByTimeAsync(400); // total > 500ms
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('Crash'));
typeof c[2] === 'string' && c[2].includes('Crash'));
expect(crashCall).toBeUndefined();
});
@@ -192,9 +192,9 @@ describe('DockerEventService - die classification', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('OOM Kill'),
undefined,
'hog',
expect.objectContaining({ containerName: 'hog' }),
);
});
@@ -225,9 +225,9 @@ describe('DockerEventService - die classification', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Healthcheck failed'),
undefined,
'api',
expect.objectContaining({ containerName: 'api' }),
);
});
@@ -291,15 +291,15 @@ describe('DockerEventService - rate limiting', () => {
await vi.advanceTimersByTimeAsync(600);
const crashCalls = mockDispatchAlert.mock.calls.filter(c =>
typeof c[1] === 'string' && c[1].includes('Crash'));
typeof c[2] === 'string' && c[2].includes('Crash'));
expect(crashCalls).toHaveLength(20);
// After the rate window, a summary warning fires.
await vi.advanceTimersByTimeAsync(61_000);
const summaryCalls = mockDispatchAlert.mock.calls.filter(c =>
typeof c[1] === 'string' && c[1].includes('additional containers crashed'));
typeof c[2] === 'string' && c[2].includes('additional containers crashed'));
expect(summaryCalls).toHaveLength(1);
expect(summaryCalls[0][1]).toContain('2 additional');
expect(summaryCalls[0][2]).toContain('2 additional');
});
});
@@ -320,9 +320,9 @@ describe('DockerEventService - malformed payloads', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'ok',
expect.objectContaining({ containerName: 'ok' }),
);
});
});
@@ -375,7 +375,7 @@ describe('DockerEventService - reconciliation', () => {
await Promise.resolve();
const massCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('daemon interruption'));
typeof c[2] === 'string' && c[2].includes('daemon interruption'));
expect(massCall).toBeDefined();
});
@@ -415,9 +415,9 @@ describe('DockerEventService - reconciliation', () => {
await Promise.resolve();
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('crashed-app'));
typeof c[2] === 'string' && c[2].includes('crashed-app'));
expect(crashCall).toBeDefined();
expect(crashCall?.[2]).toBe('my-stack');
expect(crashCall?.[3]).toMatchObject({ stackName: 'my-stack' });
});
});
@@ -443,8 +443,8 @@ describe('DockerEventService - reconnect', () => {
const warn = mockDispatchAlert.mock.calls.find(c => c[0] === 'warning');
const info = mockDispatchAlert.mock.calls.find(c => c[0] === 'info');
expect(warn?.[1]).toContain('Lost connection');
expect(info?.[1]).toContain('Reconnected');
expect(warn?.[2]).toContain('Lost connection');
expect(info?.[2]).toContain('Reconnected');
});
it('shutdown cancels pending reconnect', async () => {
@@ -504,9 +504,9 @@ describe('DockerEventService - hardening', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'app',
expect.objectContaining({ containerName: 'app' }),
);
});
@@ -532,9 +532,9 @@ describe('DockerEventService - hardening', () => {
await Promise.resolve();
const oomCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('OOM Kill'));
typeof c[2] === 'string' && c[2].includes('OOM Kill'));
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('Crash Detected'));
typeof c[2] === 'string' && c[2].includes('Crash Detected'));
expect(oomCall).toBeDefined();
expect(crashCall).toBeUndefined();
});
@@ -558,9 +558,9 @@ describe('DockerEventService - hardening', () => {
// Inspect failed, so classification stays as the original 'crash'.
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'ephemeral',
expect.objectContaining({ containerName: 'ephemeral' }),
);
});
@@ -585,9 +585,9 @@ describe('DockerEventService - hardening', () => {
await vi.advanceTimersByTimeAsync(700);
const crashCalls = mockDispatchAlert.mock.calls.filter(c =>
typeof c[1] === 'string' && c[1].includes('Crash Detected'));
typeof c[2] === 'string' && c[2].includes('Crash Detected'));
expect(crashCalls).toHaveLength(1);
expect(crashCalls[0][1]).toContain('Code: 2');
expect(crashCalls[0][2]).toContain('Code: 2');
});
});