From 3e1fb76bd0c7b8bce66a556618bbae96473af82f Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 20 Apr 2026 21:37:34 -0400 Subject: [PATCH] feat(notifications): add per-node filter and 60s refetch safety net (#717) Two polish improvements to the aggregated notifications inbox: - Per-node filter dropdown in the bell panel (hidden on single-node installs) so fleet operators can triage events from a specific box. Selected node falls back to "All nodes" automatically if that node is removed from the registry. - 60-second safety-net poll that reconciles the list so events missed during a WebSocket reconnect backoff appear without a manual refresh. Uses a ref indirection to pin the interval to the latest fetchNotifications closure. --- frontend/src/components/EditorLayout.tsx | 11 +++ frontend/src/components/NotificationPanel.tsx | 69 +++++++++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 11abceb6..e5382772 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -817,6 +817,17 @@ export default function EditorLayout() { } }; + // Safety-net poll: reconciles the list every 60s so events missed during a + // WebSocket reconnect backoff still appear without a manual refresh. The ref + // indirection keeps the interval pinned to the latest closure even though + // fetchNotifications is redefined on every render. + const fetchNotificationsRef = useRef(fetchNotifications); + fetchNotificationsRef.current = fetchNotifications; + useEffect(() => { + const id = setInterval(() => { fetchNotificationsRef.current(); }, 60_000); + return () => clearInterval(id); + }, []); + const fetchImageUpdates = async () => { try { const res = await apiFetch('/image-updates'); diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index b12da6e7..dab20304 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -12,11 +12,20 @@ import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { SegmentedControl } from '@/components/ui/segmented-control'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { cn } from '@/lib/utils'; import type { NotificationItem } from './dashboard/types'; import type { Node } from '@/context/NodeContext'; +const NODE_FILTER_ALL = 'all' as const; type NotifFilter = 'all' | 'unread' | 'alerts'; +type NodeFilter = typeof NODE_FILTER_ALL | number; type LevelConfig = { icon: LucideIcon; @@ -77,10 +86,16 @@ function formatRelative(ms: number): string { return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } -function applyFilter(items: NotificationItem[], filter: NotifFilter): NotificationItem[] { - if (filter === 'unread') return items.filter((n) => !n.is_read); - if (filter === 'alerts') return items.filter((n) => n.level === 'warning' || n.level === 'error'); - return items; +function applyFilter( + items: NotificationItem[], + filter: NotifFilter, + nodeFilter: NodeFilter, +): 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); + return result; } interface NotificationPanelProps { @@ -101,6 +116,7 @@ export function NotificationPanel({ onNavigate, }: NotificationPanelProps) { const [filter, setFilter] = useState('all'); + const [nodeFilter, setNodeFilter] = useState(NODE_FILTER_ALL); const [open, setOpen] = useState(false); const unreadCount = useMemo( @@ -114,7 +130,20 @@ export function NotificationPanel({ return ids; }, [nodes]); - const filtered = useMemo(() => applyFilter(notifications, filter), [notifications, filter]); + const showNodeFilter = nodes.length > 1; + + // Derive the effective filter at render time so a removed node falls back + // to "all" without needing a state-syncing effect (which the + // react-hooks/set-state-in-effect rule forbids). + const effectiveNodeFilter: NodeFilter = + nodeFilter === NODE_FILTER_ALL || nodes.some((n) => n.id === nodeFilter) + ? nodeFilter + : NODE_FILTER_ALL; + + const filtered = useMemo( + () => applyFilter(notifications, filter, effectiveNodeFilter), + [notifications, filter, effectiveNodeFilter], + ); const groups = useMemo(() => groupByDay(filtered), [filtered]); const filterOptions = useMemo( @@ -205,7 +234,35 @@ export function NotificationPanel({ {/* Filter segment */} -
+
+ {showNodeFilter ? ( + + ) : null}