From a0ae4dfd0568e475b6ec3ea1fd7bf10d0c449257 Mon Sep 17 00:00:00 2001 From: Ashwin Bhatkal Date: Wed, 3 Jun 2026 18:25:14 +0530 Subject: [PATCH 1/6] =?UTF-8?q?feat(dashboards):=20V2=20dashboard=20detail?= =?UTF-8?q?s=20=E2=80=94=20base=20scaffolding=20(#11543)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard-v2): route entry — DashboardPageV2 fetches dashboard, gates loading/error * feat(dashboard-v2): presentational container — compose description & sections layout * feat(dashboard-v2): breadcrumbs + header chrome * feat(dashboard-v2): dashboard description — title, meta, actions, rename * feat(dashboard-v2): read-only panels & sections layout --- .../DashboardContainer.module.scss | 5 + .../DashboardActions/DashboardActions.tsx | 198 ++++++++++++ .../DashboardDescription.module.scss | 303 ++++++++++++++++++ .../DashboardMeta/DashboardMeta.tsx | 32 ++ .../DashboardTitle/DashboardTitle.tsx | 47 +++ .../RenameDashboardModal.tsx | 70 ++++ .../DashboardDescription/index.tsx | 159 +++++++++ .../Panel/Panel.module.scss | 52 +++ .../PanelsAndSectionsLayout/Panel/Panel.tsx | 67 ++++ .../PanelsAndSectionsLayout.module.scss | 10 + .../Section/Section/Section.module.scss | 9 + .../Section/Section/Section.tsx | 60 ++++ .../SectionGrid/SectionGrid.module.scss | 12 + .../Section/SectionGrid/SectionGrid.tsx | 50 +++ .../SectionHeader/SectionHeader.module.scss | 52 +++ .../Section/SectionHeader/SectionHeader.tsx | 42 +++ .../PanelsAndSectionsLayout/index.tsx | 53 +++ .../DashboardBreadcrumbs.module.scss | 63 ++++ .../DashboardHeader/DashboardBreadcrumbs.tsx | 56 ++++ .../DashboardHeader.module.scss | 9 + .../DashboardHeader/DashboardHeader.tsx | 22 ++ .../DashboardContainer/index.tsx | 36 +++ .../DashboardContainer/utils.ts | 154 +++++++++ .../DashboardPageV2.module.scss | 3 + .../pages/DashboardPageV2/DashboardPageV2.tsx | 40 ++- 25 files changed, 1603 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardContainer.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardActions/DashboardActions.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardDescription.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardMeta/DashboardMeta.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardTitle/DashboardTitle.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/RenameDashboardModal/RenameDashboardModal.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/index.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/PanelsAndSectionsLayout.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.module.scss create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx create mode 100644 frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts create mode 100644 frontend/src/pages/DashboardPageV2/DashboardPageV2.module.scss diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardContainer.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardContainer.module.scss new file mode 100644 index 00000000000..53ca596fc0c --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardContainer.module.scss @@ -0,0 +1,5 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardActions/DashboardActions.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardActions/DashboardActions.tsx new file mode 100644 index 00000000000..8374f73a6e5 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardActions/DashboardActions.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react'; +import { FullScreenHandle } from 'react-full-screen'; +import { useTranslation } from 'react-i18next'; +import { useCopyToClipboard } from 'react-use'; +import { + ClipboardCopy, + Ellipsis, + FileJson, + Fullscreen, + LockKeyhole, + PenLine, + Plus, +} from '@signozhq/icons'; +import { Popover } from 'antd'; +import { Button } from '@signozhq/ui/button'; +import { toast } from '@signozhq/ui/sonner'; +import { TooltipSimple } from '@signozhq/ui/tooltip'; +import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas'; +import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton'; +import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; +import { useAppContext } from 'providers/App/App'; +import { USER_ROLES } from 'types/roles'; + +import styles from '../DashboardDescription.module.scss'; + +interface Props { + dashboard: DashboardtypesGettableDashboardV2DTO; + handle: FullScreenHandle; + isDashboardLocked: boolean; + editDashboard: boolean; + isAuthor: boolean; + addPanelPermission: boolean; + onAddPanel: () => void; + onLockToggle: () => void; + onOpenRename: () => void; +} + +function DashboardActions({ + dashboard, + handle, + isDashboardLocked, + editDashboard, + isAuthor, + addPanelPermission, + onAddPanel, + onLockToggle, + onOpenRename, +}: Props): JSX.Element { + const { user } = useAppContext(); + const { t } = useTranslation(['dashboard', 'common']); + + const id = dashboard.id; + const title = dashboard.spec?.display?.name ?? ''; + + const [isDashboardSettingsOpen, setIsDashboardSettingsOpen] = + useState(false); + + const [state, setCopy] = useCopyToClipboard(); + + useEffect(() => { + if (state.error) { + toast.error(t('something_went_wrong', { ns: 'common' })); + } + if (state.value) { + toast.success(t('success', { ns: 'common' })); + } + }, [state.error, state.value, t]); + + const dashboardDataJSON = (): string => JSON.stringify(dashboard, null, 2); + + const exportJSON = (): void => { + const blob = new Blob([dashboardDataJSON()], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${title || 'dashboard'}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + return ( +
+ + setIsDashboardSettingsOpen(visible)} + rootClassName={styles.dashboardSettings} + content={ +
+
+ {(isAuthor || user.role === USER_ROLES.ADMIN) && ( + + + + )} + + {!isDashboardLocked && editDashboard && ( + + )} + + +
+
+ + +
+
+ +
+
+ } + trigger="click" + placement="bottomRight" + > + + )} +
+ ); +} + +export default DashboardActions; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardDescription.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardDescription.module.scss new file mode 100644 index 00000000000..ff7a0d01693 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardDescription.module.scss @@ -0,0 +1,303 @@ +.dashboardDescriptionContainer { + box-shadow: none; + border: none; + background: unset; + color: var(--l2-foreground); + + :global(.ant-card-body) { + padding: 0px; + } + + .dashboardDetails { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 16px 16px 0px 16px; + align-items: flex-start; + + .leftSection { + display: flex; + align-items: center; + gap: 8px; + width: 45%; + + .dashboardImg { + height: 16px; + width: 16px; + } + + .dashboardTitle { + color: var(--l1-foreground); + font-family: Inter; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; /* 150% */ + letter-spacing: -0.08px; + max-width: 80%; + + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .publicDashboardIcon { + margin-right: 4px; + } + } + + .rightSection { + display: flex; + width: 55%; + justify-content: flex-end; + flex-wrap: wrap; + align-items: center; + gap: 14px; + + .icons { + display: flex; + align-items: center; + width: 32px; + height: 34px; + padding: 6px; + justify-content: center; + border-radius: 2px; + border: 1px solid var(--l1-border); + background: var(--l3-background); + color: var(--l2-foreground); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 10px; /* 83.333% */ + letter-spacing: 0.12px; + } + + .icons:hover { + background-color: unset; + } + + .configureButton { + display: flex; + align-items: center; + width: 93px; + height: 34px; + padding: 6px; + justify-content: center; + border-radius: 2px; + border: 1px solid var(--l1-border); + background: var(--l3-background); + color: var(--l2-foreground); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 10px; /* 83.333% */ + letter-spacing: 0.12px; + } + + .addPanelBtn { + display: flex; + width: 119px; + height: 34px; + padding: 5.937px 11.875px; + justify-content: center; + align-items: center; + color: var(--primary-foreground); + background: var(--primary-background); + font-family: Inter; + font-size: 11.875px; + font-style: normal; + font-weight: 500; + line-height: 17.812px; /* 150% */ + } + } + } + + .dashboardTags { + display: flex; + gap: 6px; + padding: 16px 16px 0px 16px; + flex-wrap: wrap; + + .tag { + display: flex; + padding: 4px 8px; + justify-content: center; + align-items: center; + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent); + background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent); + color: var(--bg-sienna-400); + text-align: center; + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + margin-inline-end: 0px; + } + } + + .dashboardDescriptionSection { + color: var(--l2-foreground); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; /* 157.143% */ + letter-spacing: -0.07px; + padding: 20px 16px 0px 16px; + } +} + +.dashboardSettings { + width: 191px; + height: 302px; + flex-shrink: 0; + + :global(.ant-popover-inner) { + padding: 0px; + border-radius: 4px; + border: 1px solid var(--l1-border); + background: linear-gradient( + 139deg, + color-mix(in srgb, var(--card) 80%, transparent) 0%, + color-mix(in srgb, var(--card) 90%, transparent) 98.68% + ) !important; + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + } + + .menuContent { + display: flex; + flex-direction: column; + + section { + display: flex; + flex-direction: column; + align-items: start; + + button { + display: flex; + width: 100%; + height: unset; + padding: 8px; + align-items: center; + gap: 12px; + color: var(--l2-foreground); + font-family: Inter; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; + letter-spacing: 0.14px; + border-top: none; + } + } + + .section1, + .section2 { + border-bottom: 1px solid var(--l1-border); + } + + .deleteDashboard button { + color: var(--bg-cherry-400) !important; + } + } +} + +.renameDashboard { + :global(.ant-modal-content) { + width: 384px; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid var(--l1-border); + background: var(--l2-background); + box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2); + padding: 0px; + + :global(.ant-modal-header) { + height: 52px; + padding: 16px; + background: var(--l2-background); + border-bottom: 1px solid var(--l1-border); + margin-bottom: 0px; + + :global(.ant-modal-title) { + color: var(--l1-foreground); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 142.857% */ + width: 349px; + height: 20px; + } + } + + :global(.ant-modal-body) { + padding: 16px; + + .dashboardContent { + display: flex; + flex-direction: column; + gap: 8px; + + .nameText { + color: var(--l1-foreground); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + } + + .dashboardNameInput { + display: flex; + padding: 6px 6px 6px 8px; + align-items: center; + gap: 4px; + align-self: stretch; + border-radius: 0px 2px 2px 0px; + border: 1px solid var(--l1-border); + background: var(--l3-background); + } + } + } + + :global(.ant-modal-footer) { + padding: 16px; + margin-top: 0px; + + .dashboardRename { + display: flex; + flex-direction: row-reverse; + gap: 12px; + + .cancelBtn { + display: flex; + padding: 4px 8px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 2px; + background: var(--l1-border); + } + + .renameBtn { + display: flex; + align-items: center; + width: 169px; + padding: 4px 8px; + justify-content: center; + gap: 4px; + border-radius: 2px; + background: var(--primary-background); + } + } + } + } +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardMeta/DashboardMeta.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardMeta/DashboardMeta.tsx new file mode 100644 index 00000000000..ff801f049ac --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardMeta/DashboardMeta.tsx @@ -0,0 +1,32 @@ +import { Badge } from '@signozhq/ui/badge'; +import { isEmpty } from 'lodash-es'; + +import styles from '../DashboardDescription.module.scss'; + +interface Props { + tags: string[]; + description: string; +} + +function DashboardMeta({ tags, description }: Props): JSX.Element { + return ( + <> + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {!isEmpty(description) && ( +
+ {description} +
+ )} + + ); +} + +export default DashboardMeta; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardTitle/DashboardTitle.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardTitle/DashboardTitle.tsx new file mode 100644 index 00000000000..72384e56e61 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/DashboardTitle/DashboardTitle.tsx @@ -0,0 +1,47 @@ +import { Globe, LockKeyhole } from '@signozhq/icons'; +import { TooltipSimple } from '@signozhq/ui/tooltip'; +import { Typography } from '@signozhq/ui/typography'; + +import styles from '../DashboardDescription.module.scss'; + +interface Props { + title: string; + image: string; + isPublicDashboard: boolean; + isDashboardLocked: boolean; +} + +function DashboardTitle({ + title, + image, + isPublicDashboard, + isDashboardLocked, +}: Props): JSX.Element { + return ( +
+ dashboard-img + 30 ? title : ''}> + + {title} + + + + {isPublicDashboard && ( + + + + )} + + {isDashboardLocked && ( + + + + )} +
+ ); +} + +export default DashboardTitle; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/RenameDashboardModal/RenameDashboardModal.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/RenameDashboardModal/RenameDashboardModal.tsx new file mode 100644 index 00000000000..e4d0e5769a3 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/RenameDashboardModal/RenameDashboardModal.tsx @@ -0,0 +1,70 @@ +import { Input, Modal } from 'antd'; +import { Button } from '@signozhq/ui/button'; +import { Check, X } from '@signozhq/icons'; +import { Typography } from '@signozhq/ui/typography'; + +import styles from '../DashboardDescription.module.scss'; + +interface Props { + open: boolean; + value: string; + isLoading: boolean; + onChange: (value: string) => void; + onRename: () => void; + onClose: () => void; +} + +function RenameDashboardModal({ + open, + value, + isLoading, + onChange, + onRename, + onClose, +}: Props): JSX.Element { + return ( + + + + + } + > +
+ + Enter a new name + + onChange(e.target.value)} + /> +
+
+ ); +} + +export default RenameDashboardModal; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/index.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/index.tsx new file mode 100644 index 00000000000..ec23ab7b09f --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/DashboardDescription/index.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from 'react'; +import { FullScreenHandle } from 'react-full-screen'; +import { Card } from 'antd'; +import { toast } from '@signozhq/ui/sonner'; +import logEvent from 'api/common/logEvent'; +import { + lockDashboardV2, + patchDashboardV2, + unlockDashboardV2, +} from 'api/generated/services/dashboard'; +import type { + DashboardtypesGettableDashboardV2DTO, + DashboardtypesJSONPatchOperationDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils'; +import useComponentPermission from 'hooks/useComponentPermission'; +import { useAppContext } from 'providers/App/App'; +import { useErrorModal } from 'providers/ErrorModalProvider'; +import APIError from 'types/api/error'; + +import DashboardHeader from '../components/DashboardHeader/DashboardHeader'; +import DashboardActions from './DashboardActions/DashboardActions'; +import DashboardMeta from './DashboardMeta/DashboardMeta'; +import DashboardTitle from './DashboardTitle/DashboardTitle'; +import RenameDashboardModal from './RenameDashboardModal/RenameDashboardModal'; + +import styles from './DashboardDescription.module.scss'; + +interface DashboardDescriptionProps { + dashboard: DashboardtypesGettableDashboardV2DTO; + handle: FullScreenHandle; + refetch: () => void; +} + +function DashboardDescription(props: DashboardDescriptionProps): JSX.Element { + const { dashboard, handle, refetch } = props; + + const id = dashboard.id; + const isDashboardLocked = !!dashboard.locked; + + const title = dashboard.spec?.display?.name ?? ''; + const description = dashboard.spec?.display?.description ?? ''; + const image = dashboard.image || Base64Icons[0]; + const tags = useMemo( + () => + (dashboard.tags ?? []).map((t) => + t.key === t.value ? t.key : `${t.key}:${t.value}`, + ), + [dashboard.tags], + ); + + const { user } = useAppContext(); + const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role); + const { showErrorModal } = useErrorModal(); + + const isAuthor = + !!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email; + const addPanelPermission = !isDashboardLocked; + // V2 public dashboard wiring lives separately; treat as not-public for chrome. + const isPublicDashboard = false; + + const [isRenameDashboardOpen, setIsRenameDashboardOpen] = + useState(false); + const [updatedTitle, setUpdatedTitle] = useState(title); + const [isRenameLoading, setIsRenameLoading] = useState(false); + + useEffect(() => { + setUpdatedTitle(title); + }, [title]); + + const handleLockDashboardToggle = async (): Promise => { + if (!id) { + return; + } + try { + if (isDashboardLocked) { + await unlockDashboardV2({ id }); + toast.success('Dashboard unlocked'); + } else { + await lockDashboardV2({ id }); + toast.success('Dashboard locked'); + } + refetch(); + } catch (error) { + showErrorModal(error as APIError); + } + }; + + const onNameChangeHandler = async (): Promise => { + const trimmed = updatedTitle.trim(); + if (!id || !trimmed || trimmed === title) { + setIsRenameDashboardOpen(false); + return; + } + try { + setIsRenameLoading(true); + const patch: DashboardtypesJSONPatchOperationDTO[] = [ + { + op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'], + path: '/spec/display/name', + value: trimmed, + }, + ]; + await patchDashboardV2({ id }, patch); + toast.success('Dashboard renamed successfully'); + setIsRenameDashboardOpen(false); + refetch(); + } catch (error) { + showErrorModal(error as APIError); + setIsRenameDashboardOpen(true); + } finally { + setIsRenameLoading(false); + } + }; + + const onEmptyWidgetHandler = (): void => { + void logEvent('Dashboard Detail V2: Add new panel clicked', { + dashboardId: id, + }); + toast.info('V2 panel editor coming next'); + }; + + return ( + + +
+ + setIsRenameDashboardOpen(true)} + /> +
+ + + setIsRenameDashboardOpen(false)} + /> +
+ ); +} + +export default DashboardDescription; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.module.scss new file mode 100644 index 00000000000..883f1ab07de --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.module.scss @@ -0,0 +1,52 @@ +.panel { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: var(--bg-ink-400, #0b0c0e); + border: 1px solid var(--bg-slate-400, #1d212d); + border-radius: 4px; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--bg-slate-400, #1d212d); + cursor: grab; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.headerTitle { + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge { + margin-inline-end: 0; +} + +.body { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + color: var(--bg-vanilla-400, #8993ae); + font-size: 12px; + text-align: center; +} + +.bodyKind { + margin-bottom: 6px; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx new file mode 100644 index 00000000000..b5d2ae3a91e --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/Panel.tsx @@ -0,0 +1,67 @@ +import { useMemo } from 'react'; +import { Badge } from '@signozhq/ui/badge'; +import { TooltipSimple } from '@signozhq/ui/tooltip'; +import { Typography } from '@signozhq/ui/typography'; +import { EllipsisVertical } from '@signozhq/icons'; +import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas'; +import cx from 'classnames'; + +import styles from './Panel.module.scss'; + +interface Props { + panel: DashboardtypesPanelDTO | undefined; + panelId: string; + /** + * Placeholder: true once this panel's section enters the viewport. The panel + * query-loading implementation (later PR) will consume this to lazily fetch + * data. Currently unused on purpose. + */ + isVisible?: boolean; +} + +function Panel({ panel, panelId, isVisible }: Props): JSX.Element { + const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`; + const description = panel?.spec?.display?.description; + const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown'; + const queryCount = panel?.spec?.queries?.length ?? 0; + + const headerTitle = useMemo(() => { + if (!description) { + return name; + } + return ( + + {name} + + ); + }, [name, description]); + + return ( +
+
+
+ + {headerTitle} + + {kind} +
+ +
+ +
+
+
{kind} panel
+
+ {queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering + coming next +
+
+
+
+ ); +} + +export default Panel; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/PanelsAndSectionsLayout.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/PanelsAndSectionsLayout.module.scss new file mode 100644 index 00000000000..db6d48f8acc --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/PanelsAndSectionsLayout.module.scss @@ -0,0 +1,10 @@ +.body { + flex: 1; + padding: 12px 24px; + overflow: auto; +} + +.emptyState { + padding: 48px; + text-align: center; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.module.scss new file mode 100644 index 00000000000..53568dfc978 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.module.scss @@ -0,0 +1,9 @@ +.section { + margin-bottom: 12px; + border: 1px solid var(--bg-slate-500); + border-radius: 4px; +} + +.dragging { + opacity: 0.8; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx new file mode 100644 index 00000000000..cd7380a4eb3 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/Section/Section.tsx @@ -0,0 +1,60 @@ +import { useRef, useState } from 'react'; + +import { useIntersectionObserver } from 'hooks/useIntersectionObserver'; + +import type { DashboardSection } from '../../../utils'; +import SectionGrid from '../SectionGrid/SectionGrid'; +import SectionHeader from '../SectionHeader/SectionHeader'; +import styles from './Section.module.scss'; + +interface Props { + section: DashboardSection; +} + +function Section({ section }: Props): JSX.Element { + const containerRef = useRef(null); + // Placeholder signal for lazy panel query-loading (consumed in a later PR): + // true once the section scrolls into (or near) the viewport. + const isVisible = useIntersectionObserver(containerRef, { + rootMargin: '200px', + }); + + const [open, setOpen] = useState(section.open); + const toggle = (): void => setOpen((prev) => !prev); + + const grid = ; + + if (!section.title) { + // Untitled section — just the grid (no header chrome), but still observed + // for the viewport signal. + return ( +
+ {grid} +
+ ); + } + + return ( +
+ + {open ? grid : null} +
+ ); +} + +export default Section; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.module.scss new file mode 100644 index 00000000000..231e03e5f57 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.module.scss @@ -0,0 +1,12 @@ +.grid { + // Override react-grid-layout's default red drag/resize placeholder with the + // SigNoz brand blue. + :global(.react-grid-item.react-grid-placeholder) { + background: var(--bg-robin-500); + opacity: 0.2; + border-radius: 4px; + transition-duration: 100ms; + z-index: 2; + user-select: none; + } +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx new file mode 100644 index 00000000000..56f897b66ea --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionGrid/SectionGrid.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout'; + +import type { DashboardSection } from '../../../utils'; +import Panel from '../../Panel/Panel'; +import styles from './SectionGrid.module.scss'; + +const ResponsiveGridLayout = WidthProvider(GridLayout); + +interface Props { + items: DashboardSection['items']; + /** Forwarded to panels — true when the parent section is in the viewport. */ + isVisible?: boolean; +} + +function SectionGrid({ items, isVisible }: Props): JSX.Element { + const rglLayout = useMemo( + () => + items.map((item) => ({ + i: item.id, + x: item.x, + y: item.y, + w: item.width, + h: item.height, + })), + [items], + ); + + return ( + + {items.map((item) => ( +
+ +
+ ))} +
+ ); +} + +export default SectionGrid; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.module.scss new file mode 100644 index 00000000000..885ffcce1e8 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.module.scss @@ -0,0 +1,52 @@ +.header { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + + &.headerOpen { + border-bottom: 1px solid var(--bg-slate-500); + } +} + +.dragHandle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + border: none; + color: var(--bg-vanilla-400, #8993ae); + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +.toggle { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + padding: 0; + background: transparent; + border: none; + color: inherit; + text-align: left; + cursor: pointer; + min-width: 0; +} + +.title { + margin-left: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.repeatBadge { + margin-left: 8px; + opacity: 0.6; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx new file mode 100644 index 00000000000..f1b9c4ecb4f --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Section/SectionHeader/SectionHeader.tsx @@ -0,0 +1,42 @@ +import { ChevronDown, ChevronRight } from '@signozhq/icons'; +import { Typography } from '@signozhq/ui/typography'; +import cx from 'classnames'; + +import styles from './SectionHeader.module.scss'; + +interface Props { + sectionId: string; + title: string; + open: boolean; + onToggle: () => void; + repeatVariable?: string; +} + +function SectionHeader({ + sectionId, + title, + open, + onToggle, + repeatVariable, +}: Props): JSX.Element { + return ( +
+ +
+ ); +} + +export default SectionHeader; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx new file mode 100644 index 00000000000..c947fea1428 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/index.tsx @@ -0,0 +1,53 @@ +import { ReactNode, useMemo } from 'react'; + +import { Empty } from 'antd'; +import { Typography } from '@signozhq/ui/typography'; +import type { + DashboardtypesLayoutDTO, + DashboardtypesPanelDTO, +} from 'api/generated/services/sigNoz.schemas'; + +import { layoutsToSections } from '../utils'; +import Section from './Section/Section/Section'; +import styles from './PanelsAndSectionsLayout.module.scss'; + +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; + +interface Props { + layouts: DashboardtypesLayoutDTO[]; + panels: Record; +} + +function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element { + const sections = useMemo( + () => layoutsToSections(layouts, panels), + [layouts, panels], + ); + + const isEmpty = + sections.length === 0 || sections.every((s) => s.items.length === 0); + + const renderContent = (): ReactNode => { + if (isEmpty) { + return ( +
+ No panels in this dashboard yet + } + /> +
+ ); + } + + return sections.map((section) => ( +
+ )); + }; + + return
{renderContent()}
; +} + +export default PanelsAndSectionsLayout; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.module.scss new file mode 100644 index 00000000000..92fb91e50a1 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.module.scss @@ -0,0 +1,63 @@ +.dashboardBreadcrumbs { + width: 100%; + height: 48px; + display: flex; + gap: 6px; + align-items: center; + max-width: 80%; + + .dashboardBtn { + display: flex; + align-items: center; + color: var(--l2-foreground); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + padding: 0px; + height: 20px; + } + + .dashboardBtn:hover { + background-color: unset; + } + + .idBtn { + display: flex; + align-items: center; + gap: 4px; + padding: 0px 2px; + border-radius: 2px; + background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent); + color: var(--bg-robin-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 142.857% */ + height: 20px; + + max-width: calc(100% - 120px); + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + :global(.ant-btn-icon) { + margin-inline-end: 4px; + } + } + .idBtn:hover { + background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent); + color: var(--bg-robin-300); + } + + .dashboardIconImage { + height: 14px; + width: 14px; + } +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.tsx new file mode 100644 index 00000000000..b015c80659c --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardBreadcrumbs.tsx @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; +import { Button } from '@signozhq/ui/button'; +import getSessionStorageApi from 'api/browser/sessionstorage/get'; +import ROUTES from 'constants/routes'; +import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams'; +import { useSafeNavigate } from 'hooks/useSafeNavigate'; +import { LayoutGrid } from '@signozhq/icons'; + +import styles from './DashboardBreadcrumbs.module.scss'; + +interface Props { + title: string; + image: string; +} + +function DashboardBreadcrumbs({ title, image }: Props): JSX.Element { + const { safeNavigate } = useSafeNavigate(); + + const goToListPage = useCallback(() => { + const dashboardsListQueryParamsString = getSessionStorageApi( + DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY, + ); + + if (dashboardsListQueryParamsString) { + safeNavigate({ + pathname: ROUTES.ALL_DASHBOARD, + search: `?${dashboardsListQueryParamsString}`, + }); + } else { + safeNavigate(ROUTES.ALL_DASHBOARD); + } + }, [safeNavigate]); + + return ( +
+ + +
+ ); +} + +export default DashboardBreadcrumbs; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.module.scss b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.module.scss new file mode 100644 index 00000000000..c1b2369ba41 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.module.scss @@ -0,0 +1,9 @@ +.dashboardHeader { + border-bottom: 1px solid var(--l1-border); + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 0 8px; + box-sizing: border-box; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.tsx new file mode 100644 index 00000000000..c302faece8f --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/components/DashboardHeader/DashboardHeader.tsx @@ -0,0 +1,22 @@ +import { memo } from 'react'; +import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection'; + +import DashboardBreadcrumbs from './DashboardBreadcrumbs'; + +import styles from './DashboardHeader.module.scss'; + +interface Props { + title: string; + image: string; +} + +function DashboardHeader({ title, image }: Props): JSX.Element { + return ( +
+ + +
+ ); +} + +export default memo(DashboardHeader); diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx b/frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx new file mode 100644 index 00000000000..5d9f2182772 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/index.tsx @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import { FullScreen, useFullScreenHandle } from 'react-full-screen'; + +import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas'; + +import DashboardDescription from './DashboardDescription'; +import PanelsAndSectionsLayout from './PanelsAndSectionsLayout'; +import styles from './DashboardContainer.module.scss'; + +interface Props { + dashboard: DashboardtypesGettableDashboardV2DTO; + refetch: () => void; +} + +function DashboardContainer({ dashboard, refetch }: Props): JSX.Element { + const fullScreenHandle = useFullScreenHandle(); + + const { spec } = dashboard; + const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]); + const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]); + + return ( + +
+ + +
+
+ ); +} + +export default DashboardContainer; diff --git a/frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts b/frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts new file mode 100644 index 00000000000..7224203ac06 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardContainer/utils.ts @@ -0,0 +1,154 @@ +import type { + DashboardtypesLayoutDTO, + DashboardtypesPanelDTO, +} from 'api/generated/services/sigNoz.schemas'; + +export interface GridItem { + id: string; + x: number; + y: number; + width: number; + height: number; + panel: DashboardtypesPanelDTO | undefined; +} + +const PANEL_REF_PREFIX = '#/spec/panels/'; + +export function extractPanelIdFromRef(ref: string | undefined): string | null { + if (!ref) { + return null; + } + if (!ref.startsWith(PANEL_REF_PREFIX)) { + return null; + } + return ref.slice(PANEL_REF_PREFIX.length); +} + +export function flattenGridLayout( + layouts: DashboardtypesLayoutDTO[] | undefined | null, + panels: Record | undefined, +): GridItem[] { + if (!layouts?.length) { + return []; + } + + const items: GridItem[] = []; + layouts.forEach((layoutEnvelope) => { + if (layoutEnvelope?.kind !== 'Grid') { + return; + } + const gridItems = layoutEnvelope.spec?.items ?? []; + gridItems.forEach((item) => { + const id = extractPanelIdFromRef(item.content?.$ref); + if (!id) { + return; + } + items.push({ + id, + x: item.x ?? 0, + y: item.y ?? 0, + width: item.width ?? 6, + height: item.height ?? 6, + panel: panels?.[id], + }); + }); + }); + + return items; +} + +/** + * A section corresponds to one entry in `spec.layouts`. If the Grid has a + * `display.title`, it renders with a collapsible header; otherwise it is a + * "default" untitled section (visually just the grid). + */ +export interface DashboardSection { + /** + * Stable identity used for React keys and dnd-kit sortable item ids. Derived + * from the section's content (its first panel ref) so it survives reordering + * — unlike the positional `layoutIndex`. See `getSectionStableId`. + */ + id: string; + /** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */ + layoutIndex: number; + title: string | undefined; + open: boolean; + items: GridItem[]; + repeatVariable: string | undefined; +} + +/** + * Derives a stable id for a section from its content. Reordering sections changes + * their `layoutIndex` but not their content, so keying off the first panel ref + * keeps React component instances (and any local state) bound to the right + * section across a reorder. Empty sections fall back to a positional id — they + * are rarely reordered, and a future backend `id` on the layout spec is the + * proper long-term fix. + */ +export function getSectionStableId( + items: GridItem[], + layoutIndex: number, +): string { + if (items.length > 0) { + return `sec-${items[0].id}`; + } + return `sec-empty-${layoutIndex}`; +} + +export function layoutsToSections( + layouts: DashboardtypesLayoutDTO[] | undefined | null, + panels: Record | undefined, +): DashboardSection[] { + if (!layouts?.length) { + return []; + } + + return layouts + .map((layoutEnvelope, idx) => { + if (layoutEnvelope?.kind !== 'Grid') { + return null; + } + const spec = layoutEnvelope.spec; + const items: GridItem[] = (spec?.items ?? []) + .map((item) => { + const id = extractPanelIdFromRef(item.content?.$ref); + if (!id) { + return null; + } + return { + id, + x: item.x ?? 0, + y: item.y ?? 0, + width: item.width ?? 6, + height: item.height ?? 6, + panel: panels?.[id], + }; + }) + .filter((it): it is GridItem => it !== null); + + const title = spec?.display?.title; + // `open` defaults to true when no collapse field is set (the section + // is expanded by default). + const open = spec?.display?.collapse?.open !== false; + + return { + id: getSectionStableId(items, idx), + layoutIndex: idx, + title, + open, + items, + repeatVariable: spec?.repeatVariable, + }; + }) + .filter((s): s is DashboardSection => s !== null); +} + +export function getPanelKindLabel( + panel: DashboardtypesPanelDTO | undefined, +): string { + const kind = panel?.spec?.plugin?.kind; + if (!kind) { + return 'unknown'; + } + return kind.replace(/^signoz\//, ''); +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardPageV2.module.scss b/frontend/src/pages/DashboardPageV2/DashboardPageV2.module.scss new file mode 100644 index 00000000000..618b2852e64 --- /dev/null +++ b/frontend/src/pages/DashboardPageV2/DashboardPageV2.module.scss @@ -0,0 +1,3 @@ +.errorState { + padding: 24px; +} diff --git a/frontend/src/pages/DashboardPageV2/DashboardPageV2.tsx b/frontend/src/pages/DashboardPageV2/DashboardPageV2.tsx index 2f57ebba57e..5d236346634 100644 --- a/frontend/src/pages/DashboardPageV2/DashboardPageV2.tsx +++ b/frontend/src/pages/DashboardPageV2/DashboardPageV2.tsx @@ -1,5 +1,43 @@ +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + +import { Typography } from '@signozhq/ui/typography'; +import { useGetDashboardV2 } from 'api/generated/services/dashboard'; +import Spinner from 'components/Spinner'; + +import DashboardContainer from './DashboardContainer'; +import styles from './DashboardPageV2.module.scss'; + function DashboardPageV2(): JSX.Element { - return <>DashboardPageV2; + const { dashboardId } = useParams<{ dashboardId: string }>(); + + const { data, isLoading, isError, error, refetch } = useGetDashboardV2({ + id: dashboardId, + }); + + const dashboard = data?.data; + const name = dashboard?.spec?.display?.name; + + useEffect(() => { + if (name) { + document.title = name; + } + }, [name]); + + if (isLoading) { + return ; + } + + if (isError || !dashboard) { + return ( +
+ Failed to load dashboard + {(error as Error)?.message} +
+ ); + } + + return ; } export default DashboardPageV2; From 4fce33e2b394d650d00b2831c2592a4b823f37bc Mon Sep 17 00:00:00 2001 From: Nikhil Soni Date: Wed, 3 Jun 2026 19:00:25 +0530 Subject: [PATCH 2/6] chore: add metric for waterfall monitoring (#11557) * chore: add metric for waterfall monitoring Gauge for config limit and counter to count large traces * chore: use traces instead of tracedetail in metric name * chore: try removing high cardinality attributes * chore: use typed argument in logs * chore: add another metric to get idea of trace sizes * chore: change config namespace to trace instead of tracedetail To make it consistant with metric namespaces * chore: add unit to the windowed response count metric * chore: use metrics names as per otel conventions * chore: use constants for metric attributes * refactor: return error on metric creation failure * Revert "refactor: return error on metric creation failure" This reverts commit 091c93e80b794a460d98eb574121aa7b98429889. * chore: panic on metric initiliazation error --- conf/example.yaml | 2 +- pkg/modules/tracedetail/config.go | 11 ++-- .../tracedetail/impltracedetail/module.go | 18 +++++- .../tracedetail/impltracedetail/telemetry.go | 55 +++++++++++++++++++ pkg/signoz/config.go | 2 +- 5 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 pkg/modules/tracedetail/impltracedetail/telemetry.go diff --git a/conf/example.yaml b/conf/example.yaml index 2a7166b12b9..5aaf6722eb0 100644 --- a/conf/example.yaml +++ b/conf/example.yaml @@ -432,7 +432,7 @@ cloudintegration: version: v0.0.8 ##################### Trace Detail ##################### -tracedetail: +traces: waterfall: # Number of spans returned per request when the trace is too large to show all at once. span_page_size: 500 diff --git a/pkg/modules/tracedetail/config.go b/pkg/modules/tracedetail/config.go index 4c622347513..f90d3b482d8 100644 --- a/pkg/modules/tracedetail/config.go +++ b/pkg/modules/tracedetail/config.go @@ -19,7 +19,7 @@ type WaterfallConfig struct { } func NewConfigFactory() factory.ConfigFactory { - return factory.NewConfigFactory(factory.MustNewName("tracedetail"), newConfig) + return factory.NewConfigFactory(factory.MustNewName("traces"), newConfig) } func newConfig() factory.Config { @@ -34,16 +34,13 @@ func newConfig() factory.Config { func (c Config) Validate() error { if c.Waterfall.SpanPageSize <= 0 { - return errors.NewInvalidInputf(errors.CodeInvalidInput, - "tracedetail.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize) + return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize) } if c.Waterfall.MaxDepthToAutoExpand < 0 { - return errors.NewInvalidInputf(errors.CodeInvalidInput, - "tracedetail.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand) + return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand) } if c.Waterfall.MaxLimitToSelectAllSpans == 0 { - return errors.NewInvalidInputf(errors.CodeInvalidInput, - "tracedetail.waterfall.max_limit_to_select_all_spans must be positive") + return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive") } return nil } diff --git a/pkg/modules/tracedetail/impltracedetail/module.go b/pkg/modules/tracedetail/impltracedetail/module.go index 435e170939d..d636911a009 100644 --- a/pkg/modules/tracedetail/impltracedetail/module.go +++ b/pkg/modules/tracedetail/impltracedetail/module.go @@ -7,21 +7,34 @@ import ( "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/modules/tracedetail" "github.com/SigNoz/signoz/pkg/types/spantypes" + "go.opentelemetry.io/otel/metric" ) type module struct { store spantypes.TraceStore settings factory.ScopedProviderSettings config tracedetail.Config + metrics *moduleMetrics } func NewModule(traceStore spantypes.TraceStore, providerSettings factory.ProviderSettings, cfg tracedetail.Config) *module { scopedProviderSettings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail") - return &module{ + + metrics, err := newModuleMetrics(scopedProviderSettings.Meter()) + if err != nil { + panic(err) + } + + m := &module{ config: cfg, store: traceStore, settings: scopedProviderSettings, + metrics: metrics, } + + m.metrics.waterfallSpanLimit.Record(context.Background(), int64(cfg.Waterfall.MaxLimitToSelectAllSpans), metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))) + + return m } func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) { @@ -80,6 +93,9 @@ func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpa } effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans) if summary.NumSpans > uint64(effectiveLimit) { + attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed)) + m.metrics.waterfallRequestCount.Add(ctx, 1, attrs) + m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs) return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End) } return m.getFullWaterfall(ctx, traceID, summary) diff --git a/pkg/modules/tracedetail/impltracedetail/telemetry.go b/pkg/modules/tracedetail/impltracedetail/telemetry.go new file mode 100644 index 00000000000..8bb0da0bc8a --- /dev/null +++ b/pkg/modules/tracedetail/impltracedetail/telemetry.go @@ -0,0 +1,55 @@ +package impltracedetail + +import ( + "github.com/SigNoz/signoz/pkg/errors" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const ( + attrResponseType = attribute.Key("response_type") + attrResponseTypeWindowed = "windowed" +) + +type moduleMetrics struct { + waterfallSpanLimit metric.Int64Gauge + waterfallRequestCount metric.Int64Counter + waterfallSpanCount metric.Int64Counter +} + +func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) { + var errs error + + spanLimit, err := meter.Int64Gauge( + "signoz.traces.waterfall.span.limit", + metric.WithDescription("The span count limit above which windowed waterfall is returned instead of the full waterfall."), + metric.WithUnit("{span}"), + ) + if err != nil { + errs = errors.Join(errs, err) + } + + requestCount, err := meter.Int64Counter( + "signoz.traces.waterfall.request.count", + metric.WithDescription("Total number of waterfall requests, by response_type."), + metric.WithUnit("{request}"), + ) + if err != nil { + errs = errors.Join(errs, err) + } + + spanCount, err := meter.Int64Counter( + "signoz.traces.waterfall.span.count", + metric.WithDescription("Total number of spans across waterfall requests, by response_type."), + metric.WithUnit("{span}"), + ) + if err != nil { + errs = errors.Join(errs, err) + } + + return &moduleMetrics{ + waterfallSpanLimit: spanLimit, + waterfallRequestCount: requestCount, + waterfallSpanCount: spanCount, + }, errs +} diff --git a/pkg/signoz/config.go b/pkg/signoz/config.go index eccd8284d5f..d2954c75781 100644 --- a/pkg/signoz/config.go +++ b/pkg/signoz/config.go @@ -143,7 +143,7 @@ type Config struct { CloudIntegration cloudintegration.Config `mapstructure:"cloudintegration"` // TraceDetail config - TraceDetail tracedetail.Config `mapstructure:"tracedetail"` + TraceDetail tracedetail.Config `mapstructure:"traces"` // Authz config Authz authz.Config `mapstructure:"authz"` From 86e71151d7966973b06bc25c584ce35fdc13914c Mon Sep 17 00:00:00 2001 From: Rinky Devi <82359874+rinkydevi@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:16:04 +0530 Subject: [PATCH 3/6] fix: remove widget filter references when a dashboard variable is deleted (#11270) * fix: the query being updated after deleting the variables * fix: use exact variable string match when removing clauses on delete Passing `true` to removeKeysFromExpression removed the first clause whose value contained any `$`, which corrupted expressions when two variables shared the same filter attribute (e.g. $env and $env_region both backed by deployment.environment). Switching to the exact variable string (`$${variableName}`) ensures only the deleted variable's clause is removed. Also adds 9 targeted edge-case tests covering shared-key variables, variable-name boundary ($env vs $environment), mixed literal/variable clauses, multi-value array filter items, clickhouse_sql, idempotency, empty widgets, and unrelated-variable no-ops. * fix: refined the deletiong process * fix: adding toast * fix: resolved comments * fix: updated the tests and moved func to utils --- .../src/components/QueryBuilderV2/utils.ts | 47 +++ .../addTagFiltersToDashboard.test.tsx | 328 ++++++++++++++++++ .../addTagFiltersToDashboard.tsx | 135 +++++++ .../DashboardVariableSettings/index.tsx | 41 ++- 4 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.test.tsx diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 0ce347391b8..67b21dd5103 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -721,6 +721,53 @@ export const removeKeysFromExpression = ( return result?.text ?? ''; }; +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const createVariablePlaceholderRegExp = ( + variableName: string, +): RegExp => { + const escapedName = escapeRegExp(variableName); + // (?![\w.]) prevents $env from matching inside $environment or $env.attr + return new RegExp( + `(\\$${escapedName}(?![\\w.])|\\{\\{\\s*\\.?${escapedName}\\s*\\}\\}|\\[\\[\\s*${escapedName}\\s*\\]\\])`, + 'g', + ); +}; + +const matchesVariablePlaceholder = ( + text: string, + variableName: string, +): boolean => createVariablePlaceholderRegExp(variableName).test(text); + +export const removeVariableFromExpression = ( + expression: string | undefined, + variableName: string, +): string => { + if (!expression) { + return ''; + } + + const queryPairs = extractQueryPairs(expression); + + const keysToRemove = queryPairs + .filter((pair) => { + const singleValue = pair.value?.toString() ?? ''; + const listValues = (pair.valueList ?? []).join(' '); + return ( + matchesVariablePlaceholder(singleValue, variableName) || + matchesVariablePlaceholder(listValues, variableName) + ); + }) + .map((pair) => pair.key); + + if (keysToRemove.length === 0) { + return expression; + } + + return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`); +}; + /** * Convert old having format to new having format * @param having - Array of old having objects with columnName, op, and value diff --git a/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.test.tsx b/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.test.tsx new file mode 100644 index 00000000000..e4f0576f0b7 --- /dev/null +++ b/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.test.tsx @@ -0,0 +1,328 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { Dashboard } from 'types/api/dashboard/getAll'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; +import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard'; + +// --------------------------------------------------------------------------- +// Shared fixture helpers +// --------------------------------------------------------------------------- + +const EMPTY_BUILDER = { + queryData: [] as any, + queryFormulas: [], + queryTraceOperator: [], +}; + +const BASE_WIDGET = { + opacity: '1', + nullZeroValues: 'null', + timePreferance: 'GLOBAL_TIME' as const, + softMin: null, + softMax: null, + selectedLogFields: null, + selectedTracesFields: null, +}; + +const DEFAULT_QUERY_DATA = { + queryName: 'q1', + // In QB v5, expression holds the query label (A/B/C), not a filter expression + expression: 'A', + dataSource: DataSource.METRICS, + functions: [], + groupBy: [], + filters: { items: [] as any[], op: 'AND' as const }, + legend: '', + disabled: false, + having: [], + limit: null, + stepInterval: null, + orderBy: [], + selectColumns: [], + source: '' as const, +}; + +/** + * Build a dashboard with a single builder widget. + * Only supply the fields your test actually cares about. + */ +const buildBuilderDashboard = ( + filterExpression: string, + queryDataOverrides: Record = {}, +): Dashboard => ({ + id: 'dash1', + createdAt: '', + updatedAt: '', + createdBy: '', + updatedBy: '', + data: { + title: 'Test Dashboard', + widgets: [ + { + ...BASE_WIDGET, + id: 'widget-1', + panelTypes: PANEL_TYPES.TIME_SERIES, + title: 'Widget 1', + description: '', + query: { + id: 'query1', + queryType: EQueryType.QUERY_BUILDER, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + { + ...DEFAULT_QUERY_DATA, + ...queryDataOverrides, + filter: { expression: filterExpression }, + }, + ], + queryFormulas: [], + queryTraceOperator: [], + }, + unit: '', + }, + }, + ], + variables: {}, + }, +}); + +const buildClickhouseDashboard = (query: string): Dashboard => ({ + id: 'dash-ch', + createdAt: '', + updatedAt: '', + createdBy: '', + updatedBy: '', + data: { + title: 'CH', + widgets: [ + { + ...BASE_WIDGET, + id: 'w1', + panelTypes: PANEL_TYPES.TIME_SERIES, + title: '', + description: '', + query: { + id: 'q1', + queryType: EQueryType.CLICKHOUSE, + promql: [], + clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }], + builder: EMPTY_BUILDER, + unit: '', + }, + }, + ], + variables: {}, + }, +}); + +const buildPromqlDashboard = (query: string): Dashboard => ({ + id: 'dash-prom', + createdAt: '', + updatedAt: '', + createdBy: '', + updatedBy: '', + data: { + title: 'PromQL Dashboard', + widgets: [ + { + ...BASE_WIDGET, + id: 'widget-prom', + panelTypes: PANEL_TYPES.TIME_SERIES, + title: 'PromQL Widget', + description: '', + query: { + id: 'query-prom', + queryType: EQueryType.PROM, + promql: [{ name: 'A', query, legend: '', disabled: false }], + clickhouse_sql: [], + builder: EMPTY_BUILDER, + unit: '', + }, + }, + ], + variables: {}, + }, +}); + +/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */ +const chQuery = (sql: string, varName: string): string => { + const result = removeVariableReferencesFromDashboard( + buildClickhouseDashboard(sql), + varName, + ); + return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query; +}; + +/** Extract the first builder queryData from a cleaned dashboard. */ +const firstBuilderQueryData = (dashboard: Dashboard | undefined): any => + (dashboard!.data.widgets![0] as any).query.builder.queryData[0]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('removeVariableReferencesFromDashboard', () => { + describe('builder filter expression cleanup', () => { + it('removes a variable clause from filter.expression', () => { + const dashboard = buildBuilderDashboard( + "service.name IN $service AND env = 'prod'", + ); + + const result = removeVariableReferencesFromDashboard(dashboard, 'service'); + + expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'"); + }); + + it('leaves no dangling AND/OR after removing a variable clause', () => { + const dashboard = buildBuilderDashboard( + "service.name IN $service AND env = 'prod'", + ); + + const result = removeVariableReferencesFromDashboard(dashboard, 'service'); + const { expression } = firstBuilderQueryData(result).filter; + + expect(expression).toBe("env = 'prod'"); + expect(expression).not.toMatch(/^\s*(AND|OR)/i); + expect(expression).not.toMatch(/(AND|OR)\s*$/i); + }); + + it('does not remove $environment clause when deleting $env', () => { + const dashboard = buildBuilderDashboard( + 'env = $env AND deployment.environment = $environment', + ); + + const result = removeVariableReferencesFromDashboard(dashboard, 'env'); + + expect(firstBuilderQueryData(result).filter.expression).toBe( + 'deployment.environment = $environment', + ); + }); + + it('leaves literal filter expressions untouched when removing a variable', () => { + const dashboard = buildBuilderDashboard( + "service.name = 'api-gateway' AND env = 'prod'", + ); + + const result = removeVariableReferencesFromDashboard(dashboard, 'service'); + + expect(firstBuilderQueryData(result).filter.expression).toBe( + "service.name = 'api-gateway' AND env = 'prod'", + ); + }); + + it('removes only the variable clause, preserving a literal clause on the same key', () => { + const dashboard = buildBuilderDashboard( + "service.name IN $service AND service.name = 'api-gateway'", + ); + + const result = removeVariableReferencesFromDashboard(dashboard, 'service'); + + expect(firstBuilderQueryData(result).filter.expression).toBe( + "service.name = 'api-gateway'", + ); + }); + + it('returns filter.expression unchanged when the variable has no clauses in it', () => { + const dashboard = buildBuilderDashboard("env = 'prod'"); + + const result = removeVariableReferencesFromDashboard(dashboard, 'service'); + + expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'"); + }); + }); + + describe('PromQL query cleanup', () => { + it('removes variable placeholder from a promql query', () => { + const result = removeVariableReferencesFromDashboard( + buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'), + 'service', + ); + + const widget = result!.data.widgets![0] as any; + expect(widget.query.promql[0].query).toBe( + 'sum(rate(http_requests_total{}[5m]))', + ); + }); + + it('strips only the variable token inside a PromQL label matcher (token-only path)', () => { + const result = removeVariableReferencesFromDashboard( + buildPromqlDashboard('up{env="$env", job="api"}'), + 'env', + ); + + const widget = result!.data.widgets![0] as any; + expect(widget.query.promql[0].query).toBe('up{env="", job="api"}'); + }); + }); + + describe('ClickHouse SQL query cleanup', () => { + it('removes a quoted variable clause and its WHERE keyword', () => { + expect( + chQuery( + "SELECT count() FROM signoz_logs WHERE service_name = '$service'", + 'service', + ), + ).toBe('SELECT count() FROM signoz_logs'); + }); + + it('removes a middle clause: AND env={{.env}} AND', () => { + expect( + chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'), + ).toBe('SELECT count() FROM t WHERE a=1 AND b=2'); + }); + + it('removes the first clause: env={{.env}} AND rest', () => { + expect( + chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'), + ).toBe('SELECT count() FROM t WHERE b=2'); + }); + + it('removes the last clause: rest AND env=$env', () => { + expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe( + 'SELECT count() FROM t WHERE a=1', + ); + }); + + it('removes a clause with double-bracket syntax: service=[[svc]]', () => { + expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe( + 'SELECT count() FROM t', + ); + }); + + it('falls back to token-only strip for a bare variable in SELECT', () => { + expect(chQuery('SELECT $metric FROM table', 'metric')).toBe( + 'SELECT FROM table', + ); + }); + }); + + describe('edge cases', () => { + it('is idempotent — calling twice produces the same result', () => { + const dashboard = buildBuilderDashboard( + "service.name IN $service AND env = 'prod'", + ); + + const once = removeVariableReferencesFromDashboard(dashboard, 'service'); + const twice = removeVariableReferencesFromDashboard(once, 'service'); + + expect(twice).toStrictEqual(once); + }); + + it('handles a dashboard with no widgets without throwing', () => { + const dashboard: Dashboard = { + id: 'dash-empty', + createdAt: '', + updatedAt: '', + createdBy: '', + updatedBy: '', + data: { title: 'Empty Dashboard', widgets: undefined, variables: {} }, + }; + + expect(() => + removeVariableReferencesFromDashboard(dashboard, 'service'), + ).not.toThrow(); + }); + }); +}); diff --git a/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.tsx b/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.tsx index 4c3d93f4077..6014480f716 100644 --- a/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.tsx +++ b/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/addTagFiltersToDashboard.tsx @@ -1,6 +1,8 @@ import { convertFiltersToExpressionWithExistingQuery, + createVariablePlaceholderRegExp, removeKeysFromExpression, + removeVariableFromExpression, } from 'components/QueryBuilderV2/utils'; import { cloneDeep, isArray, isEmpty } from 'lodash-es'; import { Dashboard, Widgets } from 'types/api/dashboard/getAll'; @@ -157,6 +159,139 @@ const updateAfterRemoval = ( }; }; +const removeVariablePlaceholders = ( + text: string | undefined, + variableName: string, +): string => { + if (!text) { + return ''; + } + + const tokenPattern = createVariablePlaceholderRegExp(variableName); + + // Step 1: attempt clause-aware removal for SQL WHERE patterns. + // Strips the entire `key op $var` unit plus its adjacent AND/OR so we + // never leave a dangling `key = ` in unquoted ClickHouse SQL clauses. + // Handles three shapes: + // (a) preceding conjunction: AND key = $var + // (b) following conjunction: key = $var AND + // (c) standalone clause: key = $var (end of expression) + const escapedToken = tokenPattern.source; + const clausePattern = new RegExp( + // (a) conjunction before the clause + `\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` + + // (b)+(c) clause first, optional conjunction after + `|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`, + 'gi', + ); + + const withClauseRemoval = text.replace(clausePattern, ''); + if (withClauseRemoval !== text) { + return withClauseRemoval + .replace(/\s{2,}/g, ' ') + .replace(/\bWHERE\s*$/i, '') + .trim(); + } + + // Step 2: fallback — bare variable usage outside a key-op-value pattern + // (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here. + return text + .replace(tokenPattern, '') + .replace(/\s{2,}/g, ' ') + .trim(); +}; + +const removeVariableReferencesFromQueryData = ( + queryData: IBuilderQuery, + variableName: string, +): IBuilderQuery => { + const updatedFilter = queryData.filter?.expression + ? { + ...queryData.filter, + expression: removeVariableFromExpression( + queryData.filter.expression, + variableName, + ), + } + : queryData.filter; + + return { ...queryData, filter: updatedFilter }; +}; + +const removeVariableReferencesFromWidget = ( + widget: Widgets, + variableName: string, +): Widgets => { + let updatedWidget = { ...widget }; + + if (updatedWidget.query?.builder?.queryData) { + updatedWidget = { + ...updatedWidget, + query: { + ...updatedWidget.query, + builder: { + ...updatedWidget.query.builder, + queryData: updatedWidget.query.builder.queryData.map((queryData) => + removeVariableReferencesFromQueryData(queryData, variableName), + ), + }, + }, + }; + } + + if (updatedWidget.query?.promql) { + updatedWidget = { + ...updatedWidget, + query: { + ...updatedWidget.query, + promql: updatedWidget.query.promql.map((promqlQuery) => ({ + ...promqlQuery, + query: removeVariablePlaceholders(promqlQuery.query, variableName), + })), + }, + }; + } + + if (updatedWidget.query?.clickhouse_sql) { + updatedWidget = { + ...updatedWidget, + query: { + ...updatedWidget.query, + clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({ + ...sqlQuery, + query: removeVariablePlaceholders(sqlQuery.query, variableName), + })), + }, + }; + } + + return updatedWidget; +}; + +export const removeVariableReferencesFromDashboard = ( + dashboard: Dashboard | undefined, + variableName: string, +): Dashboard | undefined => { + if (!dashboard || !variableName) { + return dashboard; + } + + const updatedDashboard = cloneDeep(dashboard); + + if (updatedDashboard.data.widgets) { + updatedDashboard.data.widgets = updatedDashboard.data.widgets.map( + (widget) => { + if ('query' in widget) { + return removeVariableReferencesFromWidget(widget as Widgets, variableName); + } + return widget; + }, + ); + } + + return updatedDashboard; +}; + /** * A function that takes a dashboard configuration and a list of tag filters * and returns an updated dashboard with the filters appended to widget queries. diff --git a/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/index.tsx b/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/index.tsx index c049c97813c..f39e9955b49 100644 --- a/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/index.tsx +++ b/frontend/src/container/DashboardContainer/DashboardSettings/DashboardVariableSettings/index.tsx @@ -18,10 +18,11 @@ import { convertVariablesToDbFormat } from 'container/DashboardContainer/Dashboa import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels'; import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables'; import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard'; -import { useNotifications } from 'hooks/useNotifications'; +import { toast } from '@signozhq/ui/sonner'; import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes'; import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore'; import { IDashboardVariable } from 'types/api/dashboard/getAll'; +import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard'; import { TVariableMode } from './types'; import VariableItem from './VariableItem/VariableItem'; @@ -92,8 +93,6 @@ function VariablesSettings({ const { dashboardData, setDashboardData } = useDashboardStore(); const { dashboardVariables } = useDashboardVariables(); - const { notifications } = useNotifications(); - const [variablesTableData, setVariablesTableData] = useState([]); const [variblesOrderArr, setVariablesOrderArr] = useState([]); const [existingVariableNamesMap, setExistingVariableNamesMap] = useState< @@ -201,9 +200,7 @@ function VariablesSettings({ onSuccess: (updatedDashboard) => { if (updatedDashboard.data) { setDashboardData(updatedDashboard.data); - notifications.success({ - message: t('variable_updated_successfully'), - }); + toast.success(t('variable_updated_successfully')); } }, }, @@ -256,6 +253,11 @@ function VariablesSettings({ }; const handleDeleteConfirm = (): void => { + if (!dashboardData || !variableToDelete.current) { + setDeleteVariableModal(false); + return; + } + const newVariablesArr = variablesTableData.filter( (variable: IDashboardVariable) => variable.id !== variableToDelete?.current?.id, @@ -263,7 +265,31 @@ function VariablesSettings({ const updatedVariables = convertVariablesToDbFormat(newVariablesArr); - updateVariables(updatedVariables); + const cleanedDashboard = + removeVariableReferencesFromDashboard( + dashboardData, + variableToDelete.current.name || '', + ) || dashboardData; + + updateMutation.mutateAsync( + { + id: dashboardData.id, + + data: { + ...cleanedDashboard.data, + variables: updatedVariables, + }, + }, + { + onSuccess: (updatedDashboard) => { + if (updatedDashboard.data) { + setDashboardData(updatedDashboard.data); + toast.success(t('variable_updated_successfully')); + } + }, + }, + ); + variableToDelete.current = null; setDeleteVariableModal(false); }; @@ -476,6 +502,7 @@ function VariablesSettings({ open={deleteVariableModal} onOk={handleDeleteConfirm} onCancel={handleDeleteCancel} + okButtonProps={{ loading: updateMutation.isLoading }} > Are you sure you want to delete variable{' '} From c5288fc1eacc0abbb9eb4551da96b0c9fe7da6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vinicius=20Louren=C3=A7o?= <12551007+H4ad@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:46:33 -0300 Subject: [PATCH 4/6] fix(dashboards): variables can be undefined when create new dashboard (#11155) --- .../ListOfDashboard/DashboardsList.tsx | 2 +- .../useTransformDashboardVariables.test.tsx | 38 +++++++++---------- .../useTransformDashboardVariables.ts | 11 +++--- frontend/src/pages/DashboardWidget/index.tsx | 2 +- .../dashboardVariablesStoreUtils.test.ts | 14 +++++++ .../dashboardVariablesStoreUtils.ts | 4 +- frontend/src/types/api/dashboard/getAll.ts | 2 +- 7 files changed, 44 insertions(+), 29 deletions(-) diff --git a/frontend/src/container/ListOfDashboard/DashboardsList.tsx b/frontend/src/container/ListOfDashboard/DashboardsList.tsx index cba922a393b..3103edfc598 100644 --- a/frontend/src/container/ListOfDashboard/DashboardsList.tsx +++ b/frontend/src/container/ListOfDashboard/DashboardsList.tsx @@ -232,7 +232,7 @@ function DashboardsList(): JSX.Element { isLocked: !!e.locked || false, lastUpdatedBy: e.updatedBy, image: e.data.image || Base64Icons[0], - variables: e.data.variables, + variables: e.data.variables ?? {}, widgets: e.data.widgets, layout: e.data.layout, panelMap: e.data.panelMap, diff --git a/frontend/src/hooks/dashboard/__test__/useTransformDashboardVariables.test.tsx b/frontend/src/hooks/dashboard/__test__/useTransformDashboardVariables.test.tsx index d23a7339597..13a0f414fd9 100644 --- a/frontend/src/hooks/dashboard/__test__/useTransformDashboardVariables.test.tsx +++ b/frontend/src/hooks/dashboard/__test__/useTransformDashboardVariables.test.tsx @@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - const orders = Object.values(result.data.variables).map((v) => v.order); + const orders = Object.values(result.data.variables!).map((v) => v.order); expect(orders).toContain(0); expect(orders).toContain(1); }); @@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.order).toBe(5); + expect(result.data.variables!.v1.order).toBe(5); }); it('assigns unique orders across multiple variables that all lack an order', () => { @@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - const orders = Object.values(result.data.variables).map((v) => v.order); + const orders = Object.values(result.data.variables!).map((v) => v.order); // All three newly assigned orders must be distinct expect(new Set(orders).size).toBe(3); }); @@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.id).toMatch( + expect(result.data.variables!.v1.id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); }); @@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.id).toBe('keep-me'); + expect(result.data.variables!.v1.id).toBe('keep-me'); }); }); @@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.defaultValue).toBe('hello'); + expect(result.data.variables!.v1.defaultValue).toBe('hello'); }); it('does not overwrite an existing defaultValue', () => { @@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.defaultValue).toBe('keep'); + expect(result.data.variables!.v1.defaultValue).toBe('keep'); }); }); @@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.selectedValue).toBe('staging'); + expect(result.data.variables!.v1.selectedValue).toBe('staging'); }); it('applies localStorage allSelected over DB value', () => { @@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.allSelected).toBe(true); + expect(result.data.variables!.v1.allSelected).toBe(true); }); }); @@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.allSelected).toBe(true); + expect(result.data.variables!.v1.allSelected).toBe(true); }); it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => { @@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.selectedValue).toBe('dev'); - expect(result.data.variables.v1.allSelected).toBe(false); + expect(result.data.variables!.v1.selectedValue).toBe('dev'); + expect(result.data.variables!.v1.allSelected).toBe(false); }); it('does not set allSelected=false when showALLOption is false', () => { @@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.selectedValue).toBe('dev'); - expect(result.data.variables.v1.allSelected).toBe(true); + expect(result.data.variables!.v1.selectedValue).toBe('dev'); + expect(result.data.variables!.v1.allSelected).toBe(true); }); it('normalizes array URL value to single value for single-select variable', () => { @@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.selectedValue).toBe('prod'); + expect(result.data.variables!.v1.selectedValue).toBe('prod'); }); it('wraps single URL value in array for multi-select variable', () => { @@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']); + expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']); }); it('looks up URL variable by variable id when name is absent', () => { @@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => { const result = transformDashboardVariables(dashboard); - expect(result.data.variables.v1.selectedValue).toBe('fallback'); + expect(result.data.variables!.v1.selectedValue).toBe('fallback'); }); }); @@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => { const dashboard = makeDashboard({ v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }), }); - const originalValue = dashboard.data.variables.v1.selectedValue; + const originalValue = dashboard.data.variables!.v1.selectedValue; transformDashboardVariables(dashboard); - expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue); + expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue); }); }); }); diff --git a/frontend/src/hooks/dashboard/useTransformDashboardVariables.ts b/frontend/src/hooks/dashboard/useTransformDashboardVariables.ts index b6e1b98376d..e1692bb8c31 100644 --- a/frontend/src/hooks/dashboard/useTransformDashboardVariables.ts +++ b/frontend/src/hooks/dashboard/useTransformDashboardVariables.ts @@ -22,11 +22,12 @@ export function useTransformDashboardVariables(dashboardId: string): Pick< localStorageVariables: any, ): Dashboard => { const updatedData = data; - if (data && localStorageVariables) { - const updatedVariables = data.data.variables; + const variables = data?.data?.variables; + if (data && localStorageVariables && variables) { + const updatedVariables = variables; const variablesFromUrl = getUrlVariables(); - Object.keys(data.data.variables).forEach((variable) => { - const variableData = data.data.variables[variable]; + Object.keys(variables).forEach((variable) => { + const variableData = variables[variable]; // values from url const urlVariable = variableData?.name @@ -34,7 +35,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick< : variablesFromUrl[variableData.id]; let updatedVariable = { - ...data.data.variables[variable], + ...variables[variable], ...localStorageVariables[variableData.name as any], }; diff --git a/frontend/src/pages/DashboardWidget/index.tsx b/frontend/src/pages/DashboardWidget/index.tsx index 34e707eb502..65a2db5db90 100644 --- a/frontend/src/pages/DashboardWidget/index.tsx +++ b/frontend/src/pages/DashboardWidget/index.tsx @@ -86,7 +86,7 @@ function DashboardWidgetInternal({ setDashboardData(updatedDashboardData); setDashboardVariablesStore({ dashboardId, - variables: updatedDashboardData.data.variables, + variables: updatedDashboardData.data.variables ?? {}, }); }, }); diff --git a/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts b/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts index b72a71c385f..7286ce5e3ec 100644 --- a/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts +++ b/frontend/src/providers/Dashboard/store/dashboardVariables/__tests__/dashboardVariablesStoreUtils.test.ts @@ -41,6 +41,20 @@ describe('dashboardVariablesStoreUtils', () => { expect(result).toStrictEqual([]); }); + it('should return empty array when variables is undefined', () => { + const result = buildSortedVariablesArray( + undefined as unknown as IDashboardVariables, + ); + expect(result).toStrictEqual([]); + }); + + it('should return empty array when variables is null', () => { + const result = buildSortedVariablesArray( + null as unknown as IDashboardVariables, + ); + expect(result).toStrictEqual([]); + }); + it('should create copies of variables (not references)', () => { const original = createVariable({ name: 'a', order: 0 }); const variables: IDashboardVariables = { a: original }; diff --git a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts b/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts index cf02b59c45a..b4e6fcb01bf 100644 --- a/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts +++ b/frontend/src/providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreUtils.ts @@ -17,11 +17,11 @@ import { * Build a sorted array of variables by their order property */ export function buildSortedVariablesArray( - variables: IDashboardVariables, + variables?: IDashboardVariables, ): IDashboardVariable[] { const sortedVariablesArray: IDashboardVariable[] = []; - Object.values(variables).forEach((value) => { + Object.values(variables ?? {}).forEach((value) => { sortedVariablesArray.push({ ...value }); }); diff --git a/frontend/src/types/api/dashboard/getAll.ts b/frontend/src/types/api/dashboard/getAll.ts index e726dfb42db..71275540921 100644 --- a/frontend/src/types/api/dashboard/getAll.ts +++ b/frontend/src/types/api/dashboard/getAll.ts @@ -95,7 +95,7 @@ export interface DashboardData { title: string; layout?: Layout[]; panelMap?: Record; - variables: Record; + variables?: Record; version?: string; image?: string; } From 95adbc31cc5555a9f27627bac094661f17de983f Mon Sep 17 00:00:00 2001 From: Ashwin Bhatkal Date: Thu, 4 Jun 2026 01:20:16 +0530 Subject: [PATCH 5/6] chore(dashboard): remove obsolete Sentry query-range timeout warning (#11576) Removes the useEffect that captured a Sentry warning when a widget's query range was not called within 120s, along with the now-unused queryRangeCalledRef and the @sentry/react import in GridCard. Closes SigNoz/engineering-pod#5217 --- .../container/GridCardLayout/GridCard/index.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index 1e814ed7277..cf78d92d512 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -1,7 +1,6 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'; // eslint-disable-next-line no-restricted-imports import { useDispatch, useSelector } from 'react-redux'; -import * as Sentry from '@sentry/react'; import logEvent from 'api/common/logEvent'; import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app'; import { QueryParams } from 'constants/query'; @@ -67,20 +66,6 @@ function GridCardGraph({ const [errorMessage, setErrorMessage] = useState(); const [isInternalServerError, setIsInternalServerError] = useState(false); - const queryRangeCalledRef = useRef(false); - - useEffect(() => { - const timeoutId = setTimeout(() => { - if (!queryRangeCalledRef.current) { - Sentry.captureEvent({ - message: `Dashboard query range not called within expected timeframe for widget ${widget?.id}`, - level: 'warning', - }); - } - }, 120000); - return (): void => clearTimeout(timeoutId); - }, [widget?.id]); - const { minTime, maxTime, @@ -271,14 +256,12 @@ function GridCardGraph({ }); } } - queryRangeCalledRef.current = true; }, onSettled: (data) => { dataAvailable?.( isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes), ); getGraphData?.(data?.payload?.data); - queryRangeCalledRef.current = true; }, }, ); From fdb22e666979481165bd2bcdad67ca1d84931c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vinicius=20Louren=C3=A7o?= <12551007+H4ad@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:53:35 -0300 Subject: [PATCH 6/6] test(alerts): add tests for list alerts & triggered alerts (#11554) * test(alerts): add tests for triggered and list alerts page * test(alerts): add tests for list alerts and triggered alerts * test(fireEvent): replace by userEvent --- frontend/__mocks__/useSafeNavigate.ts | 28 --- frontend/jest.config.ts | 6 +- frontend/src/__tests__/logEventMock.ts | 11 + frontend/src/__tests__/safeNavigateMock.ts | 29 +++ .../__tests__/FeedbackModal.test.tsx | 15 +- .../__tests__/HeaderRightSection.test.tsx | 14 +- .../__tests__/ShareURLModal.test.tsx | 10 +- .../cmdKPalette/__test__/cmdkPalette.test.tsx | 1 - .../sidebar-toggle-shortcut.test.tsx | 17 +- .../__tests__/TooltipFooter.test.tsx | 10 +- .../AlertsEmptyState/AlertsEmptyState.tsx | 9 +- .../__tests__/ListAlertRules.actions.test.tsx | 215 ++++++++++++++++ .../__tests__/ListAlertRules.columns.test.tsx | 79 ++++++ .../__tests__/ListAlertRules.empty.test.tsx | 91 +++++++ .../__tests__/ListAlertRules.list.test.tsx | 123 ++++++++++ .../ListAlertRules.newAlert.test.tsx | 65 +++++ .../ListAlertRules.pagination.test.tsx | 64 +++++ .../ListAlertRules.permissions.test.tsx | 71 ++++++ .../ListAlertRules.rowClick.test.tsx | 52 ++++ .../__tests__/ListAlertRules.search.test.tsx | 99 ++++++++ .../__tests__/ListAlertRules.utils.test.ts | 232 ++++++++++++++++++ .../ListAlertRules/__tests__/_helpers.tsx | 65 +++++ .../components/ColumnSelector.tsx | 1 + .../src/container/ListAlertRules/index.tsx | 2 + .../container/ListAlertRules/table.config.tsx | 24 +- .../MCPServerSettings.test.tsx | 9 +- .../MySettings/__tests__/MySettings.test.tsx | 9 +- .../__tests__/InviteTeamMembers.test.tsx | 5 - .../__tests__/OnboardingQuestionaire.test.tsx | 5 - .../tests/CreatePipelineButton.test.tsx | 15 +- .../__tests__/TriggeredAlerts.empty.test.tsx | 118 +++++++++ .../__tests__/TriggeredAlerts.filter.test.tsx | 87 +++++++ .../TriggeredAlerts.grouping.test.tsx | 85 +++++++ .../__tests__/TriggeredAlerts.list.test.tsx | 115 +++++++++ .../TriggeredAlerts.pagination.test.tsx | 76 ++++++ .../TriggeredAlerts.rowClick.test.tsx | 143 +++++++++++ .../__tests__/TriggeredAlerts.search.test.tsx | 118 +++++++++ .../__tests__/TriggeredAlerts.utils.test.ts | 181 ++++++++++++++ .../TriggeredAlerts/__tests__/_helpers.tsx | 71 ++++++ .../components/AlertStatusTag.tsx | 11 +- .../components/EmptyStates.tsx | 12 +- .../components/GroupTagsCell.tsx | 1 + .../src/container/TriggeredAlerts/index.tsx | 3 + .../TriggeredAlerts/table.config.tsx | 20 +- .../__tests__/useCreateAlerts.test.tsx | 5 - .../mocks-server/__mockdata__/alert_rules.ts | 85 +++++++ .../__mockdata__/triggered_alerts.ts | 102 ++++++++ frontend/src/mocks-server/handlers.ts | 34 +++ .../Settings/__tests__/Settings.test.tsx | 5 - .../pages/SignUp/__tests__/SignUp.test.tsx | 5 - frontend/src/tests/nuqs-helpers.ts | 41 ++++ 51 files changed, 2552 insertions(+), 142 deletions(-) delete mode 100644 frontend/__mocks__/useSafeNavigate.ts create mode 100644 frontend/src/__tests__/logEventMock.ts create mode 100644 frontend/src/__tests__/safeNavigateMock.ts create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.actions.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.columns.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.empty.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.list.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.newAlert.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.pagination.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.permissions.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.rowClick.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.search.test.tsx create mode 100644 frontend/src/container/ListAlertRules/__tests__/ListAlertRules.utils.test.ts create mode 100644 frontend/src/container/ListAlertRules/__tests__/_helpers.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.empty.test.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.filter.test.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.grouping.test.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.list.test.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.pagination.test.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.rowClick.test.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.search.test.tsx create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.utils.test.ts create mode 100644 frontend/src/container/TriggeredAlerts/__tests__/_helpers.tsx create mode 100644 frontend/src/mocks-server/__mockdata__/alert_rules.ts create mode 100644 frontend/src/mocks-server/__mockdata__/triggered_alerts.ts create mode 100644 frontend/src/tests/nuqs-helpers.ts diff --git a/frontend/__mocks__/useSafeNavigate.ts b/frontend/__mocks__/useSafeNavigate.ts deleted file mode 100644 index 5e1e62878dc..00000000000 --- a/frontend/__mocks__/useSafeNavigate.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests -interface SafeNavigateOptions { - replace?: boolean; - state?: unknown; - newTab?: boolean; -} - -interface SafeNavigateTo { - pathname?: string; - search?: string; - hash?: string; -} - -type SafeNavigateToType = string | SafeNavigateTo; - -interface UseSafeNavigateReturn { - safeNavigate: jest.MockedFunction< - (to: SafeNavigateToType, options?: SafeNavigateOptions) => void - >; -} - -export const useSafeNavigate = (): UseSafeNavigateReturn => ({ - safeNavigate: jest.fn( - (_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {}, - ) as jest.MockedFunction< - (to: SafeNavigateToType, options?: SafeNavigateOptions) => void - >, -}); diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 2705353c828..9f6a7fb47e1 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -1,6 +1,8 @@ import type { Config } from '@jest/types'; -const USE_SAFE_NAVIGATE_MOCK_PATH = '/__mocks__/useSafeNavigate.ts'; +const USE_SAFE_NAVIGATE_MOCK_PATH = + '/src/__tests__/safeNavigateMock.ts'; +const LOG_EVENT_MOCK_PATH = '/src/__tests__/logEventMock.ts'; const config: Config.InitialOptions = { silent: true, @@ -22,6 +24,8 @@ const config: Config.InitialOptions = { '^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, '^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, '^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, + '^api/common/logEvent$': LOG_EVENT_MOCK_PATH, + '^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH, '^constants/env$': '/__mocks__/env.ts', '^src/constants/env$': '/__mocks__/env.ts', '^@signozhq/icons$': '/__mocks__/signozhqIconsMock.tsx', diff --git a/frontend/src/__tests__/logEventMock.ts b/frontend/src/__tests__/logEventMock.ts new file mode 100644 index 00000000000..ef1909a912d --- /dev/null +++ b/frontend/src/__tests__/logEventMock.ts @@ -0,0 +1,11 @@ +// Shared mock for `api/common/logEvent`. +// Wired into jest.config.ts moduleNameMapper, so any import of +// `api/common/logEvent` in test code resolves to this file. +// Tests can import `logEventMock` to assert analytics calls — Jest's +// `clearMocks: true` resets call history between tests. + +export const logEventMock: jest.MockedFunction< + (eventName: string, attributes?: Record) => void +> = jest.fn(); + +export default logEventMock; diff --git a/frontend/src/__tests__/safeNavigateMock.ts b/frontend/src/__tests__/safeNavigateMock.ts new file mode 100644 index 00000000000..a11628e4920 --- /dev/null +++ b/frontend/src/__tests__/safeNavigateMock.ts @@ -0,0 +1,29 @@ +// Shared mock for `hooks/useSafeNavigate`. +// Wired into jest.config.ts moduleNameMapper, so any import of +// `hooks/useSafeNavigate` in test code resolves to this file. +// Tests can import `safeNavigateMock` to assert navigation calls — Jest's +// `clearMocks: true` resets call history between tests. + +interface SafeNavigateOptions { + replace?: boolean; + state?: unknown; + newTab?: boolean; +} + +interface SafeNavigateTo { + pathname?: string; + search?: string; + hash?: string; +} + +type SafeNavigateToType = string | SafeNavigateTo; + +export const safeNavigateMock: jest.MockedFunction< + (to: SafeNavigateToType, options?: SafeNavigateOptions) => void +> = jest.fn(); + +export const useSafeNavigate = (): { + safeNavigate: typeof safeNavigateMock; +} => ({ + safeNavigate: safeNavigateMock, +}); diff --git a/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx index 17a55c79f08..098e3e239cc 100644 --- a/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx +++ b/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx @@ -3,17 +3,12 @@ import { useLocation } from 'react-router-dom'; import { toast } from '@signozhq/ui/sonner'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import logEvent from 'api/common/logEvent'; +import { logEventMock } from '__tests__/logEventMock'; import { handleContactSupport } from 'container/Integrations/utils'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import FeedbackModal from '../FeedbackModal'; -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(() => Promise.resolve()), -})); - jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: jest.fn(), @@ -35,7 +30,6 @@ jest.mock('container/Integrations/utils', () => ({ handleContactSupport: jest.fn(), })); -const mockLogEvent = logEvent as jest.MockedFunction; const mockUseLocation = useLocation as jest.Mock; const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock; const mockHandleContactSupport = handleContactSupport as jest.Mock; @@ -50,6 +44,7 @@ const mockLocation = { describe('FeedbackModal', () => { beforeEach(() => { jest.clearAllMocks(); + logEventMock.mockReturnValue(Promise.resolve() as never); mockUseLocation.mockReturnValue(mockLocation); mockUseGetTenantLicense.mockReturnValue({ isCloudUser: false, @@ -116,7 +111,7 @@ describe('FeedbackModal', () => { await user.type(textarea, testFeedback); await user.click(submitButton); - expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', { + expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', { data: testFeedback, type: 'feedback', page: mockLocation.pathname, @@ -149,7 +144,7 @@ describe('FeedbackModal', () => { await user.type(textarea, testFeedback); await user.click(submitButton); - expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', { + expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', { data: testFeedback, type: 'reportBug', page: mockLocation.pathname, @@ -182,7 +177,7 @@ describe('FeedbackModal', () => { await user.type(textarea, testFeedback); await user.click(submitButton); - expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', { + expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', { data: testFeedback, type: 'featureRequest', page: mockLocation.pathname, diff --git a/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx index 312d3ab2e3e..35324c705ac 100644 --- a/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx +++ b/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx @@ -2,16 +2,11 @@ import { useLocation } from 'react-router-dom'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import logEvent from 'api/common/logEvent'; +import { logEventMock } from '__tests__/logEventMock'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import HeaderRightSection from '../HeaderRightSection'; -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(), -})); - jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: jest.fn(), @@ -50,7 +45,6 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({ useIsAIAssistantEnabled: (): boolean => false, })); -const mockLogEvent = logEvent as jest.Mock; const mockUseLocation = useLocation as jest.Mock; const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock; @@ -120,7 +114,7 @@ describe('HeaderRightSection', () => { await user.click(feedbackButton!); - expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', { + expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', { page: mockLocation.pathname, }); expect(screen.getByTestId('feedback-modal')).toBeInTheDocument(); @@ -133,7 +127,7 @@ describe('HeaderRightSection', () => { const shareButton = screen.getByRole('button', { name: /share/i }); await user.click(shareButton); - expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', { + expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', { page: mockLocation.pathname, }); expect(screen.getByTestId('share-modal')).toBeInTheDocument(); @@ -150,7 +144,7 @@ describe('HeaderRightSection', () => { await user.click(announcementsButton!); - expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', { + expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', { page: mockLocation.pathname, }); }); diff --git a/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx index 61227ade6ba..5c58da48e02 100644 --- a/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx +++ b/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx @@ -5,18 +5,13 @@ import { matchPath, useLocation } from 'react-router-dom'; import { useCopyToClipboard } from 'react-use'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import logEvent from 'api/common/logEvent'; +import { logEventMock } from '__tests__/logEventMock'; import ROUTES from 'constants/routes'; import useUrlQuery from 'hooks/useUrlQuery'; import GetMinMax from 'lib/getMinMax'; import ShareURLModal from '../ShareURLModal'; -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(), -})); - jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: jest.fn(), @@ -53,7 +48,6 @@ Object.defineProperty(window, 'location', { writable: true, }); -const mockLogEvent = logEvent as jest.Mock; const mockUseLocation = useLocation as jest.Mock; const mockUseUrlQuery = useUrlQuery as jest.Mock; const mockUseSelector = useSelector as jest.Mock; @@ -125,7 +119,7 @@ describe('ShareURLModal', () => { await user.click(copyButton); expect(mockHandleCopyToClipboard).toHaveBeenCalled(); - expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', { + expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', { page: TEST_PATH, URL: expect.any(String), }); diff --git a/frontend/src/components/cmdKPalette/__test__/cmdkPalette.test.tsx b/frontend/src/components/cmdKPalette/__test__/cmdkPalette.test.tsx index fb748815dad..42f7ff62822 100644 --- a/frontend/src/components/cmdKPalette/__test__/cmdkPalette.test.tsx +++ b/frontend/src/components/cmdKPalette/__test__/cmdkPalette.test.tsx @@ -139,7 +139,6 @@ jest.mock('react-query', (): unknown => { }); // mock other side-effecty modules -jest.mock('api/common/logEvent', () => jest.fn()); jest.mock('api/browser/localstorage/set', () => jest.fn()); jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() })); diff --git a/frontend/src/container/AppLayout/__tests__/sidebar-toggle-shortcut.test.tsx b/frontend/src/container/AppLayout/__tests__/sidebar-toggle-shortcut.test.tsx index 4b1d45850dc..bef182214be 100644 --- a/frontend/src/container/AppLayout/__tests__/sidebar-toggle-shortcut.test.tsx +++ b/frontend/src/container/AppLayout/__tests__/sidebar-toggle-shortcut.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from 'react-query'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import logEvent from 'api/common/logEvent'; +import { logEventMock } from '__tests__/logEventMock'; import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts'; import { USER_PREFERENCES } from 'constants/userPreferences'; import { @@ -24,8 +24,6 @@ jest.mock('providers/cmdKProvider', () => ({ }), })); -jest.mock('api/common/logEvent', () => jest.fn()); - // Mock the AppContext const mockUpdateUserPreferenceInContext = jest.fn(); @@ -139,7 +137,7 @@ describe('Sidebar Toggle Shortcut', () => { it('should log the toggle event with correct parameters', async () => { const user = userEvent.setup(); const mockHandleShortcut = jest.fn(() => { - logEvent('Global Shortcut: Sidebar Toggle', { + logEventMock('Global Shortcut: Sidebar Toggle', { previousState: false, newState: true, }); @@ -155,10 +153,13 @@ describe('Sidebar Toggle Shortcut', () => { await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT); - expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', { - previousState: false, - newState: true, - }); + expect(logEventMock).toHaveBeenCalledWith( + 'Global Shortcut: Sidebar Toggle', + { + previousState: false, + newState: true, + }, + ); }); it('should update user preference in context', async () => { diff --git a/frontend/src/container/DashboardContainer/visualization/panels/components/__tests__/TooltipFooter.test.tsx b/frontend/src/container/DashboardContainer/visualization/panels/components/__tests__/TooltipFooter.test.tsx index a8716786a2f..b0a6b04966a 100644 --- a/frontend/src/container/DashboardContainer/visualization/panels/components/__tests__/TooltipFooter.test.tsx +++ b/frontend/src/container/DashboardContainer/visualization/panels/components/__tests__/TooltipFooter.test.tsx @@ -1,16 +1,10 @@ +import { logEventMock } from '__tests__/logEventMock'; import { Events } from 'constants/events'; import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types'; import { render, screen, userEvent } from 'tests/test-utils'; import TooltipFooter from '../TooltipFooter'; -const mockLogEvent = jest.fn(); - -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: (...args: unknown[]): unknown => mockLogEvent(...args), -})); - describe('TooltipFooter', () => { const defaultProps = { id: 'panel-123', @@ -84,7 +78,7 @@ describe('TooltipFooter', () => { await user.click(screen.getByTestId('uplot-tooltip-unpin')); - expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, { + expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, { id: 'panel-123', }); expect(dismiss).toHaveBeenCalledTimes(1); diff --git a/frontend/src/container/ListAlertRules/AlertsEmptyState/AlertsEmptyState.tsx b/frontend/src/container/ListAlertRules/AlertsEmptyState/AlertsEmptyState.tsx index 819697cadd5..cb53ed759fd 100644 --- a/frontend/src/container/ListAlertRules/AlertsEmptyState/AlertsEmptyState.tsx +++ b/frontend/src/container/ListAlertRules/AlertsEmptyState/AlertsEmptyState.tsx @@ -89,7 +89,7 @@ export function AlertsEmptyState({ onClick={onClickNewAlertHandler} disabled={!addNewAlert} loading={loading} - data-testid="add-alert" + testId="add-alert" > @@ -97,7 +97,12 @@ export function AlertsEmptyState({ {onRefresh && ( - )} diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.actions.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.actions.test.tsx new file mode 100644 index 00000000000..bad398f9e35 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.actions.test.tsx @@ -0,0 +1,215 @@ +import userEvent from '@testing-library/user-event'; +import { logEventMock } from '__tests__/logEventMock'; +import { safeNavigateMock } from '__tests__/safeNavigateMock'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { screen, waitFor } from 'tests/test-utils'; + +import { findAlertRow, renderListAlertRules } from './_helpers'; + +async function openActionsMenu(row: HTMLElement): Promise { + const trigger = row.querySelector( + '[data-testid="alert-actions"]', + ) as HTMLElement | null; + expect(trigger).not.toBeNull(); + const user = userEvent.setup({ delay: null }); + await user.click(trigger as HTMLElement); + // Radix renders the menu items in a portal once the trigger is activated. + await screen.findByRole('menu'); +} + +async function clickMenuItem(label: string): Promise { + const user = userEvent.setup({ delay: null }); + const item = await screen.findByRole('menuitem', { name: label }); + await user.click(item); +} + +describe('ListAlertRules — actions menu', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('renders Enable/Disable/Edit/Edit in New Tab/Clone/Delete items after opening the menu', async () => { + renderListAlertRules(); + const row = await findAlertRow('High CPU Alert'); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + + await openActionsMenu(row); + + const items = screen.getAllByRole('menuitem'); + const labels = items.map((it) => it.textContent); + expect(labels).toStrictEqual( + expect.arrayContaining([ + 'Edit', + 'Edit in New Tab', + 'Clone', + 'Delete', + 'Disable', + ]), + ); + }); + + it('disabled rule (rule-4) shows "Enable" instead of "Disable"', async () => { + renderListAlertRules(); + const row = await findAlertRow('Disabled Alert'); + await openActionsMenu(row); + + const items = screen.getAllByRole('menuitem'); + const labels = items.map((it) => it.textContent); + expect(labels).toContain('Enable'); + expect(labels).not.toContain('Disable'); + }); + + it('toggle action: clicking Disable sends PATCH with disabled:true', async () => { + let capturedBody: unknown = null; + let capturedPath: string | null = null; + server.use( + rest.patch('http://localhost/api/v2/rules/:id', async (req, res, ctx) => { + capturedBody = await req.json(); + capturedPath = req.params.id as string; + return res(ctx.status(200), ctx.json({ status: 'success' })); + }), + ); + + renderListAlertRules(); + const row = await findAlertRow('High CPU Alert'); + await openActionsMenu(row); + await clickMenuItem('Disable'); + + await waitFor(() => { + expect(capturedBody).toStrictEqual( + expect.objectContaining({ disabled: true }), + ); + }); + expect(capturedPath).toBe('rule-1'); + + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Action', + expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }), + ); + }); + + it('edit action: clicking Edit navigates via safeNavigate and logs event', async () => { + renderListAlertRules(); + const row = await findAlertRow('High CPU Alert'); + await openActionsMenu(row); + await clickMenuItem('Edit'); + + await waitFor(() => { + expect(safeNavigateMock).toHaveBeenCalled(); + }); + expect(safeNavigateMock.mock.calls[0][0]).toContain('ruleId=rule-1'); + + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Action', + expect.objectContaining({ action: 'Edit', ruleId: 'rule-1' }), + ); + }); + + it('edit in new tab action: clicking opens with newTab:true', async () => { + renderListAlertRules(); + const row = await findAlertRow('High CPU Alert'); + await openActionsMenu(row); + await clickMenuItem('Edit in New Tab'); + + await waitFor(() => { + expect(safeNavigateMock).toHaveBeenCalled(); + }); + const [url, options] = safeNavigateMock.mock.calls[0]; + expect(url).toContain('ruleId=rule-1'); + expect(options).toStrictEqual(expect.objectContaining({ newTab: true })); + }); + + it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => { + let capturedPostBody: unknown = null; + server.use( + rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => { + capturedPostBody = await req.json(); + return res( + ctx.status(201), + ctx.json({ + data: { + ...(capturedPostBody as Record), + id: 'cloned-from-server', + }, + status: 'success', + }), + ); + }), + ); + + renderListAlertRules(); + const row = await findAlertRow('High CPU Alert'); + await openActionsMenu(row); + await clickMenuItem('Clone'); + + await waitFor(() => { + expect(capturedPostBody).toStrictEqual( + expect.objectContaining({ alert: 'High CPU Alert - Copy' }), + ); + }); + + // The id from the server response round-trips into the navigate URL — this + // protects against a regression where the code hardcodes the id. + await waitFor(() => { + expect(safeNavigateMock).toHaveBeenCalled(); + }); + expect(safeNavigateMock.mock.calls[0][0]).toContain( + 'ruleId=cloned-from-server', + ); + + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Action', + expect.objectContaining({ action: 'Clone', ruleId: 'rule-1' }), + ); + }); + + it('delete action: sends DELETE for the rule id', async () => { + let deletedId: string | null = null; + server.use( + rest.delete('http://localhost/api/v2/rules/:id', (req, res, ctx) => { + deletedId = req.params.id as string; + return res(ctx.status(200), ctx.json({ status: 'success' })); + }), + ); + + renderListAlertRules(); + const row = await findAlertRow('High CPU Alert'); + await openActionsMenu(row); + await clickMenuItem('Delete'); + + await waitFor(() => { + expect(deletedId).toBe('rule-1'); + }); + + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Action', + expect.objectContaining({ action: 'Delete', ruleId: 'rule-1' }), + ); + }); + + it('error path: PATCH is still attempted when server returns 500', async () => { + let patchAttempted = false; + server.use( + rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => { + patchAttempted = true; + return res(ctx.status(500), ctx.json({ status: 'error' })); + }), + ); + + renderListAlertRules(); + const row = await findAlertRow('High CPU Alert'); + await openActionsMenu(row); + await clickMenuItem('Disable'); + + await waitFor(() => { + expect(patchAttempted).toBe(true); + }); + + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Action', + expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }), + ); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.columns.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.columns.test.tsx new file mode 100644 index 00000000000..ca637261647 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.columns.test.tsx @@ -0,0 +1,79 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from 'tests/test-utils'; + +import { renderListAlertRules } from './_helpers'; + +const COLUMN_STORAGE_KEY = '@signoz/table-columns/alert-rules-columns'; + +describe('ListAlertRules — columns selector', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('opens columns popover and lists toggleable columns', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.click(screen.getByTestId('alert-columns-button')); + + // Popover should reveal "Toggle Columns" heading + per-column labels. + await screen.findByText('Toggle Columns'); + expect(screen.getByText('Created At')).toBeInTheDocument(); + expect(screen.getByText('Created By')).toBeInTheDocument(); + expect(screen.getByText('Updated At')).toBeInTheDocument(); + expect(screen.getByText('Updated By')).toBeInTheDocument(); + }); + + it('default-hidden columns (Created At/By, Updated At/By) are not in the table header', async () => { + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + const headers = document.querySelectorAll('th'); + const headerTexts = Array.from(headers).map((h) => h.textContent || ''); + expect(headerTexts.some((t) => t.includes('Created At'))).toBe(false); + expect(headerTexts.some((t) => t.includes('Created By'))).toBe(false); + expect(headerTexts.some((t) => t.includes('Updated At'))).toBe(false); + expect(headerTexts.some((t) => t.includes('Updated By'))).toBe(false); + }); + + it('toggling Created At on writes to localStorage and adds the header', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + const headersBefore = Array.from(document.querySelectorAll('th')).map( + (h) => h.textContent ?? '', + ); + expect(headersBefore.some((t) => t.includes('Created At'))).toBe(false); + + await user.click(screen.getByTestId('alert-columns-button')); + await screen.findByText('Toggle Columns'); + + const checkbox = document.getElementById('col-createdAt'); + expect(checkbox).not.toBeNull(); + await user.click(checkbox as HTMLElement); + + await waitFor(() => { + const stored = window.localStorage.getItem(COLUMN_STORAGE_KEY); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored as string); + expect(parsed.hiddenColumnIds).not.toContain('createdAt'); + }); + + await waitFor(() => { + const headersAfter = Array.from(document.querySelectorAll('th')).map( + (h) => h.textContent ?? '', + ); + expect(headersAfter.some((t) => t.includes('Created At'))).toBe(true); + }); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.empty.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.empty.test.tsx new file mode 100644 index 00000000000..77e2a457a3b --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.empty.test.tsx @@ -0,0 +1,91 @@ +import { safeNavigateMock } from '__tests__/safeNavigateMock'; +import userEvent from '@testing-library/user-event'; +import ROUTES from 'constants/routes'; +import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { screen } from 'tests/test-utils'; + +import { renderListAlertRules } from './_helpers'; + +describe('ListAlertRules — empty states', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('renders AlertsEmptyState when API returns no rules', async () => { + const user = userEvent.setup({ delay: null }); + + server.use( + rest.get('http://localhost/api/v2/rules', (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: [], status: 'success' })), + ), + ); + + renderListAlertRules(); + + await screen.findByText('No Alert rules yet.'); + expect( + screen.getByText('Create an Alert Rule to get started'), + ).toBeInTheDocument(); + + // New Alert Rule button is visible and triggers safeNavigate to ALERTS_NEW. + await user.click(screen.getByTestId('add-alert')); + expect(safeNavigateMock).toHaveBeenCalledWith( + ROUTES.ALERTS_NEW, + expect.objectContaining({ newTab: false }), + ); + }); + + it('renders ErrorEmptyState when API returns 500; refresh triggers a refetch', async () => { + const user = userEvent.setup({ delay: null }); + + let callCount = 0; + server.use( + rest.get('http://localhost/api/v2/rules', (_, res, ctx) => { + callCount += 1; + if (callCount === 1) { + return res(ctx.status(500), ctx.json({ status: 'error' })); + } + return res( + ctx.status(200), + ctx.json({ data: alertRulesFixture, status: 'success' }), + ); + }), + ); + + renderListAlertRules(); + + await screen.findByTestId('error-empty-state'); + + await user.click(screen.getByTestId('error-refresh-button')); + + const rule = await screen.findByText('High CPU Alert'); + expect(rule).toBeInTheDocument(); + }); + + it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => { + const user = userEvent.setup({ delay: null }); + + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + const searchInput = screen.getByTestId('list-alerts-search-input'); + await user.clear(searchInput); + await user.type(searchInput, 'totally-not-found'); + + await screen.findByTestId('no-results-empty-state'); + expect(screen.getByTestId('no-results-title')).toHaveTextContent( + 'No matching alert rules', + ); + expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent( + 'No alert rules match your search. Try adjusting your search criteria.', + ); + + await user.click(screen.getByTestId('no-results-clear-button')); + + const rule = await screen.findByText('High CPU Alert'); + expect(rule).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.list.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.list.test.tsx new file mode 100644 index 00000000000..705f5ca2817 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.list.test.tsx @@ -0,0 +1,123 @@ +import { screen, waitFor } from 'tests/test-utils'; + +import { renderListAlertRules } from './_helpers'; + +describe('ListAlertRules — list rendering', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('renders alert rules from API', async () => { + renderListAlertRules(); + + await expect( + screen.findByTestId('alert-row-rule-1-name'), + ).resolves.toHaveTextContent('High CPU Alert'); + expect(screen.getByTestId('alert-row-rule-2-name')).toHaveTextContent( + 'Memory Pending Alert', + ); + expect(screen.getByTestId('alert-row-rule-3-name')).toHaveTextContent( + 'Healthy Alert', + ); + expect(screen.getByTestId('alert-row-rule-4-name')).toHaveTextContent( + 'Disabled Alert', + ); + }); + + it('renders state badges via STATE_CONFIG mapping', async () => { + renderListAlertRules(); + + await waitFor(() => + expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(), + ); + + expect(screen.getByTestId('alert-row-rule-1-state')).toHaveTextContent( + 'Firing', + ); + expect(screen.getByTestId('alert-row-rule-2-state')).toHaveTextContent( + 'Pending', + ); + expect(screen.getByTestId('alert-row-rule-3-state')).toHaveTextContent('OK'); + expect(screen.getByTestId('alert-row-rule-4-state')).toHaveTextContent( + 'Disabled', + ); + expect(screen.getByTestId('alert-row-rule-5-state')).toHaveTextContent('OK'); + }); + + it('renders state badges with semantic colors', async () => { + renderListAlertRules(); + + await waitFor(() => + expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(), + ); + + expect(screen.getByTestId('alert-row-rule-1-state')).toHaveAttribute( + 'data-color', + 'cherry', + ); + expect(screen.getByTestId('alert-row-rule-2-state')).toHaveAttribute( + 'data-color', + 'amber', + ); + expect(screen.getByTestId('alert-row-rule-3-state')).toHaveAttribute( + 'data-color', + 'forest', + ); + expect(screen.getByTestId('alert-row-rule-4-state')).toHaveAttribute( + 'data-color', + 'vanilla', + ); + }); + + it('renders severity badges for rules with severity', async () => { + renderListAlertRules(); + + await waitFor(() => + expect(screen.getByTestId('alert-row-rule-1-severity')).toBeInTheDocument(), + ); + + expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveTextContent( + 'critical', + ); + expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveTextContent( + 'warning', + ); + expect(screen.getByTestId('alert-row-rule-3-severity')).toHaveTextContent( + 'info', + ); + expect(screen.getByTestId('alert-row-rule-4-severity')).toHaveTextContent( + 'critical', + ); + expect(screen.getByTestId('alert-row-rule-5-severity')).toHaveTextContent( + '-', + ); + expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveAttribute( + 'data-color', + 'cherry', + ); + expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveAttribute( + 'data-color', + 'amber', + ); + }); + + it('renders header controls (search, columns, new alert)', async () => { + renderListAlertRules(); + + await waitFor(() => + expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(), + ); + + expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'), + ).toBeInTheDocument(); + expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument(); + expect( + screen.getByTestId('list-alerts-new-alert-button'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /new alert/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.newAlert.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.newAlert.test.tsx new file mode 100644 index 00000000000..78bbcffab00 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.newAlert.test.tsx @@ -0,0 +1,65 @@ +import { logEventMock } from '__tests__/logEventMock'; +import { safeNavigateMock } from '__tests__/safeNavigateMock'; +import userEvent from '@testing-library/user-event'; +import ROUTES from 'constants/routes'; +import { screen, waitFor } from 'tests/test-utils'; + +import { renderListAlertRules } from './_helpers'; + +describe('ListAlertRules — new alert button', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('plain click navigates to ALERTS_NEW with newTab:false', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.click(screen.getByRole('button', { name: /new alert/i })); + + await waitFor(() => { + expect(safeNavigateMock).toHaveBeenCalled(); + }); + expect(safeNavigateMock).toHaveBeenCalledWith( + ROUTES.ALERTS_NEW, + expect.objectContaining({ newTab: false }), + ); + }); + + it('logs Alert: New alert button clicked', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.click(screen.getByRole('button', { name: /new alert/i })); + + await waitFor(() => { + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: New alert button clicked', + expect.objectContaining({ layout: 'new' }), + ); + }); + }); + + it('ctrl+click on New Alert opens in a new tab (newTab:true)', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.keyboard('{Control>}'); + await user.click(screen.getByRole('button', { name: /new alert/i })); + await user.keyboard('{/Control}'); + + await waitFor(() => { + expect(safeNavigateMock).toHaveBeenCalled(); + }); + expect(safeNavigateMock).toHaveBeenCalledWith( + ROUTES.ALERTS_NEW, + expect.objectContaining({ newTab: true }), + ); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.pagination.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.pagination.test.tsx new file mode 100644 index 00000000000..11b498ac963 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.pagination.test.tsx @@ -0,0 +1,64 @@ +import userEvent from '@testing-library/user-event'; +import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { screen, waitFor } from 'tests/test-utils'; +import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers'; + +import { renderListAlertRules } from './_helpers'; + +describe('ListAlertRules — pagination', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + server.use( + rest.get('http://localhost/api/v2/rules', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ data: alertRulesPaginationFixture, status: 'success' }), + ), + ), + ); + }); + + it('shows first 10 rows on page 1 (default limit)', async () => { + renderListAlertRules(); + + await screen.findByText('Pag Rule 0'); + + for (let i = 0; i < 10; i += 1) { + expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument(); + } + expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument(); + expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument(); + }); + + it('shows total count when showTotalCount is enabled', async () => { + renderListAlertRules(); + + await screen.findByText('Pag Rule 0'); + + const totalCount = await screen.findByTestId('pagination-total-count'); + expect(totalCount.textContent).toContain('Showing'); + expect(totalCount.textContent).toContain('of 15'); + }); + + it('navigates to page 2 and shows remaining rows', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('Pag Rule 0'); + + const nextBtn = screen.getByLabelText('Go to next page'); + await user.click(nextBtn); + + await waitFor(() => { + expect(screen.getByText('Pag Rule 10')).toBeInTheDocument(); + expect(screen.getByText('Pag Rule 14')).toBeInTheDocument(); + expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getCurrentNuqsQueryString()).toContain('page=2'); + }); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.permissions.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.permissions.test.tsx new file mode 100644 index 00000000000..d9a15f0eba2 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.permissions.test.tsx @@ -0,0 +1,71 @@ +import { screen, waitFor } from 'tests/test-utils'; +import { USER_ROLES } from 'types/roles'; + +import { renderListAlertRules } from './_helpers'; + +describe('ListAlertRules — permissions', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('VIEWER role hides "New Alert" button and "Actions" column', async () => { + renderListAlertRules({ role: USER_ROLES.VIEWER }); + + await screen.findByText('High CPU Alert'); + + expect( + screen.queryByTestId('list-alerts-new-alert-button'), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /new alert/i }), + ).not.toBeInTheDocument(); + + const headers = Array.from(document.querySelectorAll('th')).map( + (h) => h.textContent ?? '', + ); + expect(headers.some((t) => t.includes('Actions'))).toBe(false); + expect(screen.queryByTestId('alert-actions')).not.toBeInTheDocument(); + }); + + it('ADMIN role shows "New Alert" button and "Actions" column', async () => { + renderListAlertRules({ role: USER_ROLES.ADMIN }); + + await screen.findByText('High CPU Alert'); + + expect( + screen.getByTestId('list-alerts-new-alert-button'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /new alert/i }), + ).toBeInTheDocument(); + + await waitFor(() => { + const headers = Array.from(document.querySelectorAll('th')).map( + (h) => h.textContent ?? '', + ); + expect(headers.some((t) => t.includes('Actions'))).toBe(true); + }); + expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0); + }); + + it('EDITOR role behaves like ADMIN (New Alert + Actions visible)', async () => { + renderListAlertRules({ role: USER_ROLES.EDITOR }); + + await screen.findByText('High CPU Alert'); + + expect( + screen.getByTestId('list-alerts-new-alert-button'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /new alert/i }), + ).toBeInTheDocument(); + + await waitFor(() => { + const headers = Array.from(document.querySelectorAll('th')).map( + (h) => h.textContent ?? '', + ); + expect(headers.some((t) => t.includes('Actions'))).toBe(true); + }); + expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.rowClick.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.rowClick.test.tsx new file mode 100644 index 00000000000..b478e2515fb --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.rowClick.test.tsx @@ -0,0 +1,52 @@ +import { safeNavigateMock } from '__tests__/safeNavigateMock'; +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from 'tests/test-utils'; + +import { renderListAlertRules } from './_helpers'; + +describe('ListAlertRules — row click navigation', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + const ruleCell = await screen.findByText('High CPU Alert'); + + const td = ruleCell.closest('td'); + expect(td).not.toBeNull(); + await user.click(td as HTMLElement); + + await waitFor(() => { + expect(safeNavigateMock).toHaveBeenCalled(); + }); + + const [url] = safeNavigateMock.mock.calls[0]; + expect(url).toContain('/alerts/overview?'); + expect(url).toContain('ruleId=rule-1'); + expect(url).toContain('panelTypes=graph'); + expect(url).toContain('compositeQuery='); + }); + + it('ctrl+click on a row navigates with newTab option', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + const ruleCell = await screen.findByText('High CPU Alert'); + + const td = ruleCell.closest('td'); + await user.keyboard('{Control>}'); + await user.click(td as HTMLElement); + await user.keyboard('{/Control}'); + + await waitFor(() => { + expect(safeNavigateMock).toHaveBeenCalled(); + }); + + const [url, options] = safeNavigateMock.mock.calls[0]; + expect(url).toContain('ruleId=rule-1'); + expect(options).toStrictEqual(expect.objectContaining({ newTab: true })); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.search.test.tsx b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.search.test.tsx new file mode 100644 index 00000000000..89e01f1596d --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.search.test.tsx @@ -0,0 +1,99 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from 'tests/test-utils'; +import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers'; + +import { renderListAlertRules } from './_helpers'; + +function getSearchInput(): HTMLInputElement { + return screen.getByTestId('list-alerts-search-input') as HTMLInputElement; +} + +describe('ListAlertRules — search', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('filters rows by alert name with debounce', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.clear(getSearchInput()); + await user.type(getSearchInput(), 'CPU'); + + await waitFor(() => { + expect(screen.getByText('High CPU Alert')).toBeInTheDocument(); + expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument(); + }); + }); + + it('filters rows by label values (severity)', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.clear(getSearchInput()); + await user.type(getSearchInput(), 'warning'); + + await waitFor(() => { + expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument(); + expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument(); + }); + }); + + it('restores all rows when search is cleared', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.clear(getSearchInput()); + await user.type(getSearchInput(), 'CPU'); + + await waitFor(() => { + expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument(); + }); + + await user.clear(getSearchInput()); + + await waitFor(() => { + expect(screen.getByText('High CPU Alert')).toBeInTheDocument(); + expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument(); + expect(screen.getByText('Healthy Alert')).toBeInTheDocument(); + }); + }); + + it('shows no-results state when no match', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules(); + + await screen.findByText('High CPU Alert'); + + await user.clear(getSearchInput()); + await user.type(getSearchInput(), 'zzzzzz-no-match'); + + await waitFor(() => { + expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument(); + expect(screen.getByTestId('no-results-title')).toHaveTextContent( + 'No matching alert rules', + ); + }); + }); + + it('resets page to 1 when search debounce fires', async () => { + const user = userEvent.setup({ delay: null }); + renderListAlertRules({ initialRoute: '/?page=2' }); + + // Page 2 of the 4-rule fixture has no rows; we only need the search input + // to be mounted, which happens before data is fetched. + const input = await screen.findByTestId('list-alerts-search-input'); + await user.clear(input); + await user.type(input, 'CPU'); + + await waitFor(() => { + expect(getCurrentNuqsQueryString()).not.toContain('page=2'); + }); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.utils.test.ts b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.utils.test.ts new file mode 100644 index 00000000000..a61885473b6 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/ListAlertRules.utils.test.ts @@ -0,0 +1,232 @@ +import { logEventMock } from '__tests__/logEventMock'; +import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas'; +import type { SortState } from 'components/TanStackTableView/types'; + +import type { AlertRule } from '../types'; +import { + ALERT_ACTIONS, + alertActionLogEvent, + filterRulesByFilters, + getAlertSortValue, + sortRules, +} from '../utils'; + +const baseRule = { + id: 'r1', + alert: 'Rule 1', + alertType: 'METRIC_BASED_ALERT', + state: 'inactive', + labels: { severity: 'info' }, + condition: {}, + createdAt: '2023-10-15T10:00:00Z', + updatedAt: '2023-10-19T10:00:00Z', +} as unknown as AlertRule; + +const makeRule = (overrides: Partial): AlertRule => ({ + ...baseRule, + ...overrides, +}); + +describe('getAlertSortValue', () => { + it('returns state for "state"', () => { + expect( + getAlertSortValue( + makeRule({ state: RuletypesAlertStateDTO.firing }), + 'state', + ), + ).toBe('firing'); + }); + + it('returns alert name for "name"', () => { + expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe( + 'My Rule', + ); + }); + + it('returns severity label for "severity"', () => { + expect( + getAlertSortValue( + makeRule({ labels: { severity: 'critical' } }), + 'severity', + ), + ).toBe('critical'); + }); + + it('returns createdAt as ms', () => { + const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' }); + const result = getAlertSortValue(rule, 'createdAt'); + expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime()); + }); + + it('returns updatedAt as ms', () => { + const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' }); + const result = getAlertSortValue(rule, 'updatedAt'); + expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime()); + }); + + it('returns 0 when createdAt missing', () => { + expect( + getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'), + ).toBe(0); + }); + + it('returns empty for unknown column', () => { + expect(getAlertSortValue(baseRule, 'xxx')).toBe(''); + }); + + it('returns empty for missing fields', () => { + expect( + getAlertSortValue( + makeRule({ state: undefined, labels: undefined }), + 'state', + ), + ).toBe(''); + expect( + getAlertSortValue( + makeRule({ state: undefined, labels: undefined }), + 'severity', + ), + ).toBe(''); + }); +}); + +describe('sortRules', () => { + const r1 = makeRule({ id: '1', alert: 'A' }); + const r2 = makeRule({ id: '2', alert: 'B' }); + const r3 = makeRule({ id: '3', alert: 'C' }); + + it('sorts ascending by name', () => { + const order: SortState = { columnName: 'name', order: 'asc' }; + const result = sortRules([r3, r1, r2], order); + expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']); + }); + + it('sorts descending by name', () => { + const order: SortState = { columnName: 'name', order: 'desc' }; + const result = sortRules([r1, r2, r3], order); + expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']); + }); + + it('returns unsorted when orderBy is null', () => { + const result = sortRules([r3, r1, r2], null); + expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']); + }); +}); + +describe('filterRulesByFilters', () => { + const r1 = makeRule({ + id: '1', + alert: 'R1', + state: RuletypesAlertStateDTO.firing, + labels: { severity: 'critical' }, + }); + const r2 = makeRule({ + id: '2', + alert: 'R2', + state: RuletypesAlertStateDTO.inactive, + labels: { severity: 'warning' }, + }); + const r3 = makeRule({ + id: '3', + alert: 'R3', + state: RuletypesAlertStateDTO.firing, + labels: { severity: 'warning' }, + }); + const rules = [r1, r2, r3]; + + it('returns input when filters empty', () => { + expect(filterRulesByFilters(rules, [])).toStrictEqual(rules); + }); + + it('filters by state', () => { + const result = filterRulesByFilters(rules, ['state:firing']); + expect(result.map((r) => r.id)).toStrictEqual(['1', '3']); + }); + + it('filters by severity', () => { + const result = filterRulesByFilters(rules, ['severity:warning']); + expect(result.map((r) => r.id)).toStrictEqual(['2', '3']); + }); + + it('combines state AND severity', () => { + const result = filterRulesByFilters(rules, [ + 'state:firing', + 'severity:warning', + ]); + expect(result.map((r) => r.id)).toStrictEqual(['3']); + }); + + it('OR within same key (state)', () => { + const result = filterRulesByFilters(rules, [ + 'state:firing', + 'state:inactive', + ]); + expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']); + }); + + it('matches values case-insensitively', () => { + const result = filterRulesByFilters(rules, ['state:FIRING']); + expect(result.map((r) => r.id)).toStrictEqual(['1', '3']); + }); + + it('ignores prefixes with wrong case (state: is required lowercase)', () => { + const result = filterRulesByFilters(rules, ['STATE:FIRING']); + expect(result).toStrictEqual(rules); + }); + + it('returns empty when no rule matches', () => { + expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]); + }); + + it('ignores unknown prefix', () => { + expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules); + }); +}); + +describe('alertActionLogEvent', () => { + it('logs with mapped action label', () => { + const rule = makeRule({ + id: 'rule-1', + alert: 'My Rule', + alertType: 'METRIC_BASED_ALERT' as AlertRule['alertType'], + }); + alertActionLogEvent(ALERT_ACTIONS.EDIT, rule); + expect(logEventMock).toHaveBeenCalledWith('Alert: Action', { + ruleId: 'rule-1', + dataSource: expect.any(String), + name: 'My Rule', + action: 'Edit', + }); + }); + + it('falls back to raw action when unmapped', () => { + alertActionLogEvent('custom', baseRule); + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Action', + expect.objectContaining({ action: 'custom' }), + ); + }); + + it('maps TOGGLE action', () => { + alertActionLogEvent(ALERT_ACTIONS.TOGGLE, baseRule); + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Action', + expect.objectContaining({ action: 'Enable/Disable' }), + ); + }); + + it('maps DELETE and CLONE', () => { + alertActionLogEvent(ALERT_ACTIONS.DELETE, baseRule); + alertActionLogEvent(ALERT_ACTIONS.CLONE, baseRule); + expect(logEventMock).toHaveBeenNthCalledWith( + 1, + 'Alert: Action', + expect.objectContaining({ action: 'Delete' }), + ); + expect(logEventMock).toHaveBeenNthCalledWith( + 2, + 'Alert: Action', + expect.objectContaining({ action: 'Clone' }), + ); + }); +}); diff --git a/frontend/src/container/ListAlertRules/__tests__/_helpers.tsx b/frontend/src/container/ListAlertRules/__tests__/_helpers.tsx new file mode 100644 index 00000000000..905cb788239 --- /dev/null +++ b/frontend/src/container/ListAlertRules/__tests__/_helpers.tsx @@ -0,0 +1,65 @@ +import { QueryClient, QueryClientProvider } from 'react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import { render, RenderResult, screen } from '@testing-library/react'; +import ListAlertRules from 'container/ListAlertRules'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { AppContext } from 'providers/App/App'; +import TimezoneProvider from 'providers/Timezone'; +import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers'; +import { getAppContextMock } from 'tests/test-utils'; + +interface RenderOptions { + role?: string; + initialRoute?: string; +} + +export function renderListAlertRules( + options: RenderOptions = {}, +): RenderResult { + const { role = 'ADMIN', initialRoute = '/' } = options; + + const initialSearch = initialRoute.includes('?') + ? initialRoute.slice(initialRoute.indexOf('?')) + : ''; + resetNuqsState(initialSearch); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { refetchOnWindowFocus: false, retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + + + + + + + + + + , + ); +} + +export async function findAlertRow(alertName: string): Promise { + const cell = await screen.findByText(alertName, {}, { timeout: 5000 }); + const row = cell.closest('tr'); + if (!row) { + throw new Error(`Row not found for alert "${alertName}"`); + } + return row as HTMLElement; +} diff --git a/frontend/src/container/ListAlertRules/components/ColumnSelector.tsx b/frontend/src/container/ListAlertRules/components/ColumnSelector.tsx index 8b615c30b32..2786996bbce 100644 --- a/frontend/src/container/ListAlertRules/components/ColumnSelector.tsx +++ b/frontend/src/container/ListAlertRules/components/ColumnSelector.tsx @@ -47,6 +47,7 @@ function ColumnSelector({ size="sm" color="secondary" prefix={} + data-testid="alert-columns-button" > Columns diff --git a/frontend/src/container/ListAlertRules/index.tsx b/frontend/src/container/ListAlertRules/index.tsx index 7609bd8924f..b880dabd0b3 100644 --- a/frontend/src/container/ListAlertRules/index.tsx +++ b/frontend/src/container/ListAlertRules/index.tsx @@ -136,6 +136,7 @@ function ListAlertRules(): JSX.Element { prefix={} onClick={handleNewAlert} color="primary" + testId="list-alerts-new-alert-button" > New Alert @@ -157,6 +158,7 @@ function ListAlertRules(): JSX.Element { value={searchText} onChange={handleSearchChange} suffix={} + testId="list-alerts-search-input" /> )} diff --git a/frontend/src/container/ListAlertRules/table.config.tsx b/frontend/src/container/ListAlertRules/table.config.tsx index f24547d60b1..c313cf27d83 100644 --- a/frontend/src/container/ListAlertRules/table.config.tsx +++ b/frontend/src/container/ListAlertRules/table.config.tsx @@ -26,14 +26,18 @@ export function getAlertRuleColumns( enableSort: true, enableRemove: false, enableMove: false, - cell: ({ value }): JSX.Element => { + cell: ({ row, value }): JSX.Element => { const state = String(value ?? '').toLowerCase(); const config = STATE_CONFIG[state] ?? { color: 'secondary' as BadgeColor, label: 'Unknown', }; return ( - + {config.label} ); @@ -47,8 +51,11 @@ export function getAlertRuleColumns( enableSort: true, enableRemove: false, enableMove: false, - cell: ({ value }): JSX.Element => ( - + cell: ({ row, value }): JSX.Element => ( + {String(value ?? '-')} ), @@ -60,15 +67,20 @@ export function getAlertRuleColumns( width: { fixed: '120px' }, enableSort: true, enableMove: false, - cell: ({ value }): JSX.Element => { + cell: ({ row, value }): JSX.Element => { const severity = String(value ?? '').toLowerCase(); if (!severity) { - return -; + return ( + + - + + ); } return ( {severity} diff --git a/frontend/src/container/MCPServerSettings/MCPServerSettings.test.tsx b/frontend/src/container/MCPServerSettings/MCPServerSettings.test.tsx index 122d92e8d74..56ac9cee282 100644 --- a/frontend/src/container/MCPServerSettings/MCPServerSettings.test.tsx +++ b/frontend/src/container/MCPServerSettings/MCPServerSettings.test.tsx @@ -1,8 +1,8 @@ +import { logEventMock } from '__tests__/logEventMock'; import { render, screen, userEvent } from 'tests/test-utils'; import MCPServerSettings from './MCPServerSettings'; -const mockLogEvent = jest.fn(); const mockCopyToClipboard = jest.fn(); const mockHistoryPush = jest.fn(); const mockUseGetGlobalConfig = jest.fn(); @@ -11,11 +11,6 @@ const mockUseGetTenantLicense = jest.fn(); const mockToastSuccess = jest.fn(); const mockToastWarning = jest.fn(); -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: (...args: unknown[]): unknown => mockLogEvent(...args), -})); - jest.mock('api/generated/services/global', () => ({ useGetGlobalConfig: (...args: unknown[]): unknown => mockUseGetGlobalConfig(...args), @@ -148,7 +143,7 @@ describe('MCPServerSettings', () => { render(, undefined, { role: 'ADMIN' }); - expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', { + expect(logEventMock).toHaveBeenCalledWith('MCP Settings: Page viewed', { role: 'ADMIN', }); }); diff --git a/frontend/src/container/MySettings/__tests__/MySettings.test.tsx b/frontend/src/container/MySettings/__tests__/MySettings.test.tsx index bedf6dbcc2a..0fd0c24f1a4 100644 --- a/frontend/src/container/MySettings/__tests__/MySettings.test.tsx +++ b/frontend/src/container/MySettings/__tests__/MySettings.test.tsx @@ -1,5 +1,6 @@ import userEvent from '@testing-library/user-event'; import MySettingsContainer from 'container/MySettings'; +import { logEventMock } from '__tests__/logEventMock'; import { act, fireEvent, @@ -12,7 +13,6 @@ import APIError from 'types/api/error'; import { toast } from '@signozhq/ui/sonner'; const toggleThemeFunction = jest.fn(); -const logEventFunction = jest.fn(); const copyToClipboardFn = jest.fn(); const editUserFn = jest.fn(); const updateMyPasswordFn = jest.fn(); @@ -62,11 +62,6 @@ jest.mock('hooks/useDarkMode', () => ({ })), })); -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn((eventName, data) => logEventFunction(eventName, data)), -})); - const errorNotification = jest.fn(); const successNotification = jest.fn(); jest.mock('hooks/useNotifications', () => ({ @@ -135,7 +130,7 @@ describe('MySettings Flows', () => { await waitFor(() => { expect(toggleThemeFunction).toHaveBeenCalled(); - expect(logEventFunction).toHaveBeenCalledWith( + expect(logEventMock).toHaveBeenCalledWith( 'Account Settings: Theme Changed', { theme: 'light', diff --git a/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/__tests__/InviteTeamMembers.test.tsx b/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/__tests__/InviteTeamMembers.test.tsx index eeb8fb238ce..faba3b9986b 100644 --- a/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/__tests__/InviteTeamMembers.test.tsx +++ b/frontend/src/container/OnboardingQuestionaire/InviteTeamMembers/__tests__/InviteTeamMembers.test.tsx @@ -9,11 +9,6 @@ import { import InviteTeamMembers from '../InviteTeamMembers'; -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(), -})); - const mockNotificationSuccess = jest.fn() as jest.MockedFunction< (args: { message: string }) => void >; diff --git a/frontend/src/container/OnboardingQuestionaire/__tests__/OnboardingQuestionaire.test.tsx b/frontend/src/container/OnboardingQuestionaire/__tests__/OnboardingQuestionaire.test.tsx index a58648f94bf..8eb54370353 100644 --- a/frontend/src/container/OnboardingQuestionaire/__tests__/OnboardingQuestionaire.test.tsx +++ b/frontend/src/container/OnboardingQuestionaire/__tests__/OnboardingQuestionaire.test.tsx @@ -4,11 +4,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils'; import OnboardingQuestionaire from '../index'; // Mock dependencies -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(), -})); - jest.mock('lib/history', () => ({ __esModule: true, default: { diff --git a/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx b/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx index e92e7982701..f56e58ff670 100644 --- a/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx +++ b/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx @@ -4,15 +4,13 @@ import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import logEvent from 'api/common/logEvent'; +import { logEventMock } from '__tests__/logEventMock'; import i18n from 'ReactI18'; import store from 'store'; import CreatePipelineButton from '../Layouts/Pipeline/CreatePipelineButton'; import { pipelineApiResponseMockData } from '../mocks/pipeline'; -jest.mock('api/common/logEvent'); - describe('PipelinePage container test', () => { it('should render CreatePipelineButton section', async () => { const { asFragment } = render( @@ -53,9 +51,12 @@ describe('PipelinePage container test', () => { expect(editButton).toBeInTheDocument(); await userEvent.click(editButton); - expect(logEvent).toHaveBeenCalledWith('Logs: Pipelines: Entered Edit Mode', { - source: 'signoz-ui', - }); + expect(logEventMock).toHaveBeenCalledWith( + 'Logs: Pipelines: Entered Edit Mode', + { + source: 'signoz-ui', + }, + ); }); it('CreatePipelineButton - add new mode & tracking', async () => { @@ -78,7 +79,7 @@ describe('PipelinePage container test', () => { expect(editButton).toBeInTheDocument(); await userEvent.click(editButton); - expect(logEvent).toHaveBeenCalledWith( + expect(logEventMock).toHaveBeenCalledWith( 'Logs: Pipelines: Clicked Add New Pipeline', { source: 'signoz-ui', diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.empty.test.tsx b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.empty.test.tsx new file mode 100644 index 00000000000..9077b231569 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.empty.test.tsx @@ -0,0 +1,118 @@ +import userEvent from '@testing-library/user-event'; +import { safeNavigateMock } from '__tests__/safeNavigateMock'; +import ROUTES from 'constants/routes'; +import { triggeredAlertsFixture } from 'mocks-server/__mockdata__/triggered_alerts'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { screen, waitFor } from 'tests/test-utils'; + +import { renderTriggeredAlerts } from './_helpers'; + +describe('TriggeredAlerts — empty / error states', () => { + it('shows the "No alerts firing" empty state when the API returns []', async () => { + server.use( + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: [], status: 'success' })), + ), + ); + + renderTriggeredAlerts(); + + await screen.findByText('No alerts firing'); + expect( + screen.getByTestId('triggered-alerts-empty-create-button'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('triggered-alerts-empty-refresh-button'), + ).toBeInTheDocument(); + }); + + it('navigates to ROUTES.ALERTS_NEW when "Create Alert Rule" is clicked', async () => { + const user = userEvent.setup({ delay: null }); + server.use( + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => + res(ctx.status(200), ctx.json({ data: [], status: 'success' })), + ), + ); + + renderTriggeredAlerts(); + + await screen.findByText('No alerts firing'); + + await user.click(screen.getByTestId('triggered-alerts-empty-create-button')); + expect(safeNavigateMock).toHaveBeenCalledWith( + ROUTES.ALERTS_NEW, + expect.objectContaining({ newTab: false }), + ); + }); + + it('shows ErrorEmptyState when the API returns 500', async () => { + server.use( + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => + res(ctx.status(500)), + ), + ); + + renderTriggeredAlerts(); + + await screen.findByTestId('error-empty-state'); + expect(screen.getByTestId('error-refresh-button')).toBeInTheDocument(); + }); + + it('refetches on refresh button click after an initial error', async () => { + let callCount = 0; + server.use( + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => { + callCount += 1; + if (callCount === 1) { + return res(ctx.status(500)); + } + return res( + ctx.status(200), + ctx.json({ data: triggeredAlertsFixture, status: 'success' }), + ); + }), + ); + + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await screen.findByTestId('error-refresh-button'); + + await user.click(screen.getByTestId('error-refresh-button')); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + }); + + it('shows NoResultsEmptyState when filters yield zero matches', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + const input = screen.getByTestId('triggered-alerts-search-input'); + await user.type(input, 'this-matches-nothing-xyz'); + + await screen.findByTestId('no-results-empty-state'); + expect(screen.getByTestId('no-results-title')).toHaveTextContent( + 'No matching alerts', + ); + expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent( + 'No alerts match your current filters. Try adjusting your search criteria.', + ); + + await user.click(screen.getByTestId('no-results-clear-button')); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + expect( + (screen.getByTestId('triggered-alerts-search-input') as HTMLInputElement) + .value, + ).toBe(''); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.filter.test.tsx b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.filter.test.tsx new file mode 100644 index 00000000000..5ddace97d88 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.filter.test.tsx @@ -0,0 +1,87 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from 'tests/test-utils'; + +import { renderTriggeredAlerts } from './_helpers'; + +describe('TriggeredAlerts — severity filter', () => { + it('filters to only critical-severity rows when "Critical" is selected', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + await user.click(screen.getByTestId('triggered-alerts-filter-combobox')); + + const criticalOption = await screen.findByText( + 'Critical (severity:critical)', + ); + await user.click(criticalOption); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(); + expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(); + expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument(); + expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument(); + }); + }); + + it('shows union when multiple severities are selected', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + await user.click(screen.getByTestId('triggered-alerts-filter-combobox')); + const critical = await screen.findByText('Critical (severity:critical)'); + await user.click(critical); + + const warning = await screen.findByText('Warning (severity:warning)'); + await user.click(warning); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(); + expect(screen.getByText('Memory Warning')).toBeInTheDocument(); + expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument(); + expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument(); + }); + }); + + it('clearing the filter shows all rows again', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + await user.click(screen.getByTestId('triggered-alerts-filter-combobox')); + const critical = await screen.findByText('Critical (severity:critical)'); + await user.click(critical); + await user.keyboard('{Escape}'); + + await waitFor(() => + expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(), + ); + + // Reopen the filter combobox and deselect Critical (clicking again toggles). + await user.click(screen.getByTestId('triggered-alerts-filter-combobox')); + const criticalAgain = await screen.findByText('Critical (severity:critical)'); + await user.click(criticalAgain); + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(); + expect(screen.getByText('Memory Warning')).toBeInTheDocument(); + expect(screen.getByText('Disk Slow')).toBeInTheDocument(); + expect(screen.getByText('Network Hiccup')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.grouping.test.tsx b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.grouping.test.tsx new file mode 100644 index 00000000000..8a66a6a56f3 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.grouping.test.tsx @@ -0,0 +1,85 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from 'tests/test-utils'; + +import { renderTriggeredAlerts } from './_helpers'; + +describe('TriggeredAlerts — group by', () => { + it('renders a flat table when no group-by is selected', async () => { + renderTriggeredAlerts(); + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + // No "Group" column header in flat mode. + expect(screen.queryByText('Group')).not.toBeInTheDocument(); + }); + + it('groups by service when "service" is selected', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + await user.click(screen.getByTestId('triggered-alerts-groupby-combobox')); + + const serviceOption = await screen.findByText('service'); + await user.click(serviceOption); + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.getByText('Group')).toBeInTheDocument()); + + await waitFor(() => { + expect(screen.getByText('service:frontend')).toBeInTheDocument(); + expect(screen.getByText('service:backend')).toBeInTheDocument(); + expect(screen.getByText('service:misc')).toBeInTheDocument(); + }); + }); + + it('expands and collapses a group row to reveal nested alerts', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + await user.click(screen.getByTestId('triggered-alerts-groupby-combobox')); + const serviceOption = await screen.findByText('service'); + await user.click(serviceOption); + await user.keyboard('{Escape}'); + + await waitFor(() => + expect(screen.getByText('service:frontend')).toBeInTheDocument(), + ); + + // Nested rows aren't shown yet. + expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(); + + // The "frontend" group sits first in the table; its expand toggle is the + // first `group-expand-toggle` in DOM order. Targeting by testid is safe + // against design changes that add other buttons to the row. + const expandToggles = screen.getAllByTestId('group-expand-toggle'); + expect(expandToggles.length).toBeGreaterThan(0); + const frontendGroupBadge = screen.getByText('service:frontend'); + const frontendRow = frontendGroupBadge.closest('tr'); + expect(frontendRow).not.toBeNull(); + const frontendToggle = (frontendRow as HTMLElement).querySelector( + '[data-testid="group-expand-toggle"]', + ) as HTMLElement | null; + expect(frontendToggle).not.toBeNull(); + + await user.click(frontendToggle as HTMLElement); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + expect(screen.getByText('Disk Slow')).toBeInTheDocument(); + + await user.click(frontendToggle as HTMLElement); + + await waitFor(() => + expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.list.test.tsx b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.list.test.tsx new file mode 100644 index 00000000000..aebed65afb5 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.list.test.tsx @@ -0,0 +1,115 @@ +import { screen, waitFor } from 'tests/test-utils'; + +import { renderTriggeredAlerts } from './_helpers'; + +describe('TriggeredAlerts — list rendering', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2023-10-20T12:00:00Z')); + }); + + it('renders alerts from the API', async () => { + renderTriggeredAlerts(); + + await expect( + screen.findByTestId('alert-row-fp-critical-1-name'), + ).resolves.toHaveTextContent('High CPU Usage'); + expect(screen.getByTestId('alert-row-fp-warning-1-name')).toHaveTextContent( + 'Memory Warning', + ); + expect(screen.getByTestId('alert-row-fp-info-1-name')).toHaveTextContent( + 'Disk Slow', + ); + }); + + it('renders severity badges for alerts that have severity', async () => { + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId('alert-row-fp-critical-1-name'), + ).toBeInTheDocument(), + ); + + expect( + screen.getByTestId('alert-row-fp-critical-1-severity'), + ).toHaveTextContent('critical'); + expect( + screen.getByTestId('alert-row-fp-warning-1-severity'), + ).toHaveTextContent('warning'); + expect(screen.getByTestId('alert-row-fp-info-1-severity')).toHaveTextContent( + 'info', + ); + expect( + screen.getByTestId('alert-row-fp-noseverity-severity'), + ).toHaveTextContent('-'); + }); + + it('renders status tags reflecting the alert state', async () => { + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId('alert-row-fp-critical-1-name'), + ).toBeInTheDocument(), + ); + + expect( + screen.getByTestId('alert-row-fp-critical-1-status'), + ).toHaveTextContent('Firing'); + expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveTextContent( + 'Unprocessed', + ); + expect( + screen.getByTestId('alert-row-fp-suppressed-1-status'), + ).toHaveTextContent('Suppressed'); + }); + + it('renders status badges with semantic colors', async () => { + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId('alert-row-fp-critical-1-status'), + ).toBeInTheDocument(), + ); + + expect(screen.getByTestId('alert-row-fp-critical-1-status')).toHaveAttribute( + 'data-color', + 'cherry', + ); + expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveAttribute( + 'data-color', + 'forest', + ); + expect( + screen.getByTestId('alert-row-fp-critical-1-severity'), + ).toHaveAttribute('data-color', 'cherry'); + expect(screen.getByTestId('alert-row-fp-warning-1-severity')).toHaveAttribute( + 'data-color', + 'amber', + ); + }); + + it('renders the search input and filter comboboxes', async () => { + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId('alert-row-fp-critical-1-name'), + ).toBeInTheDocument(), + ); + + expect( + screen.getByTestId('triggered-alerts-search-input'), + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Search alerts by name'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('triggered-alerts-filter-combobox'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('triggered-alerts-groupby-combobox'), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.pagination.test.tsx b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.pagination.test.tsx new file mode 100644 index 00000000000..b2a45f3b6b1 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.pagination.test.tsx @@ -0,0 +1,76 @@ +import userEvent from '@testing-library/user-event'; +import { triggeredAlertsPaginationFixture } from 'mocks-server/__mockdata__/triggered_alerts'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { screen, waitFor } from 'tests/test-utils'; +import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers'; + +import { renderTriggeredAlerts } from './_helpers'; + +function usePaginationHandler(): void { + server.use( + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + data: triggeredAlertsPaginationFixture, + status: 'success', + }), + ), + ), + ); +} + +describe('TriggeredAlerts — pagination', () => { + // Default sort is duration ascending = newest startsAt first. Fixture indices + // 0..14 use startsAt 2023-10-01..15, so index 14 (newest) appears first and + // index 0 (oldest) appears last. Page 1 (limit 10) = items 14..5. Page 2 = 4..0. + + it('shows the first 10 rows on page 1 by default', async () => { + usePaginationHandler(); + renderTriggeredAlerts(); + + await screen.findByText('Pag Alert 14'); + + expect(screen.getByText('Pag Alert 5')).toBeInTheDocument(); + expect(screen.queryByText('Pag Alert 4')).not.toBeInTheDocument(); + expect(screen.queryByText('Pag Alert 0')).not.toBeInTheDocument(); + }); + + it('renders a "page 2" pagination button reflecting total=15 with pageSize=10', async () => { + usePaginationHandler(); + renderTriggeredAlerts(); + + await screen.findByText('Pag Alert 14'); + + const nav = screen.getByRole('navigation'); + const page2 = Array.from(nav.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === '2', + ); + expect(page2).toBeDefined(); + }); + + it('navigates to page 2 and shows the next batch of alerts', async () => { + usePaginationHandler(); + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await screen.findByText('Pag Alert 14'); + + const nav = screen.getByRole('navigation'); + const page2Button = Array.from(nav.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === '2', + ); + if (!page2Button) { + throw new Error('Page 2 button not found'); + } + await user.click(page2Button); + + await waitFor(() => + expect(screen.getByText('Pag Alert 0')).toBeInTheDocument(), + ); + expect(screen.getByText('Pag Alert 4')).toBeInTheDocument(); + expect(screen.queryByText('Pag Alert 14')).not.toBeInTheDocument(); + await waitFor(() => expect(getCurrentNuqsQueryString()).toContain('page=2')); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.rowClick.test.tsx b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.rowClick.test.tsx new file mode 100644 index 00000000000..6781c79a794 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.rowClick.test.tsx @@ -0,0 +1,143 @@ +import userEvent from '@testing-library/user-event'; +import { logEventMock } from '__tests__/logEventMock'; +import { safeNavigateMock } from '__tests__/safeNavigateMock'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { screen, waitFor } from 'tests/test-utils'; + +import { getTriggeredAlertRowTestId, renderTriggeredAlerts } from './_helpers'; + +describe('TriggeredAlerts — row click', () => { + it('navigates to the alert overview with the rule id from labels on row click', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')), + ).toBeInTheDocument(), + ); + + await user.click( + screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')), + ); + + expect(safeNavigateMock).toHaveBeenCalledWith( + '/alerts/overview?ruleId=rule-1', + ); + expect(logEventMock).toHaveBeenCalledWith( + 'Alert: Triggered alert clicked', + expect.objectContaining({ + ruleId: 'rule-1', + alertName: 'High CPU Usage', + }), + ); + }); + + it('opens in a new tab when ctrl+clicked', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')), + ).toBeInTheDocument(), + ); + + await user.keyboard('{Control>}'); + await user.click( + screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')), + ); + await user.keyboard('{/Control}'); + + expect(safeNavigateMock).toHaveBeenCalledWith( + '/alerts/overview?ruleId=rule-2', + { newTab: true }, + ); + }); + + it('navigates correctly when ruleId is parsed from generatorURL', async () => { + // Override fixture so the alert has no labels.ruleId but a valid + // generatorURL → getRuleId() falls back to parsing the URL. + server.use( + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + data: [ + { + fingerprint: 'fp-no-rule-label', + startsAt: '2023-10-19T10:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + updatedAt: '2023-10-20T00:00:00Z', + generatorURL: 'http://localhost/alerts/edit?ruleId=rule-from-url', + labels: { alertname: 'URL Rule Alert' }, + annotations: {}, + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], + }, + ], + status: 'success', + }), + ), + ), + ); + + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')), + ).toBeInTheDocument(), + ); + + await user.click( + screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')), + ); + + expect(safeNavigateMock).toHaveBeenCalledWith( + '/alerts/overview?ruleId=rule-from-url', + ); + }); + + it('does not navigate when the row has no ruleId anywhere', async () => { + server.use( + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + data: [ + { + fingerprint: 'fp-no-rule', + startsAt: '2023-10-19T10:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + updatedAt: '2023-10-20T00:00:00Z', + labels: { alertname: 'No Rule Alert' }, + annotations: {}, + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], + }, + ], + status: 'success', + }), + ), + ), + ); + + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect( + screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')), + ).toBeInTheDocument(), + ); + + await user.click( + screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')), + ); + + expect(safeNavigateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.search.test.tsx b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.search.test.tsx new file mode 100644 index 00000000000..760fc33913c --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.search.test.tsx @@ -0,0 +1,118 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from 'tests/test-utils'; + +import { renderTriggeredAlerts } from './_helpers'; + +describe('TriggeredAlerts — search', () => { + it('filters rows by alertname when typing in the search input', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + expect(screen.getByText('Memory Warning')).toBeInTheDocument(); + + const input = screen.getByTestId('triggered-alerts-search-input'); + await user.type(input, 'CPU'); + + await waitFor(() => { + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(); + expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(); + }); + }); + + it('shows all rows again when search is cleared', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + const input = screen.getByTestId('triggered-alerts-search-input'); + await user.type(input, 'CPU'); + + await waitFor(() => + expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(), + ); + + await user.clear(input); + + await waitFor(() => { + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(); + expect(screen.getByText('Memory Warning')).toBeInTheDocument(); + expect(screen.getByText('Disk Slow')).toBeInTheDocument(); + }); + }); + + it('matches rows by label value (case-insensitive)', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + const input = screen.getByTestId('triggered-alerts-search-input'); + // "backend" matches `service: backend` labels on Memory Warning and Network Hiccup. + await user.type(input, 'backend'); + + await waitFor(() => { + expect(screen.getByText('Memory Warning')).toBeInTheDocument(); + expect(screen.getByText('Network Hiccup')).toBeInTheDocument(); + expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(); + expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument(); + }); + }); + + it('matches rows by label key', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + const input = screen.getByTestId('triggered-alerts-search-input'); + await user.type(input, 'staging'); + + await waitFor(() => { + expect(screen.getByText('Disk Slow')).toBeInTheDocument(); + expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(); + expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(); + }); + }); + + it('renders the no-results empty state when the search matches nothing', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts(); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + + const input = screen.getByTestId('triggered-alerts-search-input'); + await user.type(input, 'zzzzznever-matches-anything'); + + await waitFor(() => + expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument(), + ); + }); + + it('resets pagination to page 1 when the search changes', async () => { + const user = userEvent.setup({ delay: null }); + renderTriggeredAlerts({ initialRoute: '/?page=2&limit=2' }); + + await waitFor(() => + expect(screen.getByText('Disk Slow')).toBeInTheDocument(), + ); + + const input = screen.getByTestId('triggered-alerts-search-input'); + await user.type(input, 'CPU'); + + await waitFor(() => + expect(screen.getByText('High CPU Usage')).toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.utils.test.ts b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.utils.test.ts new file mode 100644 index 00000000000..5d9509cb515 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/TriggeredAlerts.utils.test.ts @@ -0,0 +1,181 @@ +import type { SortState } from 'components/TanStackTableView/types'; + +import type { Alert } from '../types'; +import { + getAlertSortValue, + getRuleId, + normalizeAlerts, + sortAlerts, +} from '../utils'; + +const alertWithFingerprint: Alert = { + fingerprint: 'fp-existing', + labels: { alertname: 'Test', severity: 'critical' }, + annotations: {}, + startsAt: '2023-10-19T10:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], +}; + +const alertWithoutFingerprint: Alert = { + ...alertWithFingerprint, + fingerprint: undefined, +}; + +describe('normalizeAlerts', () => { + it('returns empty array when given undefined', () => { + expect(normalizeAlerts(undefined)).toStrictEqual([]); + }); + + it('preserves existing fingerprints', () => { + const result = normalizeAlerts([alertWithFingerprint]); + expect(result).toHaveLength(1); + expect(result[0].fingerprint).toBe('fp-existing'); + }); + + it('mints fingerprint when missing', () => { + const result = normalizeAlerts([alertWithoutFingerprint]); + expect(result[0].fingerprint).toBeDefined(); + expect(typeof result[0].fingerprint).toBe('string'); + expect(result[0].fingerprint?.length).toBeGreaterThan(0); + }); + + it('does not mutate the input array', () => { + const input = [alertWithFingerprint]; + const copy = JSON.parse(JSON.stringify(input)); + normalizeAlerts(input); + expect(input).toStrictEqual(copy); + }); +}); + +describe('getAlertSortValue', () => { + const alert: Alert = { + fingerprint: 'fp', + labels: { alertname: 'CPU', severity: 'critical' }, + annotations: {}, + startsAt: '2023-10-19T10:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], + }; + + it('returns status.state for "status"', () => { + expect(getAlertSortValue(alert, 'status')).toBe('active'); + }); + + it('returns alertname for "alertName"', () => { + expect(getAlertSortValue(alert, 'alertName')).toBe('CPU'); + }); + + it('returns severity for "severity"', () => { + expect(getAlertSortValue(alert, 'severity')).toBe('critical'); + }); + + it('returns elapsed ms for "firingSince"', () => { + const result = getAlertSortValue(alert, 'firingSince'); + expect(typeof result).toBe('number'); + expect(result).toBeGreaterThan(0); + }); + + it('returns elapsed ms for "duration"', () => { + const result = getAlertSortValue(alert, 'duration'); + expect(typeof result).toBe('number'); + }); + + it('returns empty string for unknown columns', () => { + expect(getAlertSortValue(alert, 'unknown')).toBe(''); + }); + + it('returns empty string for missing fields', () => { + const empty = { ...alert, status: undefined, labels: undefined }; + expect(getAlertSortValue(empty, 'status')).toBe(''); + expect(getAlertSortValue(empty, 'alertName')).toBe(''); + expect(getAlertSortValue(empty, 'severity')).toBe(''); + }); + + it('returns empty for firingSince with no startsAt', () => { + const empty = { ...alert, startsAt: undefined }; + expect(getAlertSortValue(empty, 'firingSince')).toBe(''); + }); +}); + +describe('sortAlerts', () => { + const a: Alert = { + fingerprint: 'a', + labels: { alertname: 'A' }, + annotations: {}, + startsAt: '2023-10-19T10:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], + }; + const b: Alert = { ...a, fingerprint: 'b', labels: { alertname: 'B' } }; + const c: Alert = { ...a, fingerprint: 'c', labels: { alertname: 'C' } }; + + it('sorts ascending when given orderBy', () => { + const order: SortState = { columnName: 'alertName', order: 'asc' }; + const result = sortAlerts([c, a, b], order); + expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['A', 'B', 'C']); + }); + + it('sorts descending', () => { + const order: SortState = { columnName: 'alertName', order: 'desc' }; + const result = sortAlerts([a, b, c], order); + expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['C', 'B', 'A']); + }); + + it('falls back to default duration asc when orderBy is null', () => { + const result = sortAlerts([a, b, c], null); + expect(result).toHaveLength(3); + }); +}); + +describe('getRuleId', () => { + const base: Alert = { + labels: {}, + annotations: {}, + startsAt: '2023-10-19T10:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], + fingerprint: 'fp', + }; + + it('returns labels.ruleId when present', () => { + expect(getRuleId({ ...base, labels: { ruleId: 'rule-1' } })).toBe('rule-1'); + }); + + it('falls back to generatorURL when ruleId label missing', () => { + expect( + getRuleId({ + ...base, + generatorURL: 'http://localhost/foo?ruleId=rule-42', + }), + ).toBe('rule-42'); + }); + + it('prefers labels.ruleId over generatorURL', () => { + expect( + getRuleId({ + ...base, + labels: { ruleId: 'from-label' }, + generatorURL: 'http://localhost/foo?ruleId=from-url', + }), + ).toBe('from-label'); + }); + + it('returns null when generatorURL has no ruleId param', () => { + expect( + getRuleId({ ...base, generatorURL: 'http://localhost/foo' }), + ).toBeNull(); + }); + + it('returns null when generatorURL is invalid', () => { + expect(getRuleId({ ...base, generatorURL: 'not-a-url' })).toBeNull(); + }); + + it('returns null when no source available', () => { + expect(getRuleId(base)).toBeNull(); + }); +}); diff --git a/frontend/src/container/TriggeredAlerts/__tests__/_helpers.tsx b/frontend/src/container/TriggeredAlerts/__tests__/_helpers.tsx new file mode 100644 index 00000000000..2077504b0a8 --- /dev/null +++ b/frontend/src/container/TriggeredAlerts/__tests__/_helpers.tsx @@ -0,0 +1,71 @@ +import { QueryClient, QueryClientProvider } from 'react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import { render, RenderResult, screen } from '@testing-library/react'; +import TriggeredAlerts from 'container/TriggeredAlerts'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { AppContext } from 'providers/App/App'; +import TimezoneProvider from 'providers/Timezone'; +import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers'; +import { getAppContextMock } from 'tests/test-utils'; + +interface RenderOptions { + initialRoute?: string; +} + +export function renderTriggeredAlerts( + options: RenderOptions = {}, +): RenderResult { + const { initialRoute = '/' } = options; + + const initialSearch = initialRoute.includes('?') + ? initialRoute.slice(initialRoute.indexOf('?')) + : ''; + resetNuqsState(initialSearch); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { refetchOnWindowFocus: false, retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + + + + + + + + + + , + ); +} + +export async function findAlertRow(alertName: string): Promise { + const cell = await screen.findByText(alertName, {}, { timeout: 5000 }); + const row = cell.closest('tr'); + if (!row) { + throw new Error(`Row not found for alert "${alertName}"`); + } + return row as HTMLElement; +} + +export function getTriggeredAlertRowTestId( + fingerprint: string, + column: 'name' | 'severity' | 'status', +): string { + return `alert-row-${fingerprint}-${column}`; +} diff --git a/frontend/src/container/TriggeredAlerts/components/AlertStatusTag.tsx b/frontend/src/container/TriggeredAlerts/components/AlertStatusTag.tsx index e129875db71..81d07df8eef 100644 --- a/frontend/src/container/TriggeredAlerts/components/AlertStatusTag.tsx +++ b/frontend/src/container/TriggeredAlerts/components/AlertStatusTag.tsx @@ -2,31 +2,32 @@ import { Badge } from '@signozhq/ui/badge'; interface AlertStatusTagProps { state: string; + testId?: string; } -function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element { +function AlertStatusTag({ state, testId }: AlertStatusTagProps): JSX.Element { switch (state) { case 'unprocessed': return ( - + Unprocessed ); case 'active': return ( - + Firing ); case 'suppressed': return ( - + Suppressed ); default: return ( - + Unknown ); diff --git a/frontend/src/container/TriggeredAlerts/components/EmptyStates.tsx b/frontend/src/container/TriggeredAlerts/components/EmptyStates.tsx index 368ada0258f..4d5759ded78 100644 --- a/frontend/src/container/TriggeredAlerts/components/EmptyStates.tsx +++ b/frontend/src/container/TriggeredAlerts/components/EmptyStates.tsx @@ -3,6 +3,7 @@ import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons'; import { Button } from '@signozhq/ui/button'; import ROUTES from 'constants/routes'; import { useSafeNavigate } from 'hooks/useSafeNavigate'; +import { isModifierKeyPressed } from 'utils/app'; import styles from './EmptyStates.module.scss'; @@ -13,9 +14,12 @@ interface EmptyStateProps { export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element { const { safeNavigate } = useSafeNavigate(); - const handleCreateAlert = useCallback((): void => { - safeNavigate(ROUTES.ALERTS_NEW); - }, [safeNavigate]); + const handleCreateAlert = useCallback( + (e: React.MouseEvent): void => { + safeNavigate(ROUTES.ALERTS_NEW, { newTab: isModifierKeyPressed(e) }); + }, + [safeNavigate], + ); return (
@@ -30,6 +34,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element { color="primary" prefix={} onClick={handleCreateAlert} + testId="triggered-alerts-empty-create-button" > Create Alert Rule @@ -39,6 +44,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element { color="secondary" prefix={} onClick={onRefresh} + testId="triggered-alerts-empty-refresh-button" > Refresh diff --git a/frontend/src/container/TriggeredAlerts/components/GroupTagsCell.tsx b/frontend/src/container/TriggeredAlerts/components/GroupTagsCell.tsx index a88a999ebb9..89b42eaa1ca 100644 --- a/frontend/src/container/TriggeredAlerts/components/GroupTagsCell.tsx +++ b/frontend/src/container/TriggeredAlerts/components/GroupTagsCell.tsx @@ -44,6 +44,7 @@ export function GroupTagsCell({ prefix={ localIsExpanded ? : } + testId="group-expand-toggle" />
{tags.map((tag) => ( diff --git a/frontend/src/container/TriggeredAlerts/index.tsx b/frontend/src/container/TriggeredAlerts/index.tsx index ebfdd4bba46..7a1b15d0abf 100644 --- a/frontend/src/container/TriggeredAlerts/index.tsx +++ b/frontend/src/container/TriggeredAlerts/index.tsx @@ -175,6 +175,7 @@ function TriggeredAlerts(): JSX.Element { value={searchText} onChange={handleSearchChange} suffix={} + testId="triggered-alerts-search-input" />
diff --git a/frontend/src/container/TriggeredAlerts/table.config.tsx b/frontend/src/container/TriggeredAlerts/table.config.tsx index 6c89ee305c2..c7da462966a 100644 --- a/frontend/src/container/TriggeredAlerts/table.config.tsx +++ b/frontend/src/container/TriggeredAlerts/table.config.tsx @@ -21,8 +21,11 @@ export function getAlertColumns( width: { fixed: '100px' }, enableSort: false, enableMove: false, - cell: ({ value }): JSX.Element => ( - + cell: ({ row, value }): JSX.Element => ( + ), }, { @@ -32,8 +35,11 @@ export function getAlertColumns( width: { default: '100%' }, enableSort: true, enableMove: false, - cell: ({ value }): JSX.Element => ( - + cell: ({ row, value }): JSX.Element => ( + {String(value ?? '-')} ), @@ -45,15 +51,17 @@ export function getAlertColumns( width: { fixed: '120px' }, enableSort: true, enableMove: false, - cell: ({ value }): JSX.Element => { + cell: ({ row, value }): JSX.Element => { const severity = String(value ?? '').toLowerCase(); + const testId = `alert-row-${row.fingerprint ?? ''}-severity`; if (!severity) { - return -; + return -; } return ( {severity} diff --git a/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx b/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx index 4ccba51d631..0e9beea6a4f 100644 --- a/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx +++ b/frontend/src/hooks/queryBuilder/__tests__/useCreateAlerts.test.tsx @@ -17,11 +17,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(), -})); - jest.mock('api/dashboard/substitute_vars', () => ({ getSubstituteVars: jest.fn(), })); diff --git a/frontend/src/mocks-server/__mockdata__/alert_rules.ts b/frontend/src/mocks-server/__mockdata__/alert_rules.ts new file mode 100644 index 00000000000..d5a8b8a3d8c --- /dev/null +++ b/frontend/src/mocks-server/__mockdata__/alert_rules.ts @@ -0,0 +1,85 @@ +import { + RuletypesAlertStateDTO, + RuletypesAlertTypeDTO, + RuletypesPanelTypeDTO, + RuletypesQueryTypeDTO, + RuletypesRuleTypeDTO, +} from 'api/generated/services/sigNoz.schemas'; +import type { + RuletypesRuleConditionDTO, + RuletypesRuleDTO, +} from 'api/generated/services/sigNoz.schemas'; + +const baseCondition: RuletypesRuleConditionDTO = { + compositeQuery: { + queryType: RuletypesQueryTypeDTO.builder, + panelType: RuletypesPanelTypeDTO.graph, + queries: null, + }, +} as unknown as RuletypesRuleConditionDTO; + +const make = ( + id: string, + overrides: Partial, +): RuletypesRuleDTO => ({ + id, + alert: `Alert ${id}`, + alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT, + condition: baseCondition, + createdAt: '2023-10-15T10:00:00Z', + updatedAt: '2023-10-19T10:00:00Z', + createdBy: 'alice@signoz.io', + updatedBy: 'alice@signoz.io', + disabled: false, + state: RuletypesAlertStateDTO.inactive, + labels: { severity: 'info' }, + annotations: {}, + source: '', + evalWindow: '5m0s', + frequency: '1m0s', + ruleType: RuletypesRuleTypeDTO.threshold_rule, + ...overrides, +}); + +export const alertRulesFixture: RuletypesRuleDTO[] = [ + make('rule-1', { + alert: 'High CPU Alert', + state: RuletypesAlertStateDTO.firing, + labels: { severity: 'critical', team: 'infra' }, + }), + make('rule-2', { + alert: 'Memory Pending Alert', + state: RuletypesAlertStateDTO.pending, + labels: { severity: 'warning', team: 'backend' }, + }), + make('rule-3', { + alert: 'Healthy Alert', + state: RuletypesAlertStateDTO.inactive, + labels: { severity: 'info', team: 'infra' }, + }), + make('rule-4', { + alert: 'Disabled Alert', + state: RuletypesAlertStateDTO.disabled, + disabled: true, + labels: { severity: 'critical', team: 'frontend' }, + }), + make('rule-5', { + alert: 'No Labels Alert', + state: RuletypesAlertStateDTO.inactive, + labels: {}, + }), +]; + +export const alertRulesPaginationFixture: RuletypesRuleDTO[] = Array.from( + { length: 15 }, + (_, i) => + make(`rule-pag-${i}`, { + alert: `Pag Rule ${i}`, + state: + i % 2 === 0 + ? RuletypesAlertStateDTO.firing + : RuletypesAlertStateDTO.inactive, + labels: { severity: i % 2 === 0 ? 'critical' : 'warning' }, + createdAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + }), +); diff --git a/frontend/src/mocks-server/__mockdata__/triggered_alerts.ts b/frontend/src/mocks-server/__mockdata__/triggered_alerts.ts new file mode 100644 index 00000000000..e062142a080 --- /dev/null +++ b/frontend/src/mocks-server/__mockdata__/triggered_alerts.ts @@ -0,0 +1,102 @@ +import type { AlertmanagertypesDeprecatedGettableAlertDTO } from 'api/generated/services/sigNoz.schemas'; + +export const triggeredAlertsFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] = + [ + { + fingerprint: 'fp-critical-1', + startsAt: '2023-10-19T10:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + generatorURL: 'http://localhost/alerts/edit?ruleId=rule-1&panelTypes=graph', + labels: { + alertname: 'High CPU Usage', + severity: 'critical', + ruleId: 'rule-1', + service: 'frontend', + env: 'prod', + }, + annotations: { + summary: 'CPU above 90%', + description: 'Frontend CPU usage critical', + }, + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: ['slack'], + }, + { + fingerprint: 'fp-warning-1', + startsAt: '2023-10-19T09:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + generatorURL: 'http://localhost/alerts/edit?ruleId=rule-2', + labels: { + alertname: 'Memory Warning', + severity: 'warning', + ruleId: 'rule-2', + service: 'backend', + env: 'prod', + }, + annotations: { summary: 'Memory high' }, + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: ['slack'], + }, + { + fingerprint: 'fp-info-1', + startsAt: '2023-10-19T08:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + generatorURL: 'http://localhost/alerts/edit?ruleId=rule-3', + labels: { + alertname: 'Disk Slow', + severity: 'info', + ruleId: 'rule-3', + service: 'frontend', + env: 'staging', + }, + annotations: { summary: 'Disk slow' }, + status: { state: 'unprocessed', silencedBy: [], inhibitedBy: [] }, + receivers: ['email'], + }, + { + fingerprint: 'fp-suppressed-1', + startsAt: '2023-10-19T07:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + generatorURL: 'http://localhost/alerts/edit?ruleId=rule-4', + labels: { + alertname: 'Network Hiccup', + severity: 'error', + ruleId: 'rule-4', + service: 'backend', + env: 'dev', + }, + annotations: { summary: 'Network errors' }, + status: { state: 'suppressed', silencedBy: ['s-1'], inhibitedBy: [] }, + receivers: ['pagerduty'], + }, + { + fingerprint: 'fp-noseverity', + startsAt: '2023-10-19T06:00:00Z', + endsAt: '0001-01-01T00:00:00Z', + labels: { + alertname: 'Unknown Alert', + service: 'misc', + }, + annotations: {}, + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], + }, + ]; + +// Bigger fixture for pagination tests (15 entries → 2 pages at limit=10). +export const triggeredAlertsPaginationFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] = + Array.from({ length: 15 }, (_, i) => ({ + fingerprint: `fp-pag-${i}`, + startsAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`, + endsAt: '0001-01-01T00:00:00Z', + generatorURL: `http://localhost/alerts/edit?ruleId=rule-pag-${i}`, + labels: { + alertname: `Pag Alert ${i}`, + severity: i % 2 === 0 ? 'critical' : 'warning', + ruleId: `rule-pag-${i}`, + service: 'frontend', + }, + annotations: {}, + status: { state: 'active', silencedBy: [], inhibitedBy: [] }, + receivers: [], + })); diff --git a/frontend/src/mocks-server/handlers.ts b/frontend/src/mocks-server/handlers.ts index c1a5080f318..803f4634515 100644 --- a/frontend/src/mocks-server/handlers.ts +++ b/frontend/src/mocks-server/handlers.ts @@ -3,6 +3,8 @@ import { rest } from 'msw'; import commonEnTranslation from '../../public/locales/en/common.json'; import enTranslation from '../../public/locales/en/translation.json'; import { allAlertChannels } from './__mockdata__/alerts'; +import { alertRulesFixture } from './__mockdata__/alert_rules'; +import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts'; import { billingSuccessResponse } from './__mockdata__/billing'; import { dashboardSuccessResponse, @@ -236,6 +238,38 @@ export const handlers = [ rest.get('http://localhost/api/v1/channels', (_, res, ctx) => res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })), ), + rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ data: triggeredAlertsFixture, status: 'success' }), + ), + ), + rest.get('http://localhost/api/v2/rules', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ data: alertRulesFixture, status: 'success' }), + ), + ), + rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => { + const body = (await req.json()) as { alert?: string }; + return res( + ctx.status(201), + ctx.json({ + data: { + ...alertRulesFixture[0], + id: 'new-rule-id', + alert: body?.alert ?? 'New Rule', + }, + status: 'success', + }), + ); + }), + rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success' })), + ), + rest.delete('http://localhost/api/v2/rules/:id', (_, res, ctx) => + res(ctx.status(200), ctx.json({ status: 'success' })), + ), rest.delete('http://localhost/api/v1/channels/:id', (_, res, ctx) => res( ctx.status(200), diff --git a/frontend/src/pages/Settings/__tests__/Settings.test.tsx b/frontend/src/pages/Settings/__tests__/Settings.test.tsx index 11867e9437c..b52ce307cd1 100644 --- a/frontend/src/pages/Settings/__tests__/Settings.test.tsx +++ b/frontend/src/pages/Settings/__tests__/Settings.test.tsx @@ -10,11 +10,6 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({ children, })); -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(), -})); - jest.mock('lib/history', () => ({ push: jest.fn(), listen: jest.fn(() => jest.fn()), diff --git a/frontend/src/pages/SignUp/__tests__/SignUp.test.tsx b/frontend/src/pages/SignUp/__tests__/SignUp.test.tsx index 379169c1540..b980a07b65d 100644 --- a/frontend/src/pages/SignUp/__tests__/SignUp.test.tsx +++ b/frontend/src/pages/SignUp/__tests__/SignUp.test.tsx @@ -14,11 +14,6 @@ jest.mock('AppRoutes/utils', () => ({ const mockAfterLogin = jest.mocked(afterLogin); -jest.mock('api/common/logEvent', () => ({ - __esModule: true, - default: jest.fn(), -})); - jest.mock('lib/history', () => ({ __esModule: true, default: { diff --git a/frontend/src/tests/nuqs-helpers.ts b/frontend/src/tests/nuqs-helpers.ts new file mode 100644 index 00000000000..2a5b902d1e2 --- /dev/null +++ b/frontend/src/tests/nuqs-helpers.ts @@ -0,0 +1,41 @@ +// Helpers for tests that drive components built with `nuqs`. +// +// We replace `NuqsAdapter` (which throttles URL writes to `window.history`) +// with `NuqsTestingAdapter`. The testing adapter applies URL updates +// synchronously (`rateLimitFactor: 0`) and stores state in memory +// (`hasMemory: true`) so subsequent reads see the latest value without any +// `flushNuqsUrl` sleep. Each test gets a fresh queue because +// `resetUrlUpdateQueueOnMount` defaults to `true`. +// +// Reads on `window.location.search` are no longer authoritative since the +// adapter does not push to the browser history. Use +// `getCurrentNuqsQueryString()` (or assert on `lastNuqsUrlUpdate`) instead. + +import type { OnUrlUpdateFunction } from 'nuqs/adapters/testing'; + +let lastUrlUpdate: { searchParams: URLSearchParams; queryString: string } = { + searchParams: new URLSearchParams(), + queryString: '', +}; + +export function resetNuqsState(initialQuery = ''): void { + lastUrlUpdate = { + searchParams: new URLSearchParams(initialQuery), + queryString: initialQuery, + }; +} + +export const onNuqsUrlUpdate: OnUrlUpdateFunction = (event) => { + lastUrlUpdate = { + searchParams: event.searchParams, + queryString: event.queryString, + }; +}; + +export function getCurrentNuqsQueryString(): string { + return lastUrlUpdate.queryString; +} + +export function getCurrentNuqsSearchParams(): URLSearchParams { + return lastUrlUpdate.searchParams; +}