fix(frontend): tighten bell notification panel toolbar (#929)

* fix(frontend): tighten bell notification panel toolbar

Restructure the popover so the segmented filter (All / Unread / Alerts)
and the action icons (filter toggle, mark all read, clear all) share a
single row inside the 360px panel. The previous layout wrapped the
segmented control onto a second row once the type filter was added.

Move the unread badge to the right of the title, collapse the node and
type dropdowns behind a filter-toggle icon (accent dot signals an active
filter), equalize both Select widths, and extract the duplicated
className strings into local constants.

* docs: refresh bell notification popover screenshot

Reflects the reworked toolbar (segmented filter + filter toggle +
mark-all-read + clear-all on a single row).
This commit is contained in:
Anso
2026-05-05 09:36:49 -04:00
committed by GitHub
parent 6f45a3b788
commit a85af40cb5
3 changed files with 104 additions and 72 deletions
+9 -7
View File
@@ -101,19 +101,21 @@ All dispatched notifications appear in the **notification bell** in the top-righ
<img src="/images/alerts-notifications/notification-popover.png" alt="Notification popover showing recent alert entries with level badges and timestamps" /> <img src="/images/alerts-notifications/notification-popover.png" alt="Notification popover showing recent alert entries with level badges and timestamps" />
</Frame> </Frame>
Click the bell to open the notification popover, which shows: Click the bell to open the notification popover. Each entry shows:
- **Level badge** for each entry (ERROR in red, WARNING in amber, INFO in default) - **Level badge** (ERROR in red, WARNING in amber, INFO in default)
- **Node name** badge for notifications from remote nodes - **Node name** badge for notifications from remote nodes
- **Timestamp** of when the alert was triggered - **Timestamp** of when the alert was triggered
- **Alert message** describing what was breached - **Alert message** describing what was breached
The popover header includes: A toolbar above the list provides filtering and bulk actions:
| Action | What it does | | Control | What it does |
|--------|--------------| |---------|--------------|
| **Mark all as read** | Marks all notifications as read (removes the red dot) | | **All / Unread / Alerts** | Switches the list between every notification, unread only, or alert-level only |
| **Clear all** | Deletes all notifications from the list | | **Filter toggle** | Reveals dropdowns to filter by node and by notification type; an accent dot on the icon indicates an active filter |
| **Mark all as read** | Marks every notification as read (removes the red dot) |
| **Clear all** | Deletes every notification from the list |
You can also dismiss individual notifications by hovering over them and clicking the dismiss button. You can also dismiss individual notifications by hovering over them and clicking the dismiss button.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 52 KiB

+95 -65
View File
@@ -7,6 +7,8 @@ import {
AlertOctagon, AlertOctagon,
X, X,
Trash2, Trash2,
SlidersHorizontal,
CheckCheck,
} from 'lucide-react'; } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -30,6 +32,9 @@ type NotifFilter = 'all' | 'unread' | 'alerts';
type NodeFilter = typeof NODE_FILTER_ALL | number; type NodeFilter = typeof NODE_FILTER_ALL | number;
type CategoryFilter = typeof CATEGORY_FILTER_ALL | NotificationCategory; type CategoryFilter = typeof CATEGORY_FILTER_ALL | NotificationCategory;
const FILTER_LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.14em]';
const FILTER_TRIGGER_CLASS = `h-7 w-[140px] border-card-border bg-card px-2 text-stat-subtitle shadow-none focus:ring-0 ${FILTER_LABEL_CLASS}`;
type LevelConfig = { type LevelConfig = {
icon: LucideIcon; icon: LucideIcon;
iconClass: string; iconClass: string;
@@ -136,6 +141,10 @@ export function NotificationPanel({
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL); const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL); const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const hasActiveFilters =
nodeFilter !== NODE_FILTER_ALL || categoryFilter !== CATEGORY_FILTER_ALL;
const unreadCount = useMemo( const unreadCount = useMemo(
() => notifications.filter((n) => !n.is_read).length, () => notifications.filter((n) => !n.is_read).length,
@@ -215,25 +224,59 @@ export function NotificationPanel({
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" /> <div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
<div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]"> <div className="relative flex items-center justify-between px-[var(--density-row-x)] py-[var(--density-tile-y)]">
<div className="flex items-baseline gap-2.5"> <span className="font-display text-xl italic leading-none text-stat-value">
<span className="font-display text-xl italic leading-none text-stat-value"> Notifications
Notifications </span>
{unreadCount > 0 ? (
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] tabular-nums text-brand">
{unreadCount} unread
</span> </span>
{unreadCount > 0 ? ( ) : null}
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] tabular-nums text-brand"> </div>
{unreadCount} unread </div>
</span>
{/* Filter segment */}
<div className="space-y-2 border-t border-card-border/60 px-[var(--density-row-x)] py-[var(--density-row-y)]">
<div className="flex items-center gap-2">
<SegmentedControl
value={filter}
options={filterOptions}
onChange={setFilter}
ariaLabel="Filter notifications"
/>
<div className="ml-auto flex items-center gap-0.5">
{notifications.length > 0 ? (
<Button
variant="ghost"
size="icon"
onClick={() => setShowFilters((v) => !v)}
className={cn(
'relative h-7 w-7 hover:text-stat-value',
hasActiveFilters ? 'text-brand' : 'text-stat-subtitle',
)}
title={showFilters ? 'Hide filters' : 'Show filters'}
aria-pressed={showFilters}
aria-label="Toggle filters"
>
<SlidersHorizontal className="h-3.5 w-3.5" strokeWidth={1.5} />
{hasActiveFilters ? (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand"
/>
) : null}
</Button>
) : null} ) : null}
</div>
<div className="flex items-center gap-0.5">
{unreadCount > 0 ? ( {unreadCount > 0 ? (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={onMarkAllRead} onClick={onMarkAllRead}
className="h-7 px-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle hover:text-stat-value" className="h-7 w-7 text-stat-subtitle hover:text-stat-value"
title="Mark all read"
aria-label="Mark all read"
> >
Mark read <CheckCheck className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button> </Button>
) : null} ) : null}
{notifications.length > 0 ? ( {notifications.length > 0 ? (
@@ -243,68 +286,55 @@ export function NotificationPanel({
onClick={onClearAll} onClick={onClearAll}
className="h-7 w-7 text-stat-subtitle hover:text-destructive" className="h-7 w-7 text-stat-subtitle hover:text-destructive"
title="Clear all" title="Clear all"
aria-label="Clear all notifications"
> >
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} /> <Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
</div> {showFilters ? (
<div className="flex items-center gap-2">
{/* Filter segment */} {showNodeFilter ? (
<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)]"> <Select
{showNodeFilter ? ( value={effectiveNodeFilter === NODE_FILTER_ALL ? NODE_FILTER_ALL : String(effectiveNodeFilter)}
<Select onValueChange={(v) => setNodeFilter(v === NODE_FILTER_ALL ? NODE_FILTER_ALL : Number(v))}
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={FILTER_TRIGGER_CLASS}>
> <SelectValue />
<SelectTrigger </SelectTrigger>
aria-label="Filter by node" <SelectContent>
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" <SelectItem value={NODE_FILTER_ALL} className={FILTER_LABEL_CLASS}>
All nodes
</SelectItem>
{nodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)} className={FILTER_LABEL_CLASS}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Select
value={categoryFilter}
onValueChange={(v) => setCategoryFilter(v as CategoryFilter)}
> >
<SelectValue /> <SelectTrigger aria-label="Filter by category" className={FILTER_TRIGGER_CLASS}>
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value={NODE_FILTER_ALL} className="font-mono text-[10px] uppercase tracking-[0.14em]"> <SelectContent>
All nodes <SelectItem value={CATEGORY_FILTER_ALL} className={FILTER_LABEL_CLASS}>
</SelectItem> All types
{nodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)} className="font-mono text-[10px] uppercase tracking-[0.14em]">
{n.name}
</SelectItem> </SelectItem>
))} {(Object.keys(CATEGORY_LABELS) as NotificationCategory[]).map((cat) => (
</SelectContent> <SelectItem key={cat} value={cat} className={FILTER_LABEL_CLASS}>
</Select> {CATEGORY_LABELS[cat]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null} ) : null}
<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> </div>
{/* Stream */} {/* Stream */}