mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user