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
+18 -3
View File
@@ -4,6 +4,19 @@ import { NodeRegistry } from './NodeRegistry';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
export type NotificationCategory =
| 'deploy_success'
| 'deploy_failure'
| 'stack_started'
| 'stack_stopped'
| 'stack_restarted'
| 'image_update_available'
| 'image_update_applied'
| 'autoheal_triggered'
| 'monitor_alert'
| 'scan_finding'
| 'system';
/** Webhook timeout: 10 seconds per external dispatch call. */
const WEBHOOK_TIMEOUT_MS = 10_000;
@@ -83,16 +96,18 @@ export class NotificationService {
*/
public async dispatchAlert(
level: 'info' | 'warning' | 'error',
category: NotificationCategory,
message: string,
stackName?: string,
containerName?: string,
options?: { stackName?: string; containerName?: string },
) {
const { stackName, containerName } = options ?? {};
// Internal writes use the middleware default so they share a row key
// with user-initiated requests; otherwise the UI and monitors split
// between different node_id buckets.
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
const notification = this.dbService.addNotificationHistory(localNodeId, {
level,
category,
message,
timestamp: Date.now(),
stack_name: stackName,
@@ -105,7 +120,7 @@ export class NotificationService {
// 3. Check notification routing rules if a stack context is available
const errors: string[] = [];
if (stackName) {
if (stackName !== undefined) {
const routes = this.dbService.getEnabledNotificationRoutes();
const matched = routes.filter(r => r.stack_patterns.includes(stackName));
if (matched.length > 0) {