Skip to content

Commit b951b3b

Browse files
feat(app): edit alerts from the list, filter by alert source, label source icons
The alerts page row menu now opens the alert editor, so changing a threshold no longer means navigating into the alert first. The modal needs a range for its threshold preview; a list row has none, so it derives one from the alert's interval when the modal opens. Each row's source icon gains a tooltip and accessible label naming what the alert watches, a new filter narrows the list by that source, and free-text search matches it too. All three read one getAlertSourceLabel helper so their wording cannot drift. Named 'alert source' rather than 'type' (taken by detection type) or bare 'source' (reads as a data source). The creator moves out of the shared properties line into its own dimmed sub-line: it is provenance, not configuration, and at equal weight it pushed the line into a second row that broke mid-phrase. Remaining segments no longer wrap mid-phrase. Team settings tabs gain icons.
1 parent 0558f77 commit b951b3b

10 files changed

Lines changed: 249 additions & 32 deletions

File tree

.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/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
))}

packages/app/src/components/alerts/AlertDetails.tsx

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
Flex,
99
Group,
1010
Stack,
11+
Text,
12+
Tooltip,
1113
UnstyledButton,
1214
} from '@mantine/core';
1315
import { useDisclosure } from '@mantine/hooks';
@@ -28,6 +30,7 @@ import { IS_ALERT_DETAILS_ENABLED } from '@/config';
2830
import type { AlertsPageItem } from '@/types';
2931
import {
3032
getAlertDisplayName,
33+
getAlertSourceLabel,
3134
getAlertSourceUrl,
3235
getAlertTags,
3336
} from '@/utils/alerts';
@@ -122,27 +125,48 @@ export const AlertDetails = React.memo(function AlertDetails({
122125

123126
const alertUrl = React.useMemo(() => getAlertSourceUrl(alert), [alert]);
124127

125-
const alertIcon = (() => {
128+
const sourceLabel = getAlertSourceLabel(alert);
129+
130+
// The glyph alone doesn't say what it watches; the tooltip (and its
131+
// accessible label) names it. `span` wrapper: Tooltip needs an element that
132+
// forwards a ref, which the icon components don't.
133+
const sourceGlyph = (() => {
126134
switch (alert.source) {
127135
case AlertSource.TILE:
128-
return <IconChartLine size={14} />;
136+
return <IconChartLine size={14} aria-hidden="true" />;
129137
case AlertSource.SAVED_SEARCH:
130-
return <IconTableRow size={14} />;
138+
return <IconTableRow size={14} aria-hidden="true" />;
131139
default:
132-
return <IconHelpCircle size={14} />;
140+
return <IconHelpCircle size={14} aria-hidden="true" />;
133141
}
134142
})();
135143

136-
const linkTitle = React.useMemo(() => {
137-
switch (alert.source) {
138-
case AlertSource.TILE:
139-
return 'Dashboard tile';
140-
case AlertSource.SAVED_SEARCH:
141-
return 'Saved search';
142-
default:
143-
return '';
144-
}
145-
}, [alert]);
144+
// Only labelled when the source actually resolves — same guard as
145+
// `linkTitle`, so an alert whose source is gone doesn't get a confident
146+
// "Unknown source" tooltip where the rest of the row stays silent.
147+
const alertIcon = alert.source ? (
148+
<Tooltip label={sourceLabel} withArrow position="top">
149+
<span
150+
role="img"
151+
aria-label={sourceLabel}
152+
style={{ display: 'inline-flex' }}
153+
data-testid={`alert-source-icon-${alert._id}`}
154+
>
155+
{sourceGlyph}
156+
</span>
157+
</Tooltip>
158+
) : (
159+
<span
160+
style={{ display: 'inline-flex' }}
161+
data-testid={`alert-source-icon-${alert._id}`}
162+
>
163+
{sourceGlyph}
164+
</span>
165+
);
166+
167+
// Empty for an unresolvable source: AlertRowMenu lowercases this into
168+
// "Open <source>" and falls back to its own wording when blank.
169+
const linkTitle = alert.source ? sourceLabel : '';
146170

147171
return (
148172
<div data-testid={`alert-card-${alert._id}`} className={styles.alertRow}>
@@ -184,6 +208,11 @@ export const AlertDetails = React.memo(function AlertDetails({
184208
</Link>
185209
</div>
186210
<AlertPropertiesSummary alert={alert} />
211+
{alert.createdBy && (
212+
<Text size="xs" c="dimmed" data-testid="alert-created-by">
213+
Created by {alert.createdBy.name || alert.createdBy.email}
214+
</Text>
215+
)}
187216
{getAlertTags(alert).length > 0 && (
188217
<Group gap={4}>
189218
{getAlertTags(alert).map(tag => (

packages/app/src/components/alerts/AlertPropertiesSummary.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,11 @@ function NotificationTargets({
160160

161161
/**
162162
* One-line alert metadata summary: threshold condition, optional evaluation
163-
* schedule, notification targets, and creator. Shared between the alerts
164-
* page rows and the alert detail page header.
163+
* schedule, and notification targets — configuration only.
164+
*
165+
* The creator is deliberately absent: it is provenance, not configuration, and
166+
* at equal weight it crowded the line into a second row that broke mid-phrase.
167+
* Each surface renders it as its own dimmed sub-line instead.
165168
*/
166169
export function AlertPropertiesSummary({
167170
alert,
@@ -173,8 +176,13 @@ export function AlertPropertiesSummary({
173176
alert.thresholdType;
174177

175178
return (
176-
<div className="fs-8 d-flex gap-2 align-items-center">
177-
<span>
179+
// Segments wrap as whole phrases: each is nowrap, so a narrow container
180+
// breaks between them rather than splitting "Notify via" down the middle.
181+
<div
182+
className="fs-8 d-flex gap-2 align-items-center"
183+
style={{ flexWrap: 'wrap', rowGap: 4 }}
184+
>
185+
<span style={{ whiteSpace: 'nowrap' }}>
178186
If value {thresholdLabel}{' '}
179187
<span className="fw-bold">{alert.threshold}</span>
180188
{isRangeThresholdType(alert.thresholdType) && (
@@ -187,12 +195,14 @@ export function AlertPropertiesSummary({
187195
{isDetail && (
188196
<>
189197
<span>&middot;</span>
190-
<span>Evaluates every {alert.interval}</span>
198+
<span style={{ whiteSpace: 'nowrap' }}>
199+
Evaluates every {alert.interval}
200+
</span>
191201
{alert.numConsecutiveWindows != null &&
192202
alert.numConsecutiveWindows > 1 && (
193203
<>
194204
<span>&middot;</span>
195-
<span>
205+
<span style={{ whiteSpace: 'nowrap' }}>
196206
Fires after {alert.numConsecutiveWindows} consecutive windows
197207
</span>
198208
</>
@@ -201,14 +211,6 @@ export function AlertPropertiesSummary({
201211
)}
202212
<span>&middot;</span>
203213
<NotificationTargets alert={alert} showNames={isDetail} />
204-
{alert.createdBy && (
205-
<>
206-
<span>&middot;</span>
207-
<span>
208-
Created by {alert.createdBy.name || alert.createdBy.email}
209-
</span>
210-
</>
211-
)}
212214
</div>
213215
);
214216
}

0 commit comments

Comments
 (0)