From 54e4217e21bd39de0eadae21cd337a1e3c4a3449 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:46:49 +0200 Subject: [PATCH] feat(ui/alerts): Alerts sidebar section with Inbox/All/Rules/Silences/History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `buildAlertsTreeNodes` to sidebar-utils and renders an Alerts section between Applications and Starred in LayoutShell. The section uses an accordion pattern — entering `/alerts/*` collapses apps/admin/starred and restores their state on leave. gitnexus_impact(LayoutContent, upstream) = LOW (0 direct callers; rendered only by LayoutShell's provider wrapper). Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/components/LayoutShell.tsx | 64 ++++++++++++++++++++++++- ui/src/components/sidebar-utils.test.ts | 17 +++++++ ui/src/components/sidebar-utils.ts | 16 +++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 ui/src/components/sidebar-utils.test.ts diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index c9bf83af..4f54c878 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -21,7 +21,7 @@ import { } from '@cameleer/design-system'; import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, ExchangeStatus } from '@cameleer/design-system'; import sidebarLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; -import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff } from 'lucide-react'; +import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff, Bell } from 'lucide-react'; import { AboutMeDialog } from './AboutMeDialog'; import css from './LayoutShell.module.css'; import { useQueryClient } from '@tanstack/react-query'; @@ -42,6 +42,7 @@ import { formatDuration } from '../utils/format-utils'; import { buildAppTreeNodes, buildAdminTreeNodes, + buildAlertsTreeNodes, formatCount, readCollapsed, writeCollapsed, @@ -278,6 +279,7 @@ const STATUS_ITEMS: ButtonGroupItem[] = [ const SK_APPS = 'sidebar:section:apps'; const SK_ADMIN = 'sidebar:section:admin'; +const SK_ALERTS = 'sidebar:section:alerts'; const SK_COLLAPSED = 'sidebar:collapsed'; /* ------------------------------------------------------------------ */ @@ -325,6 +327,7 @@ function LayoutContent() { // --- Admin search data (only fetched on admin pages) ---------------- const isAdminPage = location.pathname.startsWith('/admin'); + const isAlertsPage = location.pathname.startsWith('/alerts'); const { data: adminUsers } = useUsers(isAdminPage); const { data: adminGroups } = useGroups(isAdminPage); const { data: adminRoles } = useRoles(isAdminPage); @@ -367,8 +370,9 @@ function LayoutContent() { }, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]); // --- Section open states ------------------------------------------ - const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true)); + const [appsOpen, setAppsOpen] = useState(() => (isAdminPage || isAlertsPage) ? false : readCollapsed(SK_APPS, true)); const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false)); + const [alertsOpen, setAlertsOpen] = useState(() => isAlertsPage ? true : readCollapsed(SK_ALERTS, false)); const [starredOpen, setStarredOpen] = useState(true); // Accordion: entering admin collapses apps + starred; leaving restores @@ -388,6 +392,36 @@ function LayoutContent() { prevAdminRef.current = isAdminPage; }, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps + // Accordion: entering alerts collapses apps + admin + starred; leaving restores + const opsAlertsStateRef = useRef({ apps: appsOpen, admin: adminOpen, starred: starredOpen }); + const prevAlertsRef = useRef(isAlertsPage); + useEffect(() => { + if (isAlertsPage && !prevAlertsRef.current) { + opsAlertsStateRef.current = { apps: appsOpen, admin: adminOpen, starred: starredOpen }; + setAppsOpen(false); + setAdminOpen(false); + setStarredOpen(false); + setAlertsOpen(true); + } else if (!isAlertsPage && prevAlertsRef.current) { + setAppsOpen(opsAlertsStateRef.current.apps); + setAdminOpen(opsAlertsStateRef.current.admin); + setStarredOpen(opsAlertsStateRef.current.starred); + setAlertsOpen(false); + } + prevAlertsRef.current = isAlertsPage; + }, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps + + const toggleAlerts = useCallback(() => { + if (!isAlertsPage) { + navigate('/alerts/inbox'); + return; + } + setAlertsOpen((prev) => { + writeCollapsed(SK_ALERTS, !prev); + return !prev; + }); + }, [isAlertsPage, navigate]); + const toggleApps = useCallback(() => { if (isAdminPage) { navigate('/exchanges'); @@ -469,6 +503,11 @@ function LayoutContent() { [capabilities?.infrastructureEndpoints], ); + const alertsTreeNodes: SidebarTreeNode[] = useMemo( + () => buildAlertsTreeNodes(), + [], + ); + // --- Starred items ------------------------------------------------ const starredItems = useMemo( () => collectStarredItems(sidebarApps, starredIds), @@ -482,6 +521,7 @@ function LayoutContent() { if (!sidebarRevealPath) return; if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true); if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true); + if (sidebarRevealPath.startsWith('/alerts') && !alertsOpen) setAlertsOpen(true); }, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps // Normalize path so sidebar highlights the app regardless of which tab is active. @@ -771,6 +811,26 @@ function LayoutContent() { + {/* Alerts section */} + + + + {/* Starred section — only when there are starred items */} {starredItems.length > 0 && ( { + it('returns 5 entries with inbox/all/rules/silences/history paths', () => { + const nodes = buildAlertsTreeNodes(); + expect(nodes).toHaveLength(5); + const paths = nodes.map((n) => n.path); + expect(paths).toEqual([ + '/alerts/inbox', + '/alerts/all', + '/alerts/rules', + '/alerts/silences', + '/alerts/history', + ]); + }); +}); diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index abfbe32d..12aad938 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -1,5 +1,6 @@ import { createElement, type ReactNode } from 'react'; import type { SidebarTreeNode } from '@cameleer/design-system'; +import { AlertTriangle, Inbox, List, ScrollText, BellOff } from 'lucide-react'; /* ------------------------------------------------------------------ */ /* Domain types (moved out of DS — no longer exported there) */ @@ -113,3 +114,18 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean } ]; return nodes; } + +/** + * Alerts tree — static nodes for the alerting section. + * Paths: /alerts/{inbox|all|rules|silences|history} + */ +export function buildAlertsTreeNodes(): SidebarTreeNode[] { + const icon = (el: ReactNode) => el; + return [ + { id: 'alerts-inbox', label: 'Inbox', path: '/alerts/inbox', icon: icon(createElement(Inbox, { size: 14 })) }, + { id: 'alerts-all', label: 'All', path: '/alerts/all', icon: icon(createElement(List, { size: 14 })) }, + { id: 'alerts-rules', label: 'Rules', path: '/alerts/rules', icon: icon(createElement(AlertTriangle, { size: 14 })) }, + { id: 'alerts-silences', label: 'Silences', path: '/alerts/silences', icon: icon(createElement(BellOff, { size: 14 })) }, + { id: 'alerts-history', label: 'History', path: '/alerts/history', icon: icon(createElement(ScrollText, { size: 14 })) }, + ]; +}