mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user