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
15 changes: 15 additions & 0 deletions .changeset/alert-detail-ux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@hyperdx/app': minor
---

feat(alerts): tidy the alert detail header and its properties block

Edit, Delete and Terraform export move behind the same overflow menu the
alerts list uses, so the header no longer spreads four buttons across the top
and both surfaces offer the same actions. The link to what the alert watches
becomes an icon beside the alert name, where it reads as part of the alert's
identity rather than another action.

The properties block splits configuration from provenance: the creator now
sits with the created and updated timestamps in a dimmed line beneath, instead
of competing with the alert's settings.
12 changes: 12 additions & 0 deletions .changeset/alerts-summary-ux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@hyperdx/app': minor
---

feat(alerts): edit from the alerts list, filter by alert source, and label the source icons

The alerts page row menu now opens the alert editor directly, so changing a
threshold no longer means navigating to the alert first. The source icon on
each row gets a tooltip and accessible label naming what it watches ("Saved
search" / "Dashboard tile"), and a new filter narrows the list by that source
— free-text search matches it too, so typing "tile" works without touching the
dropdown. Team settings tabs gain icons.
106 changes: 41 additions & 65 deletions packages/app/src/AlertDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,37 @@ import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { AlertInterval, AlertSource } from '@hyperdx/common-utils/dist/types';
import { AlertInterval } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Breadcrumbs,
Button,
Container,
Group,
Skeleton,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconExternalLink, IconPencil } from '@tabler/icons-react';
import { useQueryClient } from '@tanstack/react-query';
import { IconExternalLink } from '@tabler/icons-react';

import { AckAlert } from '@/components/alerts/AckAlert';
import { AlertDetailChart } from '@/components/alerts/AlertDetailChart';
import { AlertDetailProperties } from '@/components/alerts/AlertDetailProperties';
import { AlertNote } from '@/components/alerts/AlertDetails';
import { AlertEvaluationsTable } from '@/components/alerts/AlertEvaluationsTable';
import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
import { AlertRowMenu } from '@/components/alerts/AlertRowMenu';
import { AlertStateBadge } from '@/components/alerts/AlertStateBadge';
import { EditAlertModal } from '@/components/alerts/EditAlertModal';
import ConfirmDeleteMenu from '@/components/ConfirmDeleteMenu';
import EmptyState from '@/components/EmptyState';
import { PageHeader } from '@/components/PageHeader';
import { TimePicker } from '@/components/TimePicker';
import { IS_ALERT_DETAILS_ENABLED } from '@/config';
import { getAlertDisplayName, getAlertSourceUrl } from '@/utils/alerts';
import {
getAlertDisplayName,
getAlertSourceLabel,
getAlertSourceUrl,
} from '@/utils/alerts';

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

function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
const alertUrl = getAlertSourceUrl(alert);
const brandName = useBrandDisplayName();
const router = useRouter();
const queryClient = useQueryClient();
const deleteAlert = api.useDeleteAlert();
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);

const onDeleteAlert = React.useCallback(async () => {
try {
await deleteAlert.mutateAsync(alert._id);
notifications.show({
color: 'green',
message: 'Alert deleted!',
autoClose: 5000,
});
// The alerts list and the source-bound edit surfaces (saved search
// modal / dashboard tile editor) all render this alert.
queryClient.invalidateQueries({ queryKey: api.getAlertsQueryKey() });
queryClient.invalidateQueries({ queryKey: ['saved-search'] });
queryClient.invalidateQueries({ queryKey: ['dashboards'] });
router.push('/alerts');
} catch (error) {
console.error('Failed to delete alert:', error);
notifications.show({
color: 'red',
message: `Something went wrong. Please contact ${brandName} team.`,
autoClose: 5000,
});
}
}, [alert._id, brandName, deleteAlert, queryClient, router]);

// Interval-dependent, but fixed for the page lifetime: the body only
// mounts once the alert has loaded, and useNewTimeQuery reads the initial
Expand Down Expand Up @@ -169,34 +143,42 @@ function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
<Group gap="sm">
{alert.state != null && <AlertStateBadge state={alert.state} />}
<Text fw={500}>{getAlertDisplayName(alert)}</Text>
{alertUrl && (
/* Next to the name rather than in the actions row: it navigates
to what the alert watches, so it belongs with the identity,
not with the verbs acting on the alert. */
<Tooltip
label={`Open ${getAlertSourceLabel(alert).toLowerCase()}`}
withArrow
>
<ActionIcon
component={Link}
href={alertUrl}
variant="subtle"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Raw palette color bypasses theme

The new source-link ActionIcon uses the raw gray Mantine color rather than a semantic theme token, making this control inconsistent across themes and future palette changes.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

color="gray"
size="md"
aria-label={`Open ${getAlertSourceLabel(alert).toLowerCase()}`}
data-testid="open-alert-source"
>
<IconExternalLink size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
}
actions={
<Group gap="sm" wrap="nowrap">
<AckAlert alert={alert} />
<Button
data-testid="edit-alert-button"
variant="secondary"
size="compact-sm"
leftSection={<IconPencil size={14} />}
onClick={() => setIsEditModalOpen(true)}
>
Edit alert
</Button>
<ConfirmDeleteMenu onDelete={onDeleteAlert} />
{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>
)}
{/* Edit, Terraform export and Delete live behind one overflow
control, shared with the alerts list so both surfaces offer the
same actions. The menu's own source-link item is suppressed:
this page carries that link next to the title instead. */}
<AlertRowMenu
alert={alert}
alertName={getAlertDisplayName(alert)}
dateRange={searchedTimeRange}
onDeleted={() => router.push('/alerts')}
/>
<TimePicker
inputValue={displayedTimeInputValue}
setInputValue={setDisplayedTimeInputValue}
Expand All @@ -205,12 +187,6 @@ function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
</Group>
}
/>
<EditAlertModal
alert={alert}
opened={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
dateRange={searchedTimeRange}
/>
<div style={{ overflow: 'auto', flexGrow: 1 }}>
<Container size="xl" py="md">
<Stack gap="lg">
Expand Down
45 changes: 43 additions & 2 deletions packages/app/src/AlertsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { PageHeader } from '@/components/PageHeader';
import {
getAlertCreatorLabel,
getAlertDisplayName,
getAlertSourceLabel,
getAlertTags,
} from '@/utils/alerts';

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

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

// Only the source types actually present, so the filter never offers an
// option that yields an empty list.
const allSources = React.useMemo(() => {
const sources = new Set<string>();
alerts.forEach(a => sources.add(getAlertSourceLabel(a)));
return Array.from(sources).sort();
}, [alerts]);

const filteredAlerts = React.useMemo(() => {
let result = alerts;
if (sourceFilter) {
result = result.filter(a => getAlertSourceLabel(a) === sourceFilter);
}
if (tagFilter) {
result = result.filter(a => getAlertTags(a).includes(tagFilter));
}
Expand All @@ -67,13 +80,24 @@ export default function AlertsPage() {
result = result.filter(
a =>
getAlertDisplayName(a).toLowerCase().includes(q) ||
// So "tile" / "saved search" narrow the list the same way the type
// filter does, without having to reach for the dropdown.
getAlertSourceLabel(a)
.toLowerCase()
.split(' ')
.some(word => word.startsWith(q)) ||
getAlertTags(a).some(t => t.toLowerCase().includes(q)),
);
}
return result;
}, [alerts, search, tagFilter, creatorFilter]);
}, [alerts, search, tagFilter, creatorFilter, sourceFilter]);

const hasFilters = !!(search?.trim() || tagFilter || creatorFilter);
const hasFilters = !!(
search?.trim() ||
tagFilter ||
creatorFilter ||
sourceFilter
);

return (
<div
Expand Down Expand Up @@ -121,6 +145,23 @@ export default function AlertsPage() {
miw={100}
data-testid="alerts-search-input"
/>
{(allSources.length > 1 || sourceFilter) && (
<Select
placeholder="Filter by alert source"
// A filter carried in from the URL may name a source no
// current alert has; keep it selectable so it can be cleared.
data={
sourceFilter && !allSources.includes(sourceFilter)
? [...allSources, sourceFilter]
: allSources
}
value={sourceFilter}
onChange={v => setSourceFilter(v)}
clearable
style={{ maxWidth: 220 }}
data-testid="alerts-source-filter"
/>
)}
{allTags.length > 0 && (
<Select
placeholder="Filter by tag"
Expand Down
23 changes: 21 additions & 2 deletions packages/app/src/TeamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ import {
TextInput,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconPencil } from '@tabler/icons-react';
import {
IconAdjustmentsHorizontal,
IconApi,
IconDatabase,
IconPencil,
IconPlug,
IconShieldLock,
IconUsers,
} from '@tabler/icons-react';

import { PageHeader } from './components/PageHeader';
import ApiKeysSection from './components/TeamSettings/ApiKeysSection';
Expand All @@ -34,6 +42,7 @@ import { APP_CONTENT_SCROLL_CONTAINER_ID, withAppNav } from './layout';
type TeamTab = {
value: string;
label: string;
icon: ReactNode;
sections: {
id: string;
// Always a function of whether this tab is the visible one. Mantine `Tabs`
Expand Down Expand Up @@ -106,6 +115,7 @@ export default function TeamPage() {
{
value: 'data',
label: 'Data',
icon: <IconDatabase size={16} />,
sections: [
{
id: 'team-data-sources',
Expand All @@ -120,6 +130,7 @@ export default function TeamPage() {
{
value: 'team',
label: 'Members',
icon: <IconUsers size={16} />,
sections: [
{
id: 'team-members',
Expand All @@ -132,6 +143,7 @@ export default function TeamPage() {
{
value: 'access',
label: 'Access',
icon: <IconShieldLock size={16} />,
sections: [
{
id: 'team-access-security-policies',
Expand All @@ -148,6 +160,7 @@ export default function TeamPage() {
{
value: 'api-agents',
label: 'API & Agents',
icon: <IconApi size={16} />,
sections: [
{
id: 'team-api-agents-api-keys',
Expand All @@ -172,6 +185,7 @@ export default function TeamPage() {
{
value: 'integrations',
label: 'Integrations',
icon: <IconPlug size={16} />,
sections: [
{
id: 'team-integrations-webhooks',
Expand All @@ -182,6 +196,7 @@ export default function TeamPage() {
{
value: 'advanced',
label: 'Query Settings',
icon: <IconAdjustmentsHorizontal size={16} />,
sections: [
{
id: 'team-advanced-query-settings',
Expand Down Expand Up @@ -333,7 +348,11 @@ export default function TeamPage() {
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.List>
{tabs.map(tab => (
<Tabs.Tab key={tab.value} value={tab.value}>
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={tab.icon}
>
{tab.label}
</Tabs.Tab>
))}
Expand Down
Loading
Loading