feat: Add alert icons to dashboard list page (#2053)

## Summary

This PR adds alert icons to the dashboard page, matching the implementation from the search page. Similarly, alerts icons have been added to favorited dashboards in the sidebar.

### Screenshots or video

<img width="1257" height="796" alt="Screenshot 2026-04-03 at 3 05 42 PM" src="https://github.com/user-attachments/assets/9e3fe31d-b757-46e8-8034-9be80529c96e" />
<img width="245" height="353" alt="Screenshot 2026-04-03 at 3 17 54 PM" src="https://github.com/user-attachments/assets/d7b06536-646d-4bd6-950c-b9087c3b3dbd" />

### How to test locally or on Vercel

This can be tested locally by creating some dashboards, favoriting them, and creating alerts on those dashboards.

### References



- Linear Issue: Closes HDX-3921
- Related PRs:
This commit is contained in:
Drew Davis 2026-04-06 12:21:42 -04:00 committed by GitHub
parent fcc0d5ec63
commit 1bcca2cde6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 167 additions and 67 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Add alert icons to dashboard list page

View file

@ -0,0 +1,31 @@
import { AlertState } from '@hyperdx/common-utils/dist/types';
import { Tooltip } from '@mantine/core';
import { IconBell, IconBellFilled } from '@tabler/icons-react';
export function AlertStatusIcon({
alerts,
}: {
alerts?: { state?: AlertState }[];
}) {
if (!Array.isArray(alerts) || alerts.length === 0) return null;
const alertingCount = alerts.filter(a => a.state === AlertState.ALERT).length;
return (
<Tooltip
label={
alertingCount > 0
? `${alertingCount} alert${alertingCount > 1 ? 's' : ''} triggered`
: 'Alerts configured'
}
>
{alertingCount > 0 ? (
<IconBellFilled
size={14}
color="var(--mantine-color-red-filled)"
data-testid="alert-status-icon-triggered"
/>
) : (
<IconBell size={14} data-testid="alert-status-icon-configured" />
)}
</Tooltip>
);
}

View file

@ -3,7 +3,7 @@ import Link from 'next/link';
import Router, { useRouter } from 'next/router';
import cx from 'classnames';
import HyperDX from '@hyperdx/browser';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
ActionIcon,
Anchor,
@ -18,7 +18,6 @@ import { useDisclosure, useLocalStorage } from '@mantine/hooks';
import {
IconArrowBarToLeft,
IconBell,
IconBellFilled,
IconChartDots,
IconDeviceFloppy,
IconDeviceLaptop,
@ -29,6 +28,7 @@ import {
} from '@tabler/icons-react';
import api from '@/api';
import { AlertStatusIcon } from '@/components/AlertStatusIcon';
import { IS_LOCAL_MODE } from '@/config';
import { Dashboard, useDashboards } from '@/dashboard';
import { useFavorites } from '@/favorites';
@ -217,26 +217,9 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
>
<Group gap={2} wrap="nowrap" align="center">
<div className="text-truncate">{savedSearch.name}</div>
{Array.isArray(savedSearch.alerts) &&
savedSearch.alerts.length > 0 ? (
savedSearch.alerts.some(a => a.state === AlertState.ALERT) ? (
<Flex flex={0}>
<IconBellFilled
size={14}
className="float-end text-danger ms-1"
aria-label="Has Alerts and is in ALERT state"
/>
</Flex>
) : (
<Flex flex={0}>
<IconBell
size={14}
className="float-end ms-1"
aria-label="Has Alerts and is in OK state"
/>
</Flex>
)
) : null}
<Flex flex={0}>
<AlertStatusIcon alerts={savedSearch.alerts} />
</Flex>
</Group>
</Link>
),
@ -244,18 +227,31 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
);
const renderDashboardLink = useCallback(
(dashboard: Dashboard) => (
<Link
href={`/dashboards/${dashboard.id}`}
key={dashboard.id}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]: dashboard.id === query.dashboardId,
})}
>
<div className="text-truncate">{dashboard.name}</div>
</Link>
),
(dashboard: Dashboard) => {
const alerts = dashboard.tiles
.map(t =>
isBuilderSavedChartConfig(t.config) ? t.config.alert : undefined,
)
.filter(a => a != null);
return (
<Link
href={`/dashboards/${dashboard.id}`}
key={dashboard.id}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]: dashboard.id === query.dashboardId,
})}
title={dashboard.name}
>
<Group gap={2} wrap="nowrap" align="center">
<div className="text-truncate">{dashboard.name}</div>
<Flex flex={0}>
<AlertStatusIcon alerts={alerts} />
</Flex>
</Group>
</Link>
);
},
[query.dashboardId],
);

View file

@ -3,10 +3,10 @@ import Head from 'next/head';
import Link from 'next/link';
import Router from 'next/router';
import { useQueryState } from 'nuqs';
import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
ActionIcon,
Anchor,
Box,
Button,
Container,
Flex,
@ -31,12 +31,14 @@ import {
IconUpload,
} from '@tabler/icons-react';
import { AlertStatusIcon } from '@/components/AlertStatusIcon';
import { FavoriteButton } from '@/components/FavoriteButton';
import { ListingCard } from '@/components/ListingCard';
import { ListingRow } from '@/components/ListingListRow';
import { PageHeader } from '@/components/PageHeader';
import { IS_K8S_DASHBOARD_ENABLED } from '@/config';
import {
type Dashboard,
useCreateDashboard,
useDashboards,
useDeleteDashboard,
@ -48,6 +50,14 @@ import { groupByTags } from '@/utils/groupByTags';
import { withAppNav } from '../../layout';
function getDashboardAlerts(tiles: Dashboard['tiles']) {
return tiles
.map(t =>
isBuilderSavedChartConfig(t.config) ? t.config.alert : undefined,
)
.filter(a => a != null);
}
const PRESET_DASHBOARDS = [
{
name: 'Services',
@ -209,6 +219,9 @@ export default function DashboardsListPage() {
tags={d.tags}
description={`${d.tiles.length} ${d.tiles.length === 1 ? 'tile' : 'tiles'}`}
onDelete={() => handleDelete(d.id)}
statusIcon={
<AlertStatusIcon alerts={getDashboardAlerts(d.tiles)} />
}
resourceId={d.id}
resourceType="dashboard"
/>
@ -351,7 +364,7 @@ export default function DashboardsListPage() {
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={20} />
<Table.Th w={40} />
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th w={50} />
@ -367,13 +380,14 @@ export default function DashboardsListPage() {
tags={d.tags}
onDelete={handleDelete}
leftSection={
<Box ps={4}>
<Group gap={0} ps={4} justify="space-between" wrap="nowrap">
<FavoriteButton
resourceType="dashboard"
resourceId={d.id}
size="xs"
/>
</Box>
<AlertStatusIcon alerts={getDashboardAlerts(d.tiles)} />
</Group>
}
/>
))}
@ -395,6 +409,9 @@ export default function DashboardsListPage() {
tags={d.tags}
description={`${d.tiles.length} ${d.tiles.length === 1 ? 'tile' : 'tiles'}`}
onDelete={() => handleDelete(d.id)}
statusIcon={
<AlertStatusIcon alerts={getDashboardAlerts(d.tiles)} />
}
resourceId={d.id}
resourceType="dashboard"
/>

View file

@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from 'react';
import Head from 'next/head';
import Router from 'next/router';
import { useQueryState } from 'nuqs';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Button,
@ -15,19 +14,17 @@ import {
Table,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconBell,
IconBellFilled,
IconLayoutGrid,
IconList,
IconSearch,
IconTable,
} from '@tabler/icons-react';
import { AlertStatusIcon } from '@/components/AlertStatusIcon';
import { FavoriteButton } from '@/components/FavoriteButton';
import { ListingCard } from '@/components/ListingCard';
import { ListingRow } from '@/components/ListingListRow';
@ -35,36 +32,11 @@ import { PageHeader } from '@/components/PageHeader';
import { useFavorites } from '@/favorites';
import { useDeleteSavedSearch, useSavedSearches } from '@/savedSearch';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import type { SavedSearchWithEnhancedAlerts } from '@/types';
import { useConfirm } from '@/useConfirm';
import { groupByTags } from '@/utils/groupByTags';
import { withAppNav } from '../../layout';
function AlertStatusIcon({
alerts,
}: {
alerts?: SavedSearchWithEnhancedAlerts['alerts'];
}) {
if (!Array.isArray(alerts) || alerts.length === 0) return null;
const alertingCount = alerts.filter(a => a.state === AlertState.ALERT).length;
return (
<Tooltip
label={
alertingCount > 0
? `${alertingCount} alert${alertingCount > 1 ? 's' : ''} triggered`
: 'Alerts configured'
}
>
{alertingCount > 0 ? (
<IconBellFilled size={14} color="var(--mantine-color-red-filled)" />
) : (
<IconBell size={14} />
)}
</Tooltip>
);
}
export default function SavedSearchesListPage() {
const brandName = useBrandDisplayName();
const { data: savedSearches, isLoading, isError } = useSavedSearches();

View file

@ -237,4 +237,73 @@ test.describe('Dashboards Listing Page', { tag: ['@dashboard'] }, () => {
});
},
);
test(
'should show alert icon on dashboard card when tile has alert configured',
{ tag: ['@full-stack'] },
async ({ page }) => {
const ts = Date.now();
const dashboardName = `E2E Alert Icon Dashboard ${ts}`;
const tileName = `E2E Alert Tile ${ts}`;
const webhookName = `E2E Webhook Alert Icon ${ts}`;
const webhookUrl = `https://example.com/alert-icon-${ts}`;
await test.step('Create a new saved dashboard', async () => {
await dashboardsListPage.goto();
await dashboardsListPage.createNewDashboard();
});
await test.step('Rename the dashboard', async () => {
await dashboardPage.editDashboardName(dashboardName);
});
await test.step('Add a tile with a chart', async () => {
await dashboardPage.addTile();
await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
await dashboardPage.chartEditor.waitForDataToLoad();
await dashboardPage.chartEditor.setChartName(tileName);
await dashboardPage.chartEditor.runQuery();
});
await test.step('Configure an alert on the tile', async () => {
await expect(dashboardPage.chartEditor.alertButton).toBeVisible();
await dashboardPage.chartEditor.clickAddAlert();
await expect(
dashboardPage.chartEditor.addNewWebhookButton,
).toBeVisible();
await dashboardPage.chartEditor.addNewWebhookButton.click();
await expect(page.getByTestId('webhook-name-input')).toBeVisible();
await dashboardPage.chartEditor.webhookAlertModal.addWebhook(
'Generic',
webhookName,
webhookUrl,
);
await expect(page.getByTestId('alert-modal')).toBeHidden();
await dashboardPage.chartEditor.save();
await expect(dashboardPage.getTiles()).toHaveCount(1, {
timeout: 10000,
});
});
await test.step('Verify alert icon in grid view', async () => {
await dashboardsListPage.goto();
await expect(
dashboardsListPage.getDashboardCard(dashboardName),
).toBeVisible();
await expect(
dashboardsListPage.getAlertStatusIcon(dashboardName),
).toBeVisible();
});
await test.step('Verify alert icon in list view', async () => {
await dashboardsListPage.switchToListView();
await expect(
dashboardsListPage.getDashboardRow(dashboardName),
).toBeVisible();
await expect(
dashboardsListPage.getAlertStatusIconInRow(dashboardName),
).toBeVisible();
});
},
);
});

View file

@ -154,4 +154,14 @@ export class DashboardsListPage {
const card = this.getFavoritedDashboardCard(name);
await card.getByTestId('favorite-button').click();
}
getAlertStatusIcon(name: string) {
const card = this.getDashboardCard(name);
return card.getByTestId(/^alert-status-icon-/);
}
getAlertStatusIconInRow(name: string) {
const row = this.getDashboardRow(name);
return row.getByTestId(/^alert-status-icon-/);
}
}