diff --git a/frontend/public/components/__tests__/notification-drawer.spec.tsx b/frontend/public/components/__tests__/notification-drawer.spec.tsx new file mode 100644 index 00000000000..c7abebeb320 --- /dev/null +++ b/frontend/public/components/__tests__/notification-drawer.spec.tsx @@ -0,0 +1,113 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Route, Routes } from 'react-router-dom-v5-compat'; +import { NamespaceContext } from '@console/app/src/components/detect-namespace/namespace'; +import type { Alert } from '@console/dynamic-plugin-sdk/src/api/common-types'; +import { AlertStates, RuleStates } from '@console/dynamic-plugin-sdk/src/api/common-types'; +import { ALL_NAMESPACES_KEY } from '@console/shared/src/constants/common'; +import { useNotificationAlerts } from '@console/shared/src/hooks/useNotificationAlerts'; +import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import { NotificationDrawer } from '../notification-drawer'; + +jest.mock('@console/shared/src/hooks/useNotificationAlerts', () => ({ + useNotificationAlerts: jest.fn(() => [[], true, null]), +})); + +jest.mock('@console/shared/src/hooks/useCanClusterUpgrade', () => ({ + useCanClusterUpgrade: jest.fn(() => false), +})); + +jest.mock('@console/dynamic-plugin-sdk/src/api/useResolvedExtensions', () => ({ + useResolvedExtensions: jest.fn(() => [[], true]), +})); + +// Importing the Console plugin store starts plugins that require webpack's populated share scope. +// The drawer resolves no alert action extensions, so skip application startup in Jest. +jest.mock('@console/dynamic-plugin-sdk/src/runtime/plugin-init', () => ({ + initConsolePlugins: jest.fn(), +})); + +const useNotificationAlertsMock = useNotificationAlerts as jest.Mock; + +const makeAlerts = (count: number, severity: string): Alert[] => + Array.from({ length: count }, (_, i) => ({ + activeAt: '2025-01-01T00:00:00Z', + annotations: { description: `Alert ${i}` }, + labels: { alertname: `TestAlert${i}`, severity }, + rule: { + id: `rule-${i}`, + name: `TestAlert${i}`, + alerts: [], + annotations: {}, + duration: 0, + labels: {}, + query: 'vector(1)', + state: RuleStates.Firing, + type: 'alerting', + }, + state: AlertStates.Firing, + })); + +const renderDrawer = (setNamespace = jest.fn()) => + renderWithProviders( + + + + } + /> + Alerts} /> + + , + ); + +const severities: [string, string][] = [ + ['critical', 'Notifications in the critical alerts group'], + ['warning', 'Notifications in the other alerts group'], +]; + +severities.forEach(([severity, groupName]) => { + describe(`NotificationDrawer ${severity} alert cap`, () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + [5, 50].forEach((count) => { + it(`renders all ${count} alerts without an overflow entry`, async () => { + useNotificationAlertsMock.mockReturnValue([makeAlerts(count, severity), true, null]); + renderDrawer(); + + const group = within(await screen.findByRole('list', { name: groupName })); + expect(group.getAllByText(/^Alert \d+$/)).toHaveLength(count); + expect(group.queryByText(/View all/)).not.toBeInTheDocument(); + }); + }); + + it('caps rendered alerts at 50 and shows the full count in the overflow entry', async () => { + useNotificationAlertsMock.mockReturnValue([makeAlerts(200, severity), true, null]); + renderDrawer(); + + const group = within(await screen.findByRole('list', { name: groupName })); + expect(group.getAllByText(/^Alert \d+$/)).toHaveLength(50); + expect(group.getByText('View all 200 alerts')).toBeVisible(); + }); + + it('selects all namespaces when opening the full alert list', () => { + const setNamespace = jest.fn(); + useNotificationAlertsMock.mockReturnValue([makeAlerts(200, severity), true, null]); + renderDrawer(setNamespace); + + const group = within(screen.getByRole('list', { name: groupName })); + userEvent.click(group.getByText('View all 200 alerts')); + + expect(setNamespace).toHaveBeenCalledWith(ALL_NAMESPACES_KEY); + expect(screen.getByRole('heading', { name: 'Alerts' })).toBeVisible(); + }); + }); +}); diff --git a/frontend/public/components/dashboard/dashboards-page/cluster-dashboard/status-card.tsx b/frontend/public/components/dashboard/dashboards-page/cluster-dashboard/status-card.tsx index 78a988b0f93..ca1d6a46f85 100644 --- a/frontend/public/components/dashboard/dashboards-page/cluster-dashboard/status-card.tsx +++ b/frontend/public/components/dashboard/dashboards-page/cluster-dashboard/status-card.tsx @@ -20,7 +20,15 @@ import { useResolvedExtensions, WatchK8sResource, } from '@console/dynamic-plugin-sdk'; -import { Gallery, GalleryItem, Card, CardHeader, CardTitle } from '@patternfly/react-core'; +import { + Gallery, + GalleryItem, + Card, + CardHeader, + CardTitle, + CardFooter, + Divider, +} from '@patternfly/react-core'; import { BlueArrowCircleUpIcon } from '@console/shared/src/components/status/icons'; import { ALL_NAMESPACES_KEY, FLAGS } from '@console/shared/src/constants/common'; import { useCanClusterUpgrade } from '@console/shared/src/hooks/useCanClusterUpgrade'; @@ -84,8 +92,11 @@ const cvResource: WatchK8sResource = { isList: false, }; +const MAX_ALERTS = 50; + export const DashboardAlerts: React.FC = ({ labelSelector }) => { const { t } = useTranslation(); + const [, setActiveNamespace] = useActiveNamespace(); const hasCVResource = useFlag(FLAGS.CLUSTER_VERSION); const [alerts, , loadError] = useNotificationAlerts(labelSelector); const [cv, cvLoaded] = useK8sWatchResource( @@ -106,9 +117,19 @@ export const DashboardAlerts: React.FC = ({ labelSelector {t('public~Update cluster')} )} - {alerts.map((alert) => ( + {alerts.slice(0, MAX_ALERTS).map((alert) => ( ))} + {alerts.length > MAX_ALERTS && ( + <> + + + setActiveNamespace(ALL_NAMESPACES_KEY)}> + {t('public~View all {{count}} alerts', { count: alerts.length })} + + + + )} ); }; @@ -116,13 +137,24 @@ export const DashboardAlerts: React.FC = ({ labelSelector export const DashboardNamespacedAlerts: React.FC = ({ namespace, }) => { + const { t } = useTranslation(); const [namespacedAlerts, , loadError] = useNamespacedNotificationAlerts(namespace); return ( - {namespacedAlerts.map((alert) => ( + {namespacedAlerts.slice(0, MAX_ALERTS).map((alert) => ( ))} + {namespacedAlerts.length > MAX_ALERTS && ( + <> + + + + {t('public~View all {{count}} alerts', { count: namespacedAlerts.length })} + + + + )} ); }; diff --git a/frontend/public/components/notification-drawer.tsx b/frontend/public/components/notification-drawer.tsx index bde6d9ada5f..2776f84ed0f 100644 --- a/frontend/public/components/notification-drawer.tsx +++ b/frontend/public/components/notification-drawer.tsx @@ -80,6 +80,10 @@ import { LabelSelector } from '@console/internal/module/k8s/label-selector'; import { useNotificationAlerts } from '@console/shared/src/hooks/useNotificationAlerts'; import { useModal } from '@console/dynamic-plugin-sdk/src/lib-core'; import { NotificationTypes } from './utils/types'; +import { ALL_NAMESPACES_KEY } from '@console/shared/src/constants/common'; +import { useActiveNamespace } from '@console/shared/src/hooks/useActiveNamespace'; + +const MAX_ALERTS_PER_GROUP = 50; const AlertErrorState: FC = ({ errorText }) => { const { t } = useTranslation(); @@ -241,6 +245,7 @@ export const NotificationDrawer: FC = ({ drawerRef, }) => { const { t } = useTranslation(); + const [, setActiveNamespace] = useActiveNamespace(); const clusterID = getClusterID(useClusterVersion()); const showServiceLevelNotification = useShowServiceLevelNotifications(clusterID); const pluginInfoEntries = usePluginInfo(); @@ -264,6 +269,10 @@ export const NotificationDrawer: FC = ({ toggleNotificationDrawer(); navigate(location); }; + const viewAllAlerts = () => { + setActiveNamespace(ALL_NAMESPACES_KEY); + itemOnClick('/monitoring/alerts'); + }; const alertActionExtensionsMap = useMemo(() => getAlertActions(alertActionExtensions, navigate), [ alertActionExtensions, @@ -349,7 +358,7 @@ export const NotificationDrawer: FC = ({ aria-label={t('public~Notifications in the critical alerts group')} > {criticalAlerts.length > 0 - ? criticalAlerts.map((alert, i) => { + ? criticalAlerts.slice(0, MAX_ALERTS_PER_GROUP).map((alert, i) => { const alertVariant = NotificationTypes[getAlertSeverity(alert)]; const alertTime = getAlertTime(alert); return ( @@ -377,6 +386,14 @@ export const NotificationDrawer: FC = ({ ); }) : emptyState} + {criticalAlerts.length > MAX_ALERTS_PER_GROUP && ( + + + + )} ); @@ -395,7 +412,7 @@ export const NotificationDrawer: FC = ({ isHidden={!isNonCriticalAlertExpanded} aria-label={t('public~Notifications in the other alerts group')} > - {nonCriticalAlerts.map((alert, i) => { + {nonCriticalAlerts.slice(0, MAX_ALERTS_PER_GROUP).map((alert, i) => { const alertVariant = NotificationTypes[getAlertSeverity(alert)]; const alertTime = getAlertTime(alert); return ( @@ -422,6 +439,14 @@ export const NotificationDrawer: FC = ({ ); })} + {nonCriticalAlerts.length > MAX_ALERTS_PER_GROUP && ( + + + + )} ) : null; diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index b01eda6e317..582e72622ad 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -442,6 +442,8 @@ "Operator status": "Operator status", "Cluster inventory": "Cluster inventory", "A cluster version update is available": "A cluster version update is available", + "View all {{count}} alerts_one": "View {{count}} alert", + "View all {{count}} alerts_other": "View all {{count}} alerts", "View alerts": "View alerts", "Filter by Node type": "Filter by Node type", "Cluster utilization": "Cluster utilization",