Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/app/pages/alerts/[alertId].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import AlertDetailPage from '@/AlertDetailPage';

export default AlertDetailPage;
File renamed without changes.
269 changes: 269 additions & 0 deletions packages/app/src/AlertDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
AlertSource,
isRangeThresholdType,
} from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Breadcrumbs,
Button,
Container,
Group,
Skeleton,
Stack,
Text,
} from '@mantine/core';
import { IconExternalLink } from '@tabler/icons-react';

import { AckAlert } from '@/components/alerts/AckAlert';
import { AlertDetailChart } from '@/components/alerts/AlertDetailChart';
import {
AlertEvaluationsTable,
AlertStateBadge,
} from '@/components/alerts/AlertEvaluationsTable';
import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
import EmptyState from '@/components/EmptyState';
import { PageHeader } from '@/components/PageHeader';
import { TimePicker } from '@/components/TimePicker';

import { useBrandDisplayName } from './theme/ThemeProvider';
import {
extendDateRangeToInterval,
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
} from './utils/alerts';
import { getWebhookChannelIcon } from './utils/webhookIcons';
import {
AlertNote,
getAlertDisplayName,
getAlertSourceUrl,
} from './AlertsPage';
import api from './api';
import { withAppNav } from './layout';
import { parseTimeQuery, useNewTimeQuery } from './timeQuery';
import type { AlertsPageItem } from './types';

import styles from '@styles/AlertsPage.module.scss';

const DEFAULT_TIME_RANGE_LABEL = 'Past 12h';
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- parseTimeQuery always resolves a fixed relative label
const defaultTimeRange = parseTimeQuery(DEFAULT_TIME_RANGE_LABEL, false) as [
Date,
Date,
];

// Number of evaluation windows in the timeline strip — wider than the
// alerts-page strip so failure/firing patterns over time are visible.
const TIMELINE_ITEMS = 60;

function AlertProperties({ alert }: { alert: AlertsPageItem }) {
const thresholdLabel =
TILE_ALERT_THRESHOLD_TYPE_OPTIONS[alert.thresholdType] ??
alert.thresholdType;

return (
<Stack gap={2}>
<div className="fs-8 d-flex gap-2 align-items-center">
<span>
If value {thresholdLabel}{' '}
<span className="fw-bold">{alert.threshold}</span>
{isRangeThresholdType(alert.thresholdType) && (
<>
{' '}
and <span className="fw-bold">{alert.thresholdMax ?? '-'}</span>
</>
)}
</span>
<span>&middot;</span>
<span>Evaluates every {alert.interval}</span>
{alert.numConsecutiveWindows != null &&
alert.numConsecutiveWindows > 1 && (
<>
<span>&middot;</span>
<span>
Fires after {alert.numConsecutiveWindows} consecutive windows
</span>
</>
)}
<span>&middot;</span>
<Group gap={5}>
Notify via {getWebhookChannelIcon(alert.channel.type)} Webhook
</Group>
{alert.createdBy && (
<>
<span>&middot;</span>
<span>
Created by {alert.createdBy.name || alert.createdBy.email}
</span>
</>
)}
</div>
{alert.note && <AlertNote note={alert.note} />}
</Stack>
);
}

function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
const alertUrl = getAlertSourceUrl(alert);

const [displayedTimeInputValue, setDisplayedTimeInputValue] = React.useState(
DEFAULT_TIME_RANGE_LABEL,
);
const { searchedTimeRange, onSearch } = useNewTimeQuery({
initialDisplayValue: DEFAULT_TIME_RANGE_LABEL,
initialTimeRange: defaultTimeRange,
setDisplayedTimeInputValue,
});

// Ensure the chart always spans at least a useful number of evaluation
// windows for the alert's interval (e.g. a 1d alert widens past 12h).
const chartDateRange = React.useMemo(
() => extendDateRangeToInterval(searchedTimeRange, alert.interval),
[searchedTimeRange, alert.interval],
);

const {
data: evaluationsData,
isLoading: isEvaluationsLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = api.useAlertEvaluations(alert._id);

const evaluations = React.useMemo(
() => evaluationsData?.pages.flatMap(page => page.data) ?? [],
[evaluationsData],
);

return (
<>
<PageHeader
breadcrumbs={
<Breadcrumbs fz="sm">
<Anchor component={Link} href="/alerts" fz="sm" c="dimmed">
Alerts
</Anchor>
<Text fz="sm" c="dimmed">
{getAlertDisplayName(alert) || 'Alert'}
</Text>
</Breadcrumbs>
}
leading={
<Group gap="sm">
{alert.state != null && <AlertStateBadge state={alert.state} />}
<Text fw={500}>{getAlertDisplayName(alert)}</Text>
</Group>
}
actions={
<Group gap="sm" wrap="nowrap">
<AckAlert alert={alert} />
{alertUrl && (
<Button
component={Link}
href={alertUrl}
variant="secondary"
size="compact-sm"
rightSection={<IconExternalLink size={14} />}
>
{alert.source === AlertSource.TILE
? 'Open dashboard tile'
: 'Open saved search'}
</Button>
)}
<TimePicker
inputValue={displayedTimeInputValue}
setInputValue={setDisplayedTimeInputValue}
onSearch={onSearch}
/>
</Group>
}
/>
<div style={{ overflow: 'auto', flexGrow: 1 }}>
<Container size="xl" py="md">
<Stack gap="lg">
<AlertProperties alert={alert} />
<AlertDetailChart
alert={alert}
dateRange={chartDateRange}
alertUrl={alertUrl}
/>
<div>
<Group
className={styles.sectionHeader}
justify="space-between"
mb="sm"
>
<span>Evaluation History</span>
<AlertHistoryCardList
alert={alert}
alertUrl={alertUrl}
history={evaluations}
maxItems={TIMELINE_ITEMS}
showErrorIndicator={false}
/>
</Group>
<AlertEvaluationsTable
evaluations={evaluations}
isLoading={isEvaluationsLoading}
hasNextPage={hasNextPage ?? false}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
</div>
</Stack>
</Container>
</div>
</>
);
}

export default function AlertDetailPage() {
const brandName = useBrandDisplayName();
const router = useRouter();
const alertId =
typeof router.query.alertId === 'string' ? router.query.alertId : undefined;

const { data, isLoading, isError } = api.useAlert(alertId);
const alert = data?.data;

return (
<div
data-testid="alert-detail-page"
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
<Head>
<title>
{alert ? `${getAlertDisplayName(alert)} - Alerts` : 'Alerts'} -{' '}
{brandName}
</title>
</Head>
{isLoading && (
<Container size="xl" py="md" w="100%">
<Stack gap="lg">
<Skeleton h={32} w="40%" />
<Skeleton h={280} w="100%" />
<Skeleton h={160} w="100%" />
</Stack>
</Container>
)}
{!isLoading && (isError || !alert) && (
<Container size="xl" py="md" w="100%">
<EmptyState
variant="card"
title="Alert not found"
description={
<Anchor component={Link} href="/alerts" size="sm">
Back to alerts
</Anchor>
}
/>
</Container>
)}
{!isLoading && alert && <AlertDetailBody alert={alert} />}
</div>
);
}

AlertDetailPage.getLayout = withAppNav;
35 changes: 24 additions & 11 deletions packages/app/src/AlertsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Alert,
Anchor,
Badge,
Button,
Collapse,
Container,
Flex,
Expand Down Expand Up @@ -51,7 +52,7 @@ import type { AlertsPageItem } from './types';

import styles from '@styles/AlertsPage.module.scss';

function getAlertDisplayName(alert: AlertsPageItem): string {
export function getAlertDisplayName(alert: AlertsPageItem): string {
if (alert.source === AlertSource.TILE && alert.dashboard) {
const tile = alert.dashboard.tiles.find(t => t.id === alert.tileId);
const tileName = tile?.config.name || 'Tile';
Expand All @@ -63,6 +64,17 @@ function getAlertDisplayName(alert: AlertsPageItem): string {
return '';
}

/** URL of the saved search / dashboard tile the alert is watching. */
export function getAlertSourceUrl(alert: AlertsPageItem): string {
if (alert.source === AlertSource.TILE && alert.dashboard) {
return `/dashboards/${alert.dashboardId}?highlightedTileId=${alert.tileId}`;
}
if (alert.source === AlertSource.SAVED_SEARCH && alert.savedSearch) {
return `/search/${alert.savedSearchId}`;
}
return '';
}

function getAlertTags(alert: AlertsPageItem): string[] {
return alert.dashboard?.tags ?? alert.savedSearch?.tags ?? [];
}
Expand All @@ -72,7 +84,7 @@ function getAlertCreatorLabel(alert: AlertsPageItem): string | undefined {
return alert.createdBy.name || alert.createdBy.email;
}

function AlertNote({ note }: { note: string }) {
export function AlertNote({ note }: { note: string }) {
const [opened, { toggle }] = useDisclosure(false);

return (
Expand Down Expand Up @@ -147,15 +159,7 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
return '–';
}, [alert]);

const alertUrl = React.useMemo(() => {
if (alert.source === AlertSource.TILE && alert.dashboard) {
return `/dashboards/${alert.dashboardId}?highlightedTileId=${alert.tileId}`;
}
if (alert.source === AlertSource.SAVED_SEARCH && alert.savedSearch) {
return `/search/${alert.savedSearchId}`;
}
return '';
}, [alert]);
const alertUrl = React.useMemo(() => getAlertSourceUrl(alert), [alert]);

const alertIcon = (() => {
switch (alert.source) {
Expand Down Expand Up @@ -268,6 +272,15 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
<Group>
<AlertHistoryCardList alert={alert} alertUrl={alertUrl} />
<AckAlert alert={alert} />
<Button
component={Link}
href={`/alerts/${alert._id}`}
variant="link"
size="compact-sm"
data-testid={`alert-details-link-${alert._id}`}
>
Details
</Button>
</Group>
</div>
);
Expand Down
Loading
Loading