mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
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.
This commit is contained in:
@@ -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 () => {
|
const fetchImageUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/image-updates');
|
const res = await apiFetch('/image-updates');
|
||||||
|
|||||||
@@ -12,11 +12,20 @@ import type { LucideIcon } from 'lucide-react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { NotificationItem } from './dashboard/types';
|
import type { NotificationItem } from './dashboard/types';
|
||||||
import type { Node } from '@/context/NodeContext';
|
import type { Node } from '@/context/NodeContext';
|
||||||
|
|
||||||
|
const NODE_FILTER_ALL = 'all' as const;
|
||||||
type NotifFilter = 'all' | 'unread' | 'alerts';
|
type NotifFilter = 'all' | 'unread' | 'alerts';
|
||||||
|
type NodeFilter = typeof NODE_FILTER_ALL | number;
|
||||||
|
|
||||||
type LevelConfig = {
|
type LevelConfig = {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
@@ -77,10 +86,16 @@ function formatRelative(ms: number): string {
|
|||||||
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter(items: NotificationItem[], filter: NotifFilter): NotificationItem[] {
|
function applyFilter(
|
||||||
if (filter === 'unread') return items.filter((n) => !n.is_read);
|
items: NotificationItem[],
|
||||||
if (filter === 'alerts') return items.filter((n) => n.level === 'warning' || n.level === 'error');
|
filter: NotifFilter,
|
||||||
return items;
|
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 {
|
interface NotificationPanelProps {
|
||||||
@@ -101,6 +116,7 @@ export function NotificationPanel({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
}: NotificationPanelProps) {
|
}: NotificationPanelProps) {
|
||||||
const [filter, setFilter] = useState<NotifFilter>('all');
|
const [filter, setFilter] = useState<NotifFilter>('all');
|
||||||
|
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const unreadCount = useMemo(
|
const unreadCount = useMemo(
|
||||||
@@ -114,7 +130,20 @@ export function NotificationPanel({
|
|||||||
return ids;
|
return ids;
|
||||||
}, [nodes]);
|
}, [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 groups = useMemo(() => groupByDay(filtered), [filtered]);
|
||||||
|
|
||||||
const filterOptions = useMemo(
|
const filterOptions = useMemo(
|
||||||
@@ -205,7 +234,35 @@ export function NotificationPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter segment */}
|
{/* Filter segment */}
|
||||||
<div className="flex items-center justify-end border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
|
<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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showNodeFilter ? (
|
||||||
|
<Select
|
||||||
|
value={effectiveNodeFilter === NODE_FILTER_ALL ? NODE_FILTER_ALL : String(effectiveNodeFilter)}
|
||||||
|
onValueChange={(v) => setNodeFilter(v === NODE_FILTER_ALL ? NODE_FILTER_ALL : Number(v))}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NODE_FILTER_ALL} className="font-mono text-[10px] uppercase tracking-[0.14em]">
|
||||||
|
All nodes
|
||||||
|
</SelectItem>
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={String(n.id)} className="font-mono text-[10px] uppercase tracking-[0.14em]">
|
||||||
|
{n.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={filter}
|
value={filter}
|
||||||
options={filterOptions}
|
options={filterOptions}
|
||||||
|
|||||||
Reference in New Issue
Block a user