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 ( - - {/* Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->*/} - - - ); -} - -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 ( - - PagerDuty - + Incident.io + - - - ); -} - -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 // -------------------------