Improvements to Webhooks Section (#1522)

- Sync with Upstream to avoid future conflicts
- Move WebhookSections to its own file
- Group Webhooks by Type
- Add Webhook Icons Support
- Ensure Link is used instead of Slack to represent Webhooks Generically

<img width="959" height="752" alt="Screenshot 2025-12-23 at 1 35 40 PM" src="https://github.com/user-attachments/assets/0df2d5a2-4396-415c-ba38-685d65d69836" />

Fixes HDX-2794
This commit is contained in:
Brandon Pereira 2025-12-23 13:53:50 -07:00 committed by GitHub
parent 9103ad6328
commit 12cd6433b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 370 additions and 495 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
Improvements to Webhooks rendering (grouping, icons, etc)

View file

@ -37,6 +37,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { PageHeader } from '@/components/PageHeader';
import { isAlertSilenceExpired } from './utils/alerts';
import { getWebhookChannelIcon } from './utils/webhookIcons';
import api from './api';
import { withAppNav } from './layout';
import type { AlertsPageItem } from './types';
@ -367,13 +368,11 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
}, [alert]);
const notificationMethod = React.useMemo(() => {
if (alert.channel.type === 'webhook') {
return (
<Group gap={2}>
Notify via <IconBrandSlack size={16} /> Webhook
</Group>
);
}
return (
<Group gap={5}>
Notify via {getWebhookChannelIcon(alert.channel.type)} Webhook
</Group>
);
}, [alert]);
const linkTitle = React.useMemo(() => {

View file

@ -48,6 +48,7 @@ import {
import { AlertPreviewChart } from './components/AlertPreviewChart';
import { AlertChannelForm } from './components/Alerts';
import { SQLInlineEditorControlled } from './components/SQLInlineEditor';
import { getWebhookChannelIcon } from './utils/webhookIcons';
import api from './api';
import { AlertWithCreatedBy, SearchConfig } from './types';
import { optionsToSelectData } from './utils';
@ -61,10 +62,6 @@ const SavedSearchAlertFormSchema = z
})
.passthrough();
const CHANNEL_ICONS = {
webhook: <IconBrandSlack size={14} />,
};
const AlertForm = ({
sourceId,
where,
@ -422,7 +419,7 @@ export const DBSearchPageAlertModal = ({
{(savedSearch?.alerts || []).map((alert, index) => (
<Tabs.Tab key={alert.id} value={`${index}`}>
<Group gap="xs">
{CHANNEL_ICONS[alert.channel.type]} Alert {index + 1}
{getWebhookChannelIcon(alert.channel.type)} Alert {index + 1}
</Group>
</Tabs.Tab>
))}

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,7 @@ import {
Tooltip,
} from '@mantine/core';
import {
IconChartHistogram,
IconChartLine,
IconFilter,
IconFilterEdit,
@ -67,7 +68,6 @@ import {
useServiceDashboardExpressions,
} from '@/serviceDashboard';
import { useSource, useSources } from '@/source';
import { Histogram } from '@/SVGIcons';
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
import usePresetDashboardFilters from './hooks/usePresetDashboardFilters';
@ -234,7 +234,7 @@ export function EndpointLatencyChart({
title="Histogram"
onClick={() => setLatencyChartType('histogram')}
>
<Histogram width={12} color="currentColor" />
<IconChartHistogram size={14} />
</Button>
</Button.Group>
</Box>

View file

@ -1,13 +1,11 @@
import { Fragment, useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import Head from 'next/head';
import { HTTPError } from 'ky';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { DEFAULT_METADATA_MAX_ROWS_TO_READ } from '@hyperdx/common-utils/dist/core/metadata';
import {
SourceKind,
TeamClickHouseSettings,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
import {
Box,
@ -26,7 +24,6 @@ import {
TextInput,
Tooltip,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconCheck,
@ -47,14 +44,12 @@ import { IS_LOCAL_MODE } from '@/config';
import { PageHeader } from './components/PageHeader';
import TeamMembersSection from './components/TeamSettings/TeamMembersSection';
import { WebhookForm } from './components/TeamSettings/WebhookForm';
import WebhooksSection from './components/TeamSettings/WebhooksSection';
import api from './api';
import { useConnections } from './connection';
import { DEFAULT_QUERY_TIMEOUT, DEFAULT_SEARCH_ROW_LIMIT } from './defaults';
import { withAppNav } from './layout';
import { useSources } from './source';
import type { Webhook } from './types';
import { useConfirm } from './useConfirm';
import { capitalizeFirstLetter } from './utils';
function ConnectionsSection() {
@ -242,159 +237,15 @@ function SourcesSection() {
</Box>
);
}
function DeleteWebhookButton({
webhookId,
webhookName,
onSuccess,
}: {
webhookId: string;
webhookName: string;
onSuccess: VoidFunction;
}) {
const confirm = useConfirm();
const deleteWebhook = api.useDeleteWebhook();
const handleDelete = async () => {
if (
await confirm(
`Are you sure you want to delete ${webhookName} webhook?`,
'Delete',
)
) {
try {
await deleteWebhook.mutateAsync({ id: webhookId });
notifications.show({
color: 'green',
message: 'Webhook deleted successfully',
});
onSuccess();
} catch (e) {
console.error(e);
const message =
(e instanceof HTTPError
? (await e.response.json())?.message
: null) || 'Something went wrong. Please contact HyperDX team.';
notifications.show({
message,
color: 'red',
autoClose: 5000,
});
}
}
};
return (
<Button
color="red"
size="compact-xs"
variant="outline"
onClick={handleDelete}
loading={deleteWebhook.isPending}
>
Delete
</Button>
);
}
function IntegrationsSection() {
const { data: webhookData, refetch: refetchWebhooks } = api.useWebhooks([
WebhookService.Slack,
WebhookService.Generic,
WebhookService.IncidentIO,
]);
const allWebhooks = useMemo<Webhook[]>(() => {
return Array.isArray(webhookData?.data) ? webhookData.data : [];
}, [webhookData]);
const [editedWebhookId, setEditedWebhookId] = useState<string | null>(null);
const [
isAddWebhookModalOpen,
{ open: openWebhookModal, close: closeWebhookModal },
] = useDisclosure();
return (
<Box id="integrations">
<Text size="md">Integrations</Text>
<Divider my="md" />
<Card variant="muted">
<Text mb="xs">Webhooks</Text>
<Stack>
{allWebhooks.map(webhook => (
<Fragment key={webhook._id}>
<Group justify="space-between" align="flex-start">
<Stack gap={0}>
<Text size="sm">
{webhook.name} ({webhook.service})
</Text>
<Text size="xs" opacity={0.7}>
{webhook.url}
</Text>
{webhook.description && (
<Text size="xxs" opacity={0.7}>
{webhook.description}
</Text>
)}
</Stack>
<Group gap="xs">
{editedWebhookId !== webhook._id && (
<>
<Button
variant="subtle"
onClick={() => setEditedWebhookId(webhook._id)}
size="compact-xs"
leftSection={<IconPencil size={14} />}
>
Edit
</Button>
<DeleteWebhookButton
webhookId={webhook._id}
webhookName={webhook.name}
onSuccess={refetchWebhooks}
/>
</>
)}
{editedWebhookId === webhook._id && (
<Button
variant="subtle"
onClick={() => setEditedWebhookId(null)}
size="compact-xs"
>
<IconX size={14} className="me-2" /> Cancel
</Button>
)}
</Group>
</Group>
{editedWebhookId === webhook._id && (
<WebhookForm
webhook={webhook}
onClose={() => setEditedWebhookId(null)}
onSuccess={() => {
setEditedWebhookId(null);
refetchWebhooks();
}}
/>
)}
<Divider />
</Fragment>
))}
<Stack gap="md">
<WebhooksSection />
</Stack>
{!isAddWebhookModalOpen ? (
<Button variant="outline" onClick={openWebhookModal}>
Add Webhook
</Button>
) : (
<WebhookForm
onClose={closeWebhookModal}
onSuccess={() => {
refetchWebhooks();
closeWebhookModal();
}}
/>
)}
</Card>
</Box>
);

View file

@ -4,7 +4,11 @@ import { Controller, SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { ZodIssue } from 'zod';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { linter } from '@codemirror/lint';
import { AlertState, WebhookService } from '@hyperdx/common-utils/dist/types';
import {
AlertState,
WebhookApiData,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
import { isValidSlackUrl } from '@hyperdx/common-utils/dist/validation';
import {
Alert,
@ -23,7 +27,6 @@ import ReactCodeMirror, {
} from '@uiw/react-codemirror';
import api from '@/api';
import { Webhook } from '@/types';
import { isValidUrl } from '@/utils';
const DEFAULT_GENERIC_WEBHOOK_BODY = [
@ -47,7 +50,7 @@ const jsonLinterWithEmptyCheck = () => (editorView: EditorView) => {
type WebhookForm = {
name: string;
url: string;
service: string;
service: WebhookService;
description?: string;
body?: string;
headers?: string;
@ -58,7 +61,7 @@ export function WebhookForm({
onClose,
onSuccess,
}: {
webhook?: Webhook;
webhook?: WebhookApiData;
onClose: VoidFunction;
onSuccess: (webhookId?: string) => void;
}) {
@ -280,14 +283,12 @@ export function WebhookForm({
label="Service Type"
required
value={service}
onChange={value => {
form.setValue('service', value);
}}
onChange={value => form.setValue('service', value as WebhookService)}
>
<Group mt="xs">
<Radio value={WebhookService.Slack} label="Slack" />
<Radio value={WebhookService.IncidentIO} label="incident.io" />
<Radio value={WebhookService.Generic} label="Generic" />
<Radio value={WebhookService.IncidentIO} label="Incident.io" />
</Group>
</Radio.Group>
<TextInput
@ -297,6 +298,7 @@ export function WebhookForm({
error={form.formState.errors.name?.message}
{...form.register('name', { required: true })}
/>
<TextInput
label="Webhook URL"
placeholder={
@ -318,6 +320,7 @@ export function WebhookForm({
: isValidUrl(value) || 'URL must be valid',
})}
/>
<TextInput
label="Webhook Description (optional)"
placeholder="To be used for dev alerts"

View file

@ -0,0 +1,204 @@
import { Fragment, useMemo, useState } from 'react';
import { HTTPError } from 'ky';
import {
WebhookApiData,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
import { Button, Divider, Group, Stack, Text, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPencil, IconX } from '@tabler/icons-react';
import api from '../../api';
import { useConfirm } from '../../useConfirm';
import {
getWebhookServiceConfig,
getWebhookServiceName,
groupWebhooksByService,
} from '../../utils/webhookIcons';
import { WebhookForm } from './WebhookForm';
function DeleteWebhookButton({
webhookId,
webhookName,
onSuccess,
}: {
webhookId: string;
webhookName: string;
onSuccess: VoidFunction;
}) {
const confirm = useConfirm();
const deleteWebhook = api.useDeleteWebhook();
const handleDelete = async () => {
if (
await confirm(
`Are you sure you want to delete ${webhookName} webhook?`,
'Delete',
)
) {
try {
await deleteWebhook.mutateAsync({ id: webhookId });
notifications.show({
color: 'green',
message: 'Webhook deleted successfully',
});
onSuccess();
} catch (e) {
console.error(e);
const message =
(e instanceof HTTPError
? (await e.response.json())?.message
: null) || 'Something went wrong. Please contact HyperDX team.';
notifications.show({
message,
color: 'red',
autoClose: 5000,
});
}
}
};
return (
<Button
color="red"
size="compact-xs"
variant="outline"
onClick={handleDelete}
loading={deleteWebhook.isPending}
>
Delete
</Button>
);
}
export default function WebhooksSection() {
const { data: webhookData, refetch: refetchWebhooks } = api.useWebhooks([
WebhookService.Slack,
WebhookService.Generic,
WebhookService.IncidentIO,
]);
const [editedWebhookId, setEditedWebhookId] = useState<string | null>(null);
const allWebhooks = useMemo((): WebhookApiData[] => {
return Array.isArray(webhookData?.data) ? webhookData?.data || [] : [];
}, [webhookData]);
const groupedWebhooks = useMemo(() => {
return groupWebhooksByService(allWebhooks);
}, [allWebhooks]);
const [
isAddWebhookModalOpen,
{ open: openWebhookModal, close: closeWebhookModal },
] = useDisclosure();
return (
<>
<Text mb="xs">Webhooks</Text>
<Stack>
{groupedWebhooks.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No webhooks configured yet
</Text>
) : (
groupedWebhooks.map(([serviceType, webhooks]) => {
const config = getWebhookServiceConfig(serviceType);
return (
<Stack key={serviceType} gap="xs">
{/* Service type header with icon */}
<Group gap="xs" mt="md" mb="xs">
{config?.icon}
<Title order={6} c="dimmed">
{getWebhookServiceName(serviceType)}
</Title>
</Group>
{/* Webhooks in this service type */}
<Stack>
{webhooks.map(webhook => (
<Fragment key={webhook._id}>
<Group justify="space-between" align="flex-start">
<Stack gap={0}>
<Text size="sm">
{webhook.name} ({webhook.service})
</Text>
<Text size="xs" opacity={0.7}>
{webhook.url}
</Text>
{webhook.description && (
<Text size="xxs" opacity={0.7}>
{webhook.description}
</Text>
)}
</Stack>
<Group gap="xs">
{editedWebhookId !== webhook._id ? (
<>
<Button
variant="subtle"
color="gray.4"
onClick={() => setEditedWebhookId(webhook._id)}
size="compact-xs"
leftSection={<IconPencil size={14} />}
>
Edit
</Button>
<DeleteWebhookButton
webhookId={webhook._id}
webhookName={webhook.name}
onSuccess={refetchWebhooks}
/>
</>
) : (
<Button
variant="subtle"
color="gray.4"
onClick={() => setEditedWebhookId(null)}
size="compact-xs"
>
<IconX size={16} /> Cancel
</Button>
)}
</Group>
</Group>
{editedWebhookId === webhook._id && (
<WebhookForm
webhook={webhook}
onClose={() => setEditedWebhookId(null)}
onSuccess={() => {
setEditedWebhookId(null);
refetchWebhooks();
}}
/>
)}
<Divider />
</Fragment>
))}
</Stack>
</Stack>
);
})
)}
</Stack>
{!isAddWebhookModalOpen ? (
<Button variant="outline" color="gray.4" onClick={openWebhookModal}>
Add Webhook
</Button>
) : (
<WebhookForm
onClose={closeWebhookModal}
onSuccess={() => {
refetchWebhooks();
closeWebhookModal();
}}
/>
)}
</>
);
}

View file

@ -251,19 +251,6 @@ export enum KubePhase {
Unknown = 5,
}
export type Webhook = {
_id: string;
name: string;
service: WebhookService;
url: string;
description?: string;
headers?: Record<string, string>;
queryParams?: Record<string, string>;
body?: string;
createdAt: string;
updatedAt: string;
};
export type NextApiConfigResponseData = {
apiKey: string;
collectorUrl: string;

View file

@ -0,0 +1,107 @@
import React from 'react';
import {
WebhookApiData,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
import { IconBrandSlack, IconLink, IconMail } from '@tabler/icons-react';
import { IncidentIOIcon } from '@/SVGIcons';
export interface ServiceConfig {
name: string;
icon: React.ReactElement;
order: number;
}
// Service type configuration with icons and display names
export const WEBHOOK_SERVICE_CONFIG: Record<WebhookService, ServiceConfig> = {
[WebhookService.Slack]: {
name: 'Slack',
icon: <IconBrandSlack size={16} />,
order: 1,
},
[WebhookService.IncidentIO]: {
name: 'incident.io',
icon: <IncidentIOIcon width={16} />,
order: 4,
},
[WebhookService.Generic]: {
name: 'Generic',
icon: <IconLink size={16} />,
order: 5,
},
} as const;
// Channel icons for alert display (smaller sizes)
export const CHANNEL_ICONS: Record<WebhookService, React.ReactElement> = {
[WebhookService.Generic]: <IconLink size={16} />,
[WebhookService.Slack]: <IconBrandSlack size={16} />,
[WebhookService.IncidentIO]: <IncidentIOIcon width={16} />,
} as const;
/**
* Get webhook service configuration by service type
*/
export const getWebhookServiceConfig = (
serviceType: string | undefined,
): ServiceConfig | undefined => {
if (!serviceType) return undefined;
return WEBHOOK_SERVICE_CONFIG[serviceType as WebhookService];
};
/**
* Get webhook service icon for display in lists/headers
*/
export const getWebhookServiceIcon = (
serviceType: string | undefined,
): React.ReactElement => {
const config = getWebhookServiceConfig(serviceType);
return config?.icon || WEBHOOK_SERVICE_CONFIG[WebhookService.Generic].icon;
};
/**
* Get webhook channel icon for alert tabs/smaller displays
*/
export const getWebhookChannelIcon = (
serviceType: string | undefined,
): React.ReactElement => {
if (!serviceType) return CHANNEL_ICONS[WebhookService.Generic];
return (
CHANNEL_ICONS[serviceType as keyof typeof CHANNEL_ICONS] ||
CHANNEL_ICONS[WebhookService.Generic]
);
};
/**
* Get webhook service display name
*/
export const getWebhookServiceName = (
serviceType: string | undefined,
): string => {
const config = getWebhookServiceConfig(serviceType);
return config?.name || serviceType || 'Unknown';
};
/**
* Helper function to group webhooks by service type
*/
export const groupWebhooksByService = (webhooks: WebhookApiData[]) => {
const grouped = webhooks.reduce(
(acc, webhook) => {
const service = webhook.service;
if (!acc[service]) {
acc[service] = [];
}
acc[service].push(webhook);
return acc;
},
{} as Record<string, WebhookApiData[]>,
);
// Sort groups by predefined order
return Object.entries(grouped).sort(([a], [b]) => {
const orderA = WEBHOOK_SERVICE_CONFIG[a as WebhookService]?.order || 999;
const orderB = WEBHOOK_SERVICE_CONFIG[b as WebhookService]?.order || 999;
return orderA - orderB;
});
};

View file

@ -244,6 +244,23 @@ export enum WebhookService {
IncidentIO = 'incidentio',
}
// Base webhook interface (matches backend IWebhook but with JSON-serialized types)
export interface IWebhook {
_id: string;
createdAt: string;
name: string;
service: WebhookService;
updatedAt: string;
url?: string;
description?: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}
// Webhook API response type (excludes team field for security)
export type WebhookApiData = Omit<IWebhook, 'team'>;
// -------------------------
// ALERTS
// -------------------------