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:
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, ExchangeStatus } 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 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 { AboutMeDialog } from './AboutMeDialog';
|
||||||
import css from './LayoutShell.module.css';
|
import css from './LayoutShell.module.css';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -42,6 +42,7 @@ import { formatDuration } from '../utils/format-utils';
|
|||||||
import {
|
import {
|
||||||
buildAppTreeNodes,
|
buildAppTreeNodes,
|
||||||
buildAdminTreeNodes,
|
buildAdminTreeNodes,
|
||||||
|
buildAlertsTreeNodes,
|
||||||
formatCount,
|
formatCount,
|
||||||
readCollapsed,
|
readCollapsed,
|
||||||
writeCollapsed,
|
writeCollapsed,
|
||||||
@@ -278,6 +279,7 @@ const STATUS_ITEMS: ButtonGroupItem[] = [
|
|||||||
|
|
||||||
const SK_APPS = 'sidebar:section:apps';
|
const SK_APPS = 'sidebar:section:apps';
|
||||||
const SK_ADMIN = 'sidebar:section:admin';
|
const SK_ADMIN = 'sidebar:section:admin';
|
||||||
|
const SK_ALERTS = 'sidebar:section:alerts';
|
||||||
const SK_COLLAPSED = 'sidebar:collapsed';
|
const SK_COLLAPSED = 'sidebar:collapsed';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -325,6 +327,7 @@ function LayoutContent() {
|
|||||||
|
|
||||||
// --- Admin search data (only fetched on admin pages) ----------------
|
// --- Admin search data (only fetched on admin pages) ----------------
|
||||||
const isAdminPage = location.pathname.startsWith('/admin');
|
const isAdminPage = location.pathname.startsWith('/admin');
|
||||||
|
const isAlertsPage = location.pathname.startsWith('/alerts');
|
||||||
const { data: adminUsers } = useUsers(isAdminPage);
|
const { data: adminUsers } = useUsers(isAdminPage);
|
||||||
const { data: adminGroups } = useGroups(isAdminPage);
|
const { data: adminGroups } = useGroups(isAdminPage);
|
||||||
const { data: adminRoles } = useRoles(isAdminPage);
|
const { data: adminRoles } = useRoles(isAdminPage);
|
||||||
@@ -367,8 +370,9 @@ function LayoutContent() {
|
|||||||
}, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]);
|
}, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]);
|
||||||
|
|
||||||
// --- Section open states ------------------------------------------
|
// --- 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 [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false));
|
||||||
|
const [alertsOpen, setAlertsOpen] = useState(() => isAlertsPage ? true : readCollapsed(SK_ALERTS, false));
|
||||||
const [starredOpen, setStarredOpen] = useState(true);
|
const [starredOpen, setStarredOpen] = useState(true);
|
||||||
|
|
||||||
// Accordion: entering admin collapses apps + starred; leaving restores
|
// Accordion: entering admin collapses apps + starred; leaving restores
|
||||||
@@ -388,6 +392,36 @@ function LayoutContent() {
|
|||||||
prevAdminRef.current = isAdminPage;
|
prevAdminRef.current = isAdminPage;
|
||||||
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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(() => {
|
const toggleApps = useCallback(() => {
|
||||||
if (isAdminPage) {
|
if (isAdminPage) {
|
||||||
navigate('/exchanges');
|
navigate('/exchanges');
|
||||||
@@ -469,6 +503,11 @@ function LayoutContent() {
|
|||||||
[capabilities?.infrastructureEndpoints],
|
[capabilities?.infrastructureEndpoints],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const alertsTreeNodes: SidebarTreeNode[] = useMemo(
|
||||||
|
() => buildAlertsTreeNodes(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// --- Starred items ------------------------------------------------
|
// --- Starred items ------------------------------------------------
|
||||||
const starredItems = useMemo(
|
const starredItems = useMemo(
|
||||||
() => collectStarredItems(sidebarApps, starredIds),
|
() => collectStarredItems(sidebarApps, starredIds),
|
||||||
@@ -482,6 +521,7 @@ function LayoutContent() {
|
|||||||
if (!sidebarRevealPath) return;
|
if (!sidebarRevealPath) return;
|
||||||
if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true);
|
if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true);
|
||||||
if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true);
|
if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true);
|
||||||
|
if (sidebarRevealPath.startsWith('/alerts') && !alertsOpen) setAlertsOpen(true);
|
||||||
}, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Normalize path so sidebar highlights the app regardless of which tab is active.
|
// Normalize path so sidebar highlights the app regardless of which tab is active.
|
||||||
@@ -771,6 +811,26 @@ function LayoutContent() {
|
|||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
</div>
|
</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 */}
|
{/* Starred section — only when there are starred items */}
|
||||||
{starredItems.length > 0 && (
|
{starredItems.length > 0 && (
|
||||||
<Sidebar.Section
|
<Sidebar.Section
|
||||||
|
|||||||
17
ui/src/components/sidebar-utils.test.ts
Normal file
17
ui/src/components/sidebar-utils.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildAlertsTreeNodes } from './sidebar-utils';
|
||||||
|
|
||||||
|
describe('buildAlertsTreeNodes', () => {
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createElement, type ReactNode } from 'react';
|
import { createElement, type ReactNode } from 'react';
|
||||||
import type { SidebarTreeNode } from '@cameleer/design-system';
|
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) */
|
/* Domain types (moved out of DS — no longer exported there) */
|
||||||
@@ -113,3 +114,18 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
|
|||||||
];
|
];
|
||||||
return nodes;
|
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 })) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user