mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
fcc0d5ec63
commit
1bcca2cde6
7 changed files with 167 additions and 67 deletions
5
.changeset/hot-chicken-jam.md
Normal file
5
.changeset/hot-chicken-jam.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add alert icons to dashboard list page
|
||||
31
packages/app/src/components/AlertStatusIcon.tsx
Normal file
31
packages/app/src/components/AlertStatusIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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-/);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue