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
+53 -16
View File
@@ -20,12 +20,28 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { NotificationItem } from './dashboard/types';
import type { NotificationCategory, NotificationItem } from './dashboard/types';
import type { Node } from '@/context/NodeContext';
const NODE_FILTER_ALL = 'all' as const;
const CATEGORY_FILTER_ALL = 'all' as const;
type NotifFilter = 'all' | 'unread' | 'alerts';
type NodeFilter = typeof NODE_FILTER_ALL | number;
type CategoryFilter = typeof CATEGORY_FILTER_ALL | NotificationCategory;
const CATEGORY_LABELS: Record<NotificationCategory, string> = {
deploy_success: 'Deploy success',
deploy_failure: 'Deploy failure',
stack_started: 'Stack started',
stack_stopped: 'Stack stopped',
stack_restarted: 'Stack restarted',
image_update_available: 'Update available',
image_update_applied: 'Update applied',
autoheal_triggered: 'Auto-heal',
monitor_alert: 'Monitor alert',
scan_finding: 'Scan finding',
system: 'System',
};
type LevelConfig = {
icon: LucideIcon;
@@ -90,11 +106,13 @@ function applyFilter(
items: NotificationItem[],
filter: NotifFilter,
nodeFilter: NodeFilter,
categoryFilter: CategoryFilter,
): NotificationItem[] {
let result = items;
if (filter === 'unread') result = result.filter((n) => !n.is_read);
else if (filter === 'alerts') result = result.filter((n) => n.level === 'warning' || n.level === 'error');
if (nodeFilter !== NODE_FILTER_ALL) result = result.filter((n) => n.nodeId === nodeFilter);
if (categoryFilter !== CATEGORY_FILTER_ALL) result = result.filter((n) => n.category === categoryFilter);
return result;
}
@@ -117,6 +135,7 @@ export function NotificationPanel({
}: NotificationPanelProps) {
const [filter, setFilter] = useState<NotifFilter>('all');
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
const [open, setOpen] = useState(false);
const unreadCount = useMemo(
@@ -141,8 +160,8 @@ export function NotificationPanel({
: NODE_FILTER_ALL;
const filtered = useMemo(
() => applyFilter(notifications, filter, effectiveNodeFilter),
[notifications, filter, effectiveNodeFilter],
() => applyFilter(notifications, filter, effectiveNodeFilter, categoryFilter),
[notifications, filter, effectiveNodeFilter, categoryFilter],
);
const groups = useMemo(() => groupByDay(filtered), [filtered]);
@@ -234,12 +253,7 @@ export function NotificationPanel({
</div>
{/* Filter segment */}
<div
className={cn(
'flex items-center gap-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]',
showNodeFilter ? 'justify-between' : 'justify-end',
)}
>
<div className="flex flex-wrap items-center gap-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
{showNodeFilter ? (
<Select
value={effectiveNodeFilter === NODE_FILTER_ALL ? NODE_FILTER_ALL : String(effectiveNodeFilter)}
@@ -247,7 +261,7 @@ export function NotificationPanel({
>
<SelectTrigger
aria-label="Filter by node"
className="h-7 w-[140px] border-card-border bg-card px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle shadow-none focus:ring-0"
className="h-7 w-[120px] border-card-border bg-card px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle shadow-none focus:ring-0"
>
<SelectValue />
</SelectTrigger>
@@ -263,12 +277,35 @@ export function NotificationPanel({
</SelectContent>
</Select>
) : null}
<SegmentedControl
value={filter}
options={filterOptions}
onChange={setFilter}
ariaLabel="Filter notifications"
/>
<Select
value={categoryFilter}
onValueChange={(v) => setCategoryFilter(v as CategoryFilter)}
>
<SelectTrigger
aria-label="Filter by category"
className="h-7 w-[130px] border-card-border bg-card px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle shadow-none focus:ring-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CATEGORY_FILTER_ALL} className="font-mono text-[10px] uppercase tracking-[0.14em]">
All types
</SelectItem>
{(Object.keys(CATEGORY_LABELS) as NotificationCategory[]).map((cat) => (
<SelectItem key={cat} value={cat} className="font-mono text-[10px] uppercase tracking-[0.14em]">
{CATEGORY_LABELS[cat]}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto">
<SegmentedControl
value={filter}
options={filterOptions}
onChange={setFilter}
ariaLabel="Filter notifications"
/>
</div>
</div>
{/* Stream */}
@@ -43,9 +43,23 @@ export interface MetricPoint {
net_tx_mb: number;
}
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';
export interface NotificationItem {
id: number;
level: 'info' | 'warning' | 'error';
category?: NotificationCategory | string;
message: string;
timestamp: number;
is_read: number;