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 })) },
+ ];
+}