diff --git a/.changeset/angry-pugs-drive.md b/.changeset/angry-pugs-drive.md
new file mode 100644
index 00000000..38bb123e
--- /dev/null
+++ b/.changeset/angry-pugs-drive.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+Improvements to Webhooks rendering (grouping, icons, etc)
diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx
index bc7ddfdc..00d9dd45 100644
--- a/packages/app/src/AlertsPage.tsx
+++ b/packages/app/src/AlertsPage.tsx
@@ -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 (
-
- Notify via Webhook
-
- );
- }
+ return (
+
+ Notify via {getWebhookChannelIcon(alert.channel.type)} Webhook
+
+ );
}, [alert]);
const linkTitle = React.useMemo(() => {
diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx
index b0376938..39e7208b 100644
--- a/packages/app/src/DBSearchPageAlertModal.tsx
+++ b/packages/app/src/DBSearchPageAlertModal.tsx
@@ -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: ,
-};
-
const AlertForm = ({
sourceId,
where,
@@ -422,7 +419,7 @@ export const DBSearchPageAlertModal = ({
{(savedSearch?.alerts || []).map((alert, index) => (
- {CHANNEL_ICONS[alert.channel.type]} Alert {index + 1}
+ {getWebhookChannelIcon(alert.channel.type)} Alert {index + 1}
))}
diff --git a/packages/app/src/SVGIcons.tsx b/packages/app/src/SVGIcons.tsx
index f0964481..777ac3ab 100644
--- a/packages/app/src/SVGIcons.tsx
+++ b/packages/app/src/SVGIcons.tsx
@@ -5,326 +5,31 @@ type IconProps = {
width?: number;
};
-export function Histogram({
- width,
- color = '#fff',
-}: {
- width: number;
- color?: string;
-}) {
+export function IncidentIOIcon({ style, width }: IconProps) {
return (
- );
-}
-
-export function Otel({
- width,
- color = '#fff',
-}: {
- width?: number;
- color?: string;
-}) {
- return (
-
- );
-}
-
-export function FloppyIcon({ width }: IconProps) {
- return (
-
- );
-}
-
-export function InspectElement({ width }: IconProps) {
- return (
-
- );
-}
-
-export function CircleCiIcon({ style, width }: IconProps) {
- return (
-
- );
-}
-
-export function CodeBranchIcon({ style, width }: IconProps) {
- return (
-
- );
-}
-
-export function JenkinsIcon({ style, width }: IconProps) {
- return (
-
- );
-}
-
-export function PagerDutyIcon({ style, width }: IconProps) {
- return (
-
- );
-}
-
-export function VercelIcon({ style, width }: IconProps) {
- return (
-
- );
-}
-
-export function S3Icon({ style, width }: IconProps) {
- return (
-
- );
-}
-
-export function KubernetesFlatIcon({ width }: IconProps) {
- return (
-
- );
-}
-
-export function KubernetesColorIcon({ width }: IconProps) {
- return (
-
- );
-}
-
-export function TerraformFlatIcon({ width }: IconProps) {
- return (
-
- );
-}
-
-export function WebhookFlatIcon({ width }: IconProps) {
- return (
-
);
}
diff --git a/packages/app/src/ServicesDashboardPage.tsx b/packages/app/src/ServicesDashboardPage.tsx
index 5e46ddc3..3169e4b2 100644
--- a/packages/app/src/ServicesDashboardPage.tsx
+++ b/packages/app/src/ServicesDashboardPage.tsx
@@ -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')}
>
-
+
diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx
index 646c89c3..0952d55a 100644
--- a/packages/app/src/TeamPage.tsx
+++ b/packages/app/src/TeamPage.tsx
@@ -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() {
);
}
-
-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 (
-
- );
-}
-
function IntegrationsSection() {
- const { data: webhookData, refetch: refetchWebhooks } = api.useWebhooks([
- WebhookService.Slack,
- WebhookService.Generic,
- WebhookService.IncidentIO,
- ]);
-
- const allWebhooks = useMemo(() => {
- return Array.isArray(webhookData?.data) ? webhookData.data : [];
- }, [webhookData]);
-
- const [editedWebhookId, setEditedWebhookId] = useState(null);
- const [
- isAddWebhookModalOpen,
- { open: openWebhookModal, close: closeWebhookModal },
- ] = useDisclosure();
-
return (
Integrations
- Webhooks
-
-
- {allWebhooks.map(webhook => (
-
-
-
-
- {webhook.name} ({webhook.service})
-
-
- {webhook.url}
-
- {webhook.description && (
-
- {webhook.description}
-
- )}
-
-
- {editedWebhookId !== webhook._id && (
- <>
-
-
- >
- )}
- {editedWebhookId === webhook._id && (
-
- )}
-
-
- {editedWebhookId === webhook._id && (
- setEditedWebhookId(null)}
- onSuccess={() => {
- setEditedWebhookId(null);
- refetchWebhooks();
- }}
- />
- )}
-
-
- ))}
+
+
-
- {!isAddWebhookModalOpen ? (
-
- ) : (
- {
- refetchWebhooks();
- closeWebhookModal();
- }}
- />
- )}
);
diff --git a/packages/app/src/components/TeamSettings/WebhookForm.tsx b/packages/app/src/components/TeamSettings/WebhookForm.tsx
index 2ef0629e..df5e5e5d 100644
--- a/packages/app/src/components/TeamSettings/WebhookForm.tsx
+++ b/packages/app/src/components/TeamSettings/WebhookForm.tsx
@@ -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)}
>
+
-
+
+
{
+ 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 (
+
+ );
+}
+
+export default function WebhooksSection() {
+ const { data: webhookData, refetch: refetchWebhooks } = api.useWebhooks([
+ WebhookService.Slack,
+ WebhookService.Generic,
+ WebhookService.IncidentIO,
+ ]);
+
+ const [editedWebhookId, setEditedWebhookId] = useState(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 (
+ <>
+ Webhooks
+
+
+ {groupedWebhooks.length === 0 ? (
+
+ No webhooks configured yet
+
+ ) : (
+ groupedWebhooks.map(([serviceType, webhooks]) => {
+ const config = getWebhookServiceConfig(serviceType);
+ return (
+
+ {/* Service type header with icon */}
+
+ {config?.icon}
+
+ {getWebhookServiceName(serviceType)}
+
+
+
+ {/* Webhooks in this service type */}
+
+ {webhooks.map(webhook => (
+
+
+
+
+ {webhook.name} ({webhook.service})
+
+
+ {webhook.url}
+
+ {webhook.description && (
+
+ {webhook.description}
+
+ )}
+
+
+
+ {editedWebhookId !== webhook._id ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ {editedWebhookId === webhook._id && (
+ setEditedWebhookId(null)}
+ onSuccess={() => {
+ setEditedWebhookId(null);
+ refetchWebhooks();
+ }}
+ />
+ )}
+
+
+ ))}
+
+
+ );
+ })
+ )}
+
+
+ {!isAddWebhookModalOpen ? (
+
+ ) : (
+ {
+ refetchWebhooks();
+ closeWebhookModal();
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts
index 8365a6a2..6238b491 100644
--- a/packages/app/src/types.ts
+++ b/packages/app/src/types.ts
@@ -251,19 +251,6 @@ export enum KubePhase {
Unknown = 5,
}
-export type Webhook = {
- _id: string;
- name: string;
- service: WebhookService;
- url: string;
- description?: string;
- headers?: Record;
- queryParams?: Record;
- body?: string;
- createdAt: string;
- updatedAt: string;
-};
-
export type NextApiConfigResponseData = {
apiKey: string;
collectorUrl: string;
diff --git a/packages/app/src/utils/webhookIcons.tsx b/packages/app/src/utils/webhookIcons.tsx
new file mode 100644
index 00000000..21788a42
--- /dev/null
+++ b/packages/app/src/utils/webhookIcons.tsx
@@ -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.Slack]: {
+ name: 'Slack',
+ icon: ,
+ order: 1,
+ },
+ [WebhookService.IncidentIO]: {
+ name: 'incident.io',
+ icon: ,
+ order: 4,
+ },
+ [WebhookService.Generic]: {
+ name: 'Generic',
+ icon: ,
+ order: 5,
+ },
+} as const;
+
+// Channel icons for alert display (smaller sizes)
+export const CHANNEL_ICONS: Record = {
+ [WebhookService.Generic]: ,
+ [WebhookService.Slack]: ,
+ [WebhookService.IncidentIO]: ,
+} 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,
+ );
+
+ // 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;
+ });
+};
diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts
index e6bd5d93..70d01633 100644
--- a/packages/common-utils/src/types.ts
+++ b/packages/common-utils/src/types.ts
@@ -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;
+ headers?: Record;
+ body?: string;
+}
+
+// Webhook API response type (excludes team field for security)
+export type WebhookApiData = Omit;
+
// -------------------------
// ALERTS
// -------------------------