feat(ui/alerts): Alerts sidebar section with Inbox/All/Rules/Silences/History

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 13:46:49 +02:00
parent 167d0ebd42
commit 54e4217e21
3 changed files with 95 additions and 2 deletions

View File

@@ -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() {
</Sidebar.Section>
</div>
{/* Alerts section */}
<Sidebar.Section
icon={createElement(Bell, { size: 16 })}
label="Alerts"
open={alertsOpen}
onToggle={toggleAlerts}
active={isAlertsPage}
>
<SidebarTree
nodes={alertsTreeNodes}
selectedPath={location.pathname}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="alerts"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
{/* Starred section — only when there are starred items */}
{starredItems.length > 0 && (
<Sidebar.Section