Skip to content

Commit db6ee45

Browse files
feat(app): unify alert actions and make the alert source legible (#3015)
1 parent df4a7a5 commit db6ee45

14 files changed

Lines changed: 358 additions & 111 deletions

‎.changeset/alert-detail-ux.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@hyperdx/app': minor
3+
---
4+
5+
feat(alerts): tidy the alert detail header and its properties block
6+
7+
Edit, Delete and Terraform export move behind the same overflow menu the
8+
alerts list uses, so the header no longer spreads four buttons across the top
9+
and both surfaces offer the same actions. The link to what the alert watches
10+
becomes an icon beside the alert name, where it reads as part of the alert's
11+
identity rather than another action.
12+
13+
The properties block splits configuration from provenance: the creator now
14+
sits with the created and updated timestamps in a dimmed line beneath, instead
15+
of competing with the alert's settings.

‎.changeset/alerts-summary-ux.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@hyperdx/app': minor
3+
---
4+
5+
feat(alerts): edit from the alerts list, filter by alert source, and label the source icons
6+
7+
The alerts page row menu now opens the alert editor directly, so changing a
8+
threshold no longer means navigating to the alert first. The source icon on
9+
each row gets a tooltip and accessible label naming what it watches ("Saved
10+
search" / "Dashboard tile"), and a new filter narrows the list by that source
11+
— free-text search matches it too, so typing "tile" works without touching the
12+
dropdown. Team settings tabs gain icons.

‎packages/app/src/AlertDetailPage.tsx‎

Lines changed: 40 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,37 @@ import * as React from 'react';
22
import Head from 'next/head';
33
import Link from 'next/link';
44
import { useRouter } from 'next/router';
5-
import { AlertInterval, AlertSource } from '@hyperdx/common-utils/dist/types';
5+
import { AlertInterval } from '@hyperdx/common-utils/dist/types';
66
import {
7+
ActionIcon,
78
Anchor,
89
Breadcrumbs,
9-
Button,
1010
Container,
1111
Group,
1212
Skeleton,
1313
Stack,
1414
Text,
15+
Tooltip,
1516
} from '@mantine/core';
16-
import { notifications } from '@mantine/notifications';
17-
import { IconExternalLink, IconPencil } from '@tabler/icons-react';
18-
import { useQueryClient } from '@tanstack/react-query';
17+
import { IconExternalLink } from '@tabler/icons-react';
1918

2019
import { AckAlert } from '@/components/alerts/AckAlert';
2120
import { AlertDetailChart } from '@/components/alerts/AlertDetailChart';
2221
import { AlertDetailProperties } from '@/components/alerts/AlertDetailProperties';
2322
import { AlertNote } from '@/components/alerts/AlertDetails';
2423
import { AlertEvaluationsTable } from '@/components/alerts/AlertEvaluationsTable';
2524
import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
25+
import { AlertRowMenu } from '@/components/alerts/AlertRowMenu';
2626
import { AlertStateBadge } from '@/components/alerts/AlertStateBadge';
27-
import { EditAlertModal } from '@/components/alerts/EditAlertModal';
28-
import ConfirmDeleteMenu from '@/components/ConfirmDeleteMenu';
2927
import EmptyState from '@/components/EmptyState';
3028
import { PageHeader } from '@/components/PageHeader';
3129
import { TimePicker } from '@/components/TimePicker';
3230
import { IS_ALERT_DETAILS_ENABLED } from '@/config';
33-
import { getAlertDisplayName, getAlertSourceUrl } from '@/utils/alerts';
31+
import {
32+
getAlertDisplayName,
33+
getAlertSourceLabel,
34+
getAlertSourceUrl,
35+
} from '@/utils/alerts';
3436

3537
import { useBrandDisplayName } from './theme/ThemeProvider';
3638
import api from './api';
@@ -80,35 +82,7 @@ function AlertProperties({ alert }: { alert: AlertsPageItem }) {
8082

8183
function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
8284
const alertUrl = getAlertSourceUrl(alert);
83-
const brandName = useBrandDisplayName();
8485
const router = useRouter();
85-
const queryClient = useQueryClient();
86-
const deleteAlert = api.useDeleteAlert();
87-
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
88-
89-
const onDeleteAlert = React.useCallback(async () => {
90-
try {
91-
await deleteAlert.mutateAsync(alert._id);
92-
notifications.show({
93-
color: 'green',
94-
message: 'Alert deleted!',
95-
autoClose: 5000,
96-
});
97-
// The alerts list and the source-bound edit surfaces (saved search
98-
// modal / dashboard tile editor) all render this alert.
99-
queryClient.invalidateQueries({ queryKey: api.getAlertsQueryKey() });
100-
queryClient.invalidateQueries({ queryKey: ['saved-search'] });
101-
queryClient.invalidateQueries({ queryKey: ['dashboards'] });
102-
router.push('/alerts');
103-
} catch (error) {
104-
console.error('Failed to delete alert:', error);
105-
notifications.show({
106-
color: 'red',
107-
message: `Something went wrong. Please contact ${brandName} team.`,
108-
autoClose: 5000,
109-
});
110-
}
111-
}, [alert._id, brandName, deleteAlert, queryClient, router]);
11286

11387
// Interval-dependent, but fixed for the page lifetime: the body only
11488
// mounts once the alert has loaded, and useNewTimeQuery reads the initial
@@ -169,34 +143,41 @@ function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
169143
<Group gap="sm">
170144
{alert.state != null && <AlertStateBadge state={alert.state} />}
171145
<Text fw={500}>{getAlertDisplayName(alert)}</Text>
146+
{alertUrl && (
147+
/* Next to the name rather than in the actions row: it navigates
148+
to what the alert watches, so it belongs with the identity,
149+
not with the verbs acting on the alert. */
150+
<Tooltip
151+
label={`Open ${getAlertSourceLabel(alert).toLowerCase()}`}
152+
withArrow
153+
>
154+
<ActionIcon
155+
component={Link}
156+
href={alertUrl}
157+
variant="subtle"
158+
size="md"
159+
aria-label={`Open ${getAlertSourceLabel(alert).toLowerCase()}`}
160+
data-testid="open-alert-source"
161+
>
162+
<IconExternalLink size={16} />
163+
</ActionIcon>
164+
</Tooltip>
165+
)}
172166
</Group>
173167
}
174168
actions={
175169
<Group gap="sm" wrap="nowrap">
176170
<AckAlert alert={alert} />
177-
<Button
178-
data-testid="edit-alert-button"
179-
variant="secondary"
180-
size="compact-sm"
181-
leftSection={<IconPencil size={14} />}
182-
onClick={() => setIsEditModalOpen(true)}
183-
>
184-
Edit alert
185-
</Button>
186-
<ConfirmDeleteMenu onDelete={onDeleteAlert} />
187-
{alertUrl && (
188-
<Button
189-
component={Link}
190-
href={alertUrl}
191-
variant="secondary"
192-
size="compact-sm"
193-
rightSection={<IconExternalLink size={14} />}
194-
>
195-
{alert.source === AlertSource.TILE
196-
? 'Open dashboard tile'
197-
: 'Open saved search'}
198-
</Button>
199-
)}
171+
{/* Edit, Terraform export and Delete live behind one overflow
172+
control, shared with the alerts list so both surfaces offer the
173+
same actions. The menu's own source-link item is suppressed:
174+
this page carries that link next to the title instead. */}
175+
<AlertRowMenu
176+
alert={alert}
177+
alertName={getAlertDisplayName(alert)}
178+
dateRange={searchedTimeRange}
179+
onDeleted={() => router.push('/alerts')}
180+
/>
200181
<TimePicker
201182
inputValue={displayedTimeInputValue}
202183
setInputValue={setDisplayedTimeInputValue}
@@ -205,12 +186,6 @@ function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
205186
</Group>
206187
}
207188
/>
208-
<EditAlertModal
209-
alert={alert}
210-
opened={isEditModalOpen}
211-
onClose={() => setIsEditModalOpen(false)}
212-
dateRange={searchedTimeRange}
213-
/>
214189
<div style={{ overflow: 'auto', flexGrow: 1 }}>
215190
<Container size="xl" py="md">
216191
<Stack gap="lg">

‎packages/app/src/AlertsPage.tsx‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { PageHeader } from '@/components/PageHeader';
2222
import {
2323
getAlertCreatorLabel,
2424
getAlertDisplayName,
25+
getAlertSourceLabel,
2526
getAlertTags,
2627
} from '@/utils/alerts';
2728

@@ -38,6 +39,7 @@ export default function AlertsPage() {
3839
const [search, setSearch] = useQueryState('search');
3940
const [tagFilter, setTagFilter] = useQueryState('tag');
4041
const [creatorFilter, setCreatorFilter] = useQueryState('creator');
42+
const [sourceFilter, setSourceFilter] = useQueryState('alertSource');
4143

4244
const allTags = React.useMemo(() => {
4345
const tags = new Set<string>();
@@ -54,8 +56,19 @@ export default function AlertsPage() {
5456
return Array.from(creators).sort();
5557
}, [alerts]);
5658

59+
// Only the source types actually present, so the filter never offers an
60+
// option that yields an empty list.
61+
const allSources = React.useMemo(() => {
62+
const sources = new Set<string>();
63+
alerts.forEach(a => sources.add(getAlertSourceLabel(a)));
64+
return Array.from(sources).sort();
65+
}, [alerts]);
66+
5767
const filteredAlerts = React.useMemo(() => {
5868
let result = alerts;
69+
if (sourceFilter) {
70+
result = result.filter(a => getAlertSourceLabel(a) === sourceFilter);
71+
}
5972
if (tagFilter) {
6073
result = result.filter(a => getAlertTags(a).includes(tagFilter));
6174
}
@@ -67,13 +80,24 @@ export default function AlertsPage() {
6780
result = result.filter(
6881
a =>
6982
getAlertDisplayName(a).toLowerCase().includes(q) ||
83+
// So "tile" / "saved search" narrow the list the same way the type
84+
// filter does, without having to reach for the dropdown.
85+
getAlertSourceLabel(a)
86+
.toLowerCase()
87+
.split(' ')
88+
.some(word => word.startsWith(q)) ||
7089
getAlertTags(a).some(t => t.toLowerCase().includes(q)),
7190
);
7291
}
7392
return result;
74-
}, [alerts, search, tagFilter, creatorFilter]);
93+
}, [alerts, search, tagFilter, creatorFilter, sourceFilter]);
7594

76-
const hasFilters = !!(search?.trim() || tagFilter || creatorFilter);
95+
const hasFilters = !!(
96+
search?.trim() ||
97+
tagFilter ||
98+
creatorFilter ||
99+
sourceFilter
100+
);
77101

78102
return (
79103
<div
@@ -121,6 +145,23 @@ export default function AlertsPage() {
121145
miw={100}
122146
data-testid="alerts-search-input"
123147
/>
148+
{(allSources.length > 1 || sourceFilter) && (
149+
<Select
150+
placeholder="Filter by alert source"
151+
// A filter carried in from the URL may name a source no
152+
// current alert has; keep it selectable so it can be cleared.
153+
data={
154+
sourceFilter && !allSources.includes(sourceFilter)
155+
? [...allSources, sourceFilter]
156+
: allSources
157+
}
158+
value={sourceFilter}
159+
onChange={v => setSourceFilter(v)}
160+
clearable
161+
style={{ maxWidth: 220 }}
162+
data-testid="alerts-source-filter"
163+
/>
164+
)}
124165
{allTags.length > 0 && (
125166
<Select
126167
placeholder="Filter by tag"

‎packages/app/src/TeamPage.tsx‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ import {
1414
TextInput,
1515
} from '@mantine/core';
1616
import { notifications } from '@mantine/notifications';
17-
import { IconPencil } from '@tabler/icons-react';
17+
import {
18+
IconAdjustmentsHorizontal,
19+
IconApi,
20+
IconDatabase,
21+
IconPencil,
22+
IconPlug,
23+
IconShieldLock,
24+
IconUsers,
25+
} from '@tabler/icons-react';
1826

1927
import { PageHeader } from './components/PageHeader';
2028
import ApiKeysSection from './components/TeamSettings/ApiKeysSection';
@@ -34,6 +42,7 @@ import { APP_CONTENT_SCROLL_CONTAINER_ID, withAppNav } from './layout';
3442
type TeamTab = {
3543
value: string;
3644
label: string;
45+
icon: ReactNode;
3746
sections: {
3847
id: string;
3948
// Always a function of whether this tab is the visible one. Mantine `Tabs`
@@ -106,6 +115,7 @@ export default function TeamPage() {
106115
{
107116
value: 'data',
108117
label: 'Data',
118+
icon: <IconDatabase size={16} />,
109119
sections: [
110120
{
111121
id: 'team-data-sources',
@@ -120,6 +130,7 @@ export default function TeamPage() {
120130
{
121131
value: 'team',
122132
label: 'Members',
133+
icon: <IconUsers size={16} />,
123134
sections: [
124135
{
125136
id: 'team-members',
@@ -132,6 +143,7 @@ export default function TeamPage() {
132143
{
133144
value: 'access',
134145
label: 'Access',
146+
icon: <IconShieldLock size={16} />,
135147
sections: [
136148
{
137149
id: 'team-access-security-policies',
@@ -148,6 +160,7 @@ export default function TeamPage() {
148160
{
149161
value: 'api-agents',
150162
label: 'API & Agents',
163+
icon: <IconApi size={16} />,
151164
sections: [
152165
{
153166
id: 'team-api-agents-api-keys',
@@ -172,6 +185,7 @@ export default function TeamPage() {
172185
{
173186
value: 'integrations',
174187
label: 'Integrations',
188+
icon: <IconPlug size={16} />,
175189
sections: [
176190
{
177191
id: 'team-integrations-webhooks',
@@ -182,6 +196,7 @@ export default function TeamPage() {
182196
{
183197
value: 'advanced',
184198
label: 'Query Settings',
199+
icon: <IconAdjustmentsHorizontal size={16} />,
185200
sections: [
186201
{
187202
id: 'team-advanced-query-settings',
@@ -333,7 +348,11 @@ export default function TeamPage() {
333348
<Tabs value={activeTab} onChange={handleTabChange}>
334349
<Tabs.List>
335350
{tabs.map(tab => (
336-
<Tabs.Tab key={tab.value} value={tab.value}>
351+
<Tabs.Tab
352+
key={tab.value}
353+
value={tab.value}
354+
leftSection={tab.icon}
355+
>
337356
{tab.label}
338357
</Tabs.Tab>
339358
))}

0 commit comments

Comments
 (0)