Compare commits

...

3 Commits

Author SHA1 Message Date
Abhi kumar
379331ee77 feat(dashboards-v2): panel drilldown — View in Logs/Traces (#11895)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboards-v2): add the panel View-modal state and hooks

URL-driven open state (useViewPanel off expandedWidgetId, V1 parity), a per-view
time window isolated from the dashboard (useViewPanelTimeWindow), and
useViewPanelEditor which layers the drilldown reset + type-selector signal/query
type on top of the shared usePanelEditSession.

* refactor(dashboards-v2): address View-mode PR review feedback

Naming/convention:
- UsePanelEditSessionApi -> UsePanelEditSessionReturn; fullKind -> panelKind
- useViewPanelEditor -> useViewPanelMode (hook, types, file, refs, test)
- useOpenPanelEditor `state` param -> `handoffState` (view -> edit handoff)

Types tightened:
- useViewPanelMode: panelDefinition is required (registry always resolves)
- signal is required: fold the default-signal fallback into the hook so the
  query builder + type selector always get a signal; drop the now-dead
  defaultSignal from the return and the `signal ?? defaultSignal` call site.
  Behavior-preserving: only List restricts signals and it's already gated by
  query type under PromQL/ClickHouse.

Misc:
- PanelEditorPage: drop redundant optional chaining on getPanelDefinition
- ViewPanelModal: Typography.Text title with ellipsis truncation
- ViewPanelModalHeader: clearer queryType JSDoc

* feat(dashboards-v2): add panel drilldown click-enrichment utils

* feat(dashboards-v2): declare drilldown as a per-kind panel capability

* feat(dashboards-v2): open a drilldown context menu with View in Logs/Traces

* feat(dashboards-v2): substitute dashboard variables in drilldown View-in-X

Clicking View in Logs/Traces from a panel drilldown now resolves the
panel query's $var/dashboard-variable references before building the
explorer URL (V1 parity with useBaseAggregateOptions). New
useResolvedDrilldownQuery hook fires a /substitute_vars round-trip
(useReplaceVariables + buildQueryRangeRequest) when the aggregate menu
opens and the dashboard has selections, mirroring the V2-native path in
useCreateAlertFromPanel. While resolving, both actions show a spinner
and are disabled; no-variable dashboards skip the round-trip.

* refactor(dashboards-v2): address drilldown review comments

- stepClickTimeRange takes a single stepInterval (defaulted in props);
  callers do the per-query lookup from exec-stats stepIntervals
- move DrilldownAggregateMenu static inline styles into a CSS module,
  keeping only the dynamic series color inline

* feat(dashboards-v2): drill down filter-by-value into the View modal (#11959)

* feat(dashboards-v2): drill down filter-by-value into the View modal

A table group-cell click opens the filter-by-value menu (Is this / Is
not this); picking an operator adds the filter to the panel query and
opens the refined result in the View modal, persisted in the URL (V1
parity). Reuses V1's read-only getGroupContextMenuConfig; no V1 files
modified.

* refactor(dashboards-v2): type the filter-by-value ClickedData without a double-cast

Address review: build a genuinely-typed ClickedData for the group-cell
filter menu instead of an `as unknown as ClickedData` escape hatch.
getGroupContextMenuConfig reads only column.dataIndex, so column carries
just that field and record gets a minimal valid RowData.

* refactor(dashboards-v2): render the filter-by-value menu from a component

Reshape useDrilldownFilter to return data (isGroupColumnClick + onFilter)
instead of JSX, and move the operator-menu rendering into a new
DrilldownFilterMenu component wired in useDrilldown. Keeps hooks to
logic/state and rendering in components (PR review preference).

* refactor(dashboards-v2): strongly-type the drilldown popover coordinates

Replace the drilldown's use of V1's loosely-typed useCoordinates (clickedData:
any) with a V2-local useDrilldownCoordinates hook generic over the click
payload, so DrilldownContext flows through without a cast. Extract the pure
viewport-clamp math into calculatePopoverPosition with unit tests.

* feat(dashboards-v2): panel drilldown — Breakout by .. (#11896)

* feat(dashboards-v2): gate the panel editor through the capability guard

Route the panel editor's query builder and visualization type switcher through
the capability guard instead of V1's PANEL_TYPE_TO_QUERY_TYPES (now no longer
imported by any V2 file):

- PanelEditorQueryBuilder is keyed on PanelKind; its query-type tabs and
  query-builder field visibility come from the guard.
- Switching the panel kind coerces the active query type via the guard.
- The visualization type switcher disables a kind when the active query type or
  datasource is incompatible with it (e.g. List under ClickHouse/PromQL, or List
  with a metrics query). The live query type is read from the query-builder
  provider so a not-yet-staged new panel still gates correctly, and a tooltip
  explains why a type is disabled. ConfigSelect gains opt-in per-option tooltips.

* feat(dashboards-v2): create alerts from a dashboard panel

Wire the panel actions menu's "Create Alerts" item to seed a new alert
from the panel's query. `buildCreateAlertUrl` translates the panel's V5
queries into the V1 compositeQuery the alert page reads (tagged with the
panel type, v5 version and a dashboards source), and
`useCreateAlertFromPanel` opens /alerts/new in a new tab and logs the
action. Available regardless of edit access (V1 parity: create-alert
works on locked dashboards too).

To reach the query, the full panel is threaded through
Panel -> PanelHeader -> PanelActionsMenu -> usePanelActionItems instead
of just its kind; the header now derives its name/description from the
panel as well.

* refactor(dashboards-v2): ready ChartManager for the standalone view

Prep the shared graph-manager so it renders cleanly inside the View
modal: swap the legacy useNotifications save toast for the sonner toast,
and let ChartLayout grow to its content height (auto) so the manager's
legend table isn't clipped under the chart.

* feat(dashboards-v2): add the panel View modal

The View modal UI — a compact, single-host drilldown editor matching V1's
FullView: a draft preview over the per-view window, a toolbar (Reset
Query, Switch to Edit Mode, panel-type selector, time picker, refresh),
and the query-builder-only drilldown editor (no ClickHouse/PromQL tabs,
per V1). Edits are temporary — they never touch the saved dashboard.

* feat(dashboards-v2): open a drilldown context menu with View in Logs/Traces

* feat(dashboards-v2): add the Breakout by .. drilldown submenu

* chore: pr review fixes

* refactor(dashboards-v2): render the Breakout by .. menu from a component

Reshape useDrilldownBreakout to return data (queryData + onBreakout) instead
of JSX, and move the picker rendering into a new DrilldownBreakoutMenu
component (with a CSS module) wired in useDrilldown. Keeps hooks to
logic/state and rendering in components (PR review preference).

* feat(dashboards-v2): panel drilldown — context links (#11897)

* feat(dashboards-v2): resolve panel context links in the drilldown menu

* feat(dashboards-v2): panel drilldown — dashboard variables + trace link (#11964)

* feat(dashboards-v2): complete drilldown V1 parity — dashboard variables + trace link

Add the last two V1 drilldown features to V2 panels:

- Dashboard Variables submenu: from a grouped click, the aggregate menu now
  offers, per group-by field, Set/Unset a matching dynamic variable to the
  clicked value, or Create a new dynamic variable from it. Set/Unset are
  runtime-only (store + URL, matching V2 selections); Create is the one
  persisted path (appends to spec.variables via the optimistic patch).
  New Panel/hooks/useDrilldownDashboardVariables.tsx mirrors V1's
  useDashboardVarConfig; wired into useDrilldown as a new submenu.

- "View Trace Details" data link: the aggregate menu shows a /trace/{id}
  link when the clicked point carries a trace_id (V1 getDataLinks parity).
  New Panels/utils/drilldown/getDataLinks.ts.

Exports variablesUrlParser from useVariableSelection so the submenu reuses the
runtime-selection URL contract without re-running its seeding effect.

* fix(dashboards-v2): restrict drilldown filters to group-by dimensions

The pure-V5 flattening backfills an ungrouped series' labels with
{queryName: queryName} for its legend; the chart/pie enrichers turned every
label into an equality filter, leaking a bogus `A=A` into the drilldown
context. That broke the "filters are the clicked point's group-by labels,
empty when ungrouped" contract — surfacing a spurious Dashboard Variables
entry and a corrupt View-in-Logs query that V1 never shows.

Add getGroupByFilters to intersect the clicked labels with the query's
groupBy, and use it in enrichChartClick/enrichPieClick.

* refactor(dashboards-v2): dashboard-variables drilldown review fixes

Address PR review on the Dashboard Variables submenu:
- render it from a DrilldownDashboardVariablesMenu component; the hook now
  returns data (hasFieldVariables + actions) instead of JSX, taking a required
  filters array + signal (rendering belongs in the component, not the hook)
- identify dynamic variables by plugin kind on the DTO, not by mapping every
  variable to a form model first
- drop the redundant useGetDashboardV2 enabled guard (the generated hook
  already gates on !!id)
- reference the DrilldownSubMenu enum instead of a raw string
- move inline styles to a CSS module

* refactor(dashboards-v2): read drilldown variables via useDashboardFetchRequired

Use the shared useDashboardFetchRequired in useDrilldownDashboardVariables
instead of calling useGetDashboardV2 directly, so it resolves through the one
dashboard cache entry and drops the `?? []` guards. Filter dynamic variables on
the form model's `type` (via dtoToFormModel) rather than casting the raw spec.

That hook throws on a missing dashboard id, and the id was seeded into the store
in a useEffect — undefined on the first Panel render. Seed it synchronously
during render in DashboardContainer instead, and make setEditContext idempotent
so the render-phase call is a safe no-op once seeded.

* chore: fix failing lint
2026-07-06 13:18:39 +00:00
Ashwin Bhatkal
4d079a8338 refactor(dashboard-v2): unify dashboard tag chips into one TagBadge component (#11984)
* fix(dashboard-v2): render tag chips with the sienna Badge component

The key:value tag editor rendered chips as a hand-rolled div + ghost buttons with
sienna colors mixed in by hand. Use the @signozhq Badge (color=sienna, closable)
so the chips match the design system and the rest of the dashboard tag styling.

* fix(dashboard-v2): keep key:key tags intact in the settings tag editor

tagsToStrings collapsed a tag whose key equals its value down to a bare key, so
the settings tag editor showed `env` for an `env:env` tag. Always render both
sides so key:key tags round-trip through the editor unchanged.

* refactor(dashboard-v2): single TagBadge component for all dashboard tags

The list rows, details header, and tag editors each rendered tag chips their own
way (two hand-rolled sienna variants + an inline Badge), so they drifted. Extract
one TagBadge (sienna Badge, optional closable) and use it everywhere, so tag
styling changes in one place. List-row chips were not even sienna before.

* fix(dashboard-v2): tag label inherits the Badge sienna color

The chip label button used --button-variant-ghost-color: inherit, but for a CSS
custom property inherit takes the parent's value of that same variable (unset),
not the parent color — so the label fell back to the muted ghost default. Use
currentColor so it picks up the Badge's sienna foreground.
2026-07-06 13:05:23 +00:00
Vinicius Lourenço
91938925ec feat(infra-monitoring): rework the category selection (#11957) 2026-07-06 12:04:07 +00:00
72 changed files with 3548 additions and 448 deletions

View File

@@ -0,0 +1,34 @@
import { type MouseEvent, type ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
interface TagBadgeProps {
children: ReactNode;
// Show a remove button (editable contexts: create modal, settings drawer).
closable?: boolean;
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
className?: string;
}
// The single sienna tag chip used everywhere dashboards render tags — list rows,
// the details header, and the tag editors. Kept as one component so the tag
// styling stays identical across all of them.
function TagBadge({
children,
closable,
onClose,
className,
}: TagBadgeProps): JSX.Element {
return (
<Badge
color="sienna"
variant="outline"
className={className}
closable={closable}
onClose={onClose}
>
{children}
</Badge>
);
}
export default TagBadge;

View File

@@ -15,59 +15,28 @@
border: 1px solid var(--l2-border);
}
// Sienna chip — matches the dashboard list-row tag badge.
// Sienna chip via the @signozhq Badge; this only constrains its width.
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
height: 24px;
padding: 2px 4px 2px 8px;
border-radius: 50px;
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);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: 20px;
cursor: text;
}
// The tag label is a bare, chrome-less button inside the Badge (double-click to
// edit); strip its Button styling and let it ellipsize.
.tagLabel {
--button-height: auto;
--button-padding: 0;
--button-gap: 0;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: inherit;
--button-variant-ghost-hover-color: inherit;
--button-variant-ghost-color: currentColor;
--button-variant-ghost-hover-color: currentColor;
overflow: hidden;
max-width: 200px;
font-size: 13px;
font-weight: var(--font-weight-normal);
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.remove {
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
// default ghost hover is a grey that clashes with the chip. Resting color is
// left at the Button default.
--button-height: 16px;
--button-padding: 0;
--button-border-radius: 50%;
--button-variant-ghost-hover-background-color: color-mix(
in srgb,
var(--bg-sienna-500) 22%,
transparent
);
--button-variant-ghost-hover-color: var(--bg-sienna-400);
width: 16px;
min-width: 16px;
flex: none;
}
.input {
flex: 1;
min-width: 120px;

View File

@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import cx from 'classnames';
import TagBadge from '../TagBadge/TagBadge';
import { parseKeyValueTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
@@ -120,27 +120,23 @@ function TagKeyValueInput({
onBlur={commitEdit}
/>
) : (
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
<TagBadge
key={tag}
className={styles.tag}
closable
onClose={(): void => removeTag(tag)}
>
<Button
variant="ghost"
color="secondary"
className={styles.tagLabel}
title="Double-click to edit"
testId={`${testId}-chip`}
onDoubleClick={(): void => startEdit(index)}
>
{tag}
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.remove}
aria-label={`Remove ${tag}`}
onClick={(): void => removeTag(tag)}
>
<X size={12} />
</Button>
</div>
</TagBadge>
),
)}
<Input

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
@@ -27,7 +28,7 @@ interface PieArcProps {
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
onLeave: () => void;
onClick?: (slice: PieSlice) => void;
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
}
/**
@@ -72,7 +73,7 @@ export default function PieArc({
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
onMouseLeave={onLeave}
onClick={(): void => onClick?.(slice)}
onClick={(event): void => onClick?.(slice, event)}
>
<path d={arcPath} fill={fill} />
{shouldShowLabel && (

View File

@@ -80,6 +80,7 @@ describe('PieArc', () => {
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
expect(onClick).toHaveBeenCalledWith(SLICE);
// onClick now also receives the DOM event (for drill-down popover positioning).
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
});
});

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
@@ -79,6 +80,10 @@ export interface PieSlice {
label: string;
value: number;
color: string;
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
queryName?: string;
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
labels?: Record<string, string>;
}
/**
@@ -99,7 +104,7 @@ export interface PieChartProps {
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice (or its legend entry) is clicked. */
onSliceClick?: (slice: PieSlice) => void;
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
'data-testid'?: string;
}

View File

@@ -2,7 +2,7 @@
position: relative;
display: flex;
width: 100%;
height: 100%;
height: auto;
flex-direction: column;
// Stacked children (the FullView / standalone graph-manager) sit below the chart

View File

@@ -66,20 +66,6 @@
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
@@ -111,22 +97,6 @@
justify-content: space-between;
}
.quickFiltersCategoryLabel {
display: flex;
align-items: center;
gap: 4px;
}
.quickFiltersCategoryLabelIcon {
margin-right: 8px;
}
.quickFiltersCategoryLabelContainer {
display: flex;
align-items: center;
gap: 4px;
}
.listContainer {
flex: 1;
min-width: 0;
@@ -160,3 +130,106 @@
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
}
.categorySelectorSection {
padding: var(--spacing-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
&[data-type='filter'] {
padding: 0 var(--spacing-4);
}
}
.sectionLabel {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l3-foreground);
white-space: nowrap;
}
.sectionLine {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.categoryCard {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-3);
padding: var(--spacing-2);
}
.categoryList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.categoryItem {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: var(--spacing-2);
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
--typography-color: var(--l1-foreground);
&:hover:not(.categoryItemSelected) {
background: var(--l2-background-hover);
}
svg {
color: var(--l3-foreground);
flex-shrink: 0;
transition: color 0.2s ease;
}
&.categoryItemSelected {
background: var(--accent-primary);
color: var(--l1-background);
font-weight: 600;
&:hover {
background: var(--accent-primary-hover, var(--accent-primary));
}
svg {
color: var(--l1-foreground);
}
}
}
.quickFiltersSection {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, CollapseProps } from 'antd';
import { Collapse, Tooltip } from 'antd';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
});
};
const renderCategoryLabel = (
icon: JSX.Element,
label: string,
): JSX.Element => (
<div className={styles.quickFiltersCategoryLabel}>
<div className={styles.quickFiltersCategoryLabelContainer}>
{icon}
<Typography.Text>{label}</Typography.Text>
</div>
</div>
const categories = useMemo(
() => [
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
},
],
[dotMetricsEnabled],
);
const items: CollapseProps['items'] = [
{
label: renderCategoryLabel(
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Pods',
),
key: K8sCategories.PODS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Nodes',
),
key: K8sCategories.NODES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Namespaces',
),
key: K8sCategories.NAMESPACES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Clusters',
),
key: K8sCategories.CLUSTERS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Deployments',
),
key: K8sCategories.DEPLOYMENTS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Jobs',
),
key: K8sCategories.JOBS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'DaemonSets',
),
key: K8sCategories.DAEMONSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'StatefulSets',
),
key: K8sCategories.STATEFULSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Volumes',
),
key: K8sCategories.VOLUMES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
// TODO: Enable once we have implemented containers.
// {
// label: (
// <div className="k8s-quick-filters-category-label">
// <div className="k8s-quick-filters-category-label-container">
// <PackageOpen
// size={14}
// className="k8s-quick-filters-category-label-icon"
// />
// <Typography.Text>Containers</Typography.Text>
// </div>
// </div>
// ),
// key: K8sCategories.CONTAINERS,
// showArrow: false,
// children: (
// <QuickFilters
// source={QuickFiltersSource.INFRA_MONITORING}
// config={ContainersQuickFiltersConfig}
// handleFilterVisibilityChange={handleFilterVisibilityChange}
// onFilterChange={handleFilterChange}
// />
// ),
// },
];
const selectedCategoryConfig = useMemo(
() => categories.find((cat) => cat.key === selectedCategory)?.config,
[categories, selectedCategory],
);
const handleCategoryChange = (key: string | string[]): void => {
if (Array.isArray(key) && key.length > 0) {
setSelectedCategory(key[0] as string);
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
setSelectedCategory(key);
// Reset filters
setUrlFilters(null);
setOrderBy(null);
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
)}
</div>
<Collapse
onChange={handleCategoryChange}
items={items}
defaultActiveKey={[selectedCategory]}
activeKey={[selectedCategory]}
accordion
bordered={false}
ghost
/>
</div>
)}

View File

@@ -7,7 +7,7 @@ import {
SolidInfoCircle,
X,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import TagBadge from 'components/TagBadge/TagBadge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
@@ -203,19 +203,13 @@ function DashboardInfo({
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="sienna" variant="outline">
{tag}
</Badge>
<TagBadge key={tag}>{tag}</TagBadge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="sienna"
variant="outline"
data-testid="dashboard-tags-overflow"
>
+{remainingTags.length}
</Badge>
<span data-testid="dashboard-tags-overflow">
<TagBadge>+{remainingTags.length}</TagBadge>
</span>
</TooltipSimple>
)}
</div>

View File

@@ -3,10 +3,10 @@ import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schem
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
// The tag editor is strictly key:value, so always render both sides — a
// `key:key` tag stays `key:key` rather than collapsing to a bare `key`.
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
return tags.map((t) => `${t.key}:${t.value}`);
}
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {

View File

@@ -14,8 +14,6 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
@@ -25,7 +23,10 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { buildBarChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -40,6 +41,7 @@ function BarPanelRenderer({
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -56,12 +58,10 @@ function BarPanelRenderer({
[panel.spec.queries],
);
// X-scale clamps come from the request that produced the data. The generated
// request DTO is structurally the V5 request; the cast is the boundary.
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
@@ -155,10 +155,29 @@ function BarPanelRenderer({
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
},
[onClick],
[onClick, flatSeries, builderQueries, data.response],
);
return (
@@ -188,7 +207,7 @@ function BarPanelRenderer({
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
onClick={enableDrillDown ? handleChartClick : undefined}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -20,7 +20,6 @@ import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './utils/buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
@@ -28,7 +27,6 @@ function HistogramPanelRenderer({
data,
refetch,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -100,13 +98,6 @@ function HistogramPanelRenderer({
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
@@ -127,7 +118,6 @@ function HistogramPanelRenderer({
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: false,
},
};

View File

@@ -37,5 +37,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
download: false,
createAlert: false,
search: true,
drilldown: false,
},
};

View File

@@ -1,4 +1,9 @@
import { useMemo } from 'react';
import {
useCallback,
useMemo,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from 'react';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
@@ -8,6 +13,9 @@ import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichNumberClick } from '../../utils/drilldown/enrichNumberClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
@@ -17,24 +25,31 @@ function NumberPanelRenderer({
panel,
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const value = useMemo(
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const tables = useMemo(
() =>
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
),
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
[data.response, data.legendMap, data.requestPayload],
);
const value = useMemo(() => prepareNumberData(tables), [tables]);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
@@ -54,10 +69,60 @@ function NumberPanelRenderer({
[value, unit, decimalPrecision],
);
const openDrilldown = useCallback(
(coordinates: { x: number; y: number }): void => {
if (!onClick) {
return;
}
const payload = enrichNumberClick({
tables,
builderQueries,
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, tables, data.requestPayload, builderQueries],
);
const handleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>): void =>
openDrilldown({ x: event.clientX, y: event.clientY }),
[openDrilldown],
);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
openDrilldown({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}
},
[openDrilldown],
);
// The whole panel is the value, so the container itself is the drill-down target.
const isClickable = enableDrillDown && !!onClick && value !== null;
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
{...(isClickable
? {
role: 'button',
tabIndex: 0,
onClick: handleClick,
onKeyDown: handleKeyDown,
style: { cursor: 'pointer' },
}
: {})}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" onRetry={refetch} />

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -1,4 +1,8 @@
import { useCallback, useMemo } from 'react';
import {
useCallback,
useMemo,
type MouseEvent as ReactMouseEvent,
} from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
@@ -13,6 +17,9 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { enrichPieClick } from '../../utils/drilldown/enrichPieClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { preparePieData } from './prepareData';
@@ -22,6 +29,7 @@ function PiePanelRenderer({
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -30,6 +38,11 @@ function PiePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const slices = useMemo(
() =>
preparePieData({
@@ -61,10 +74,21 @@ function PiePanelRenderer({
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
(slice: PieSlice, event: ReactMouseEvent): void => {
if (!onClick) {
return;
}
const payload = enrichPieClick({
slice,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick],
[onClick, builderQueries, data.requestPayload],
);
return (
@@ -79,7 +103,7 @@ function PiePanelRenderer({
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
data-testid="pie-chart"
/>
)}

View File

@@ -23,5 +23,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
download: false,
createAlert: false,
search: false,
drilldown: true,
},
};

View File

@@ -1,4 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { Table } from 'antd';
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -8,6 +15,9 @@ import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/query
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichTableClick } from '../../utils/drilldown/enrichTableClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { useResizableColumns } from '../../hooks/useResizableColumns';
import NoData from '../../components/NoData/NoData';
@@ -27,6 +37,8 @@ function TablePanelRenderer({
data,
refetch,
searchTerm = '',
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
const containerRef = useRef<HTMLDivElement>(null);
@@ -42,6 +54,11 @@ function TablePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
// V5 joins every query into a single scalar result, so the first non-empty
// table is the whole panel.
const table = useMemo(
@@ -64,6 +81,34 @@ function TablePanelRenderer({
[spec.thresholds],
);
const handleCellClick = useCallback(
({
columnId,
record,
event,
}: {
columnId: string;
record: TableRowData;
event: ReactMouseEvent<HTMLElement>;
}): void => {
if (!onClick || !table) {
return;
}
const payload = enrichTableClick({
record,
columnId,
table,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, table, builderQueries, data.requestPayload],
);
const columns = useMemo(
() =>
table
@@ -72,9 +117,17 @@ function TablePanelRenderer({
columnUnits: spec.formatting?.columnUnits ?? {},
decimalPrecision,
thresholdsByColumn,
onCellClick: enableDrillDown ? handleCellClick : undefined,
})
: [],
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
[
table,
spec.formatting?.columnUnits,
decimalPrecision,
thresholdsByColumn,
enableDrillDown,
handleCellClick,
],
);
// User-resizable columns, persisted per panel to localStorage.

View File

@@ -25,5 +25,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
createAlert: false,
// V1 parity: only tables (and lists) expose the header search box.
search: true,
drilldown: true,
},
};

View File

@@ -52,6 +52,12 @@ export interface BuildTableColumnsArgs {
decimalPrecision?: PrecisionOption;
/** Thresholds grouped by column name (see `mapTableThresholds`). */
thresholdsByColumn: Record<string, PanelThreshold[]>;
/** When set, every body cell becomes a drill-down target (keyed by its column id). */
onCellClick?: (args: {
columnId: string;
record: TableRowData;
event: React.MouseEvent<HTMLElement>;
}) => void;
}
/**
@@ -65,6 +71,7 @@ export function buildTableColumns({
columnUnits,
decimalPrecision,
thresholdsByColumn,
onCellClick,
}: BuildTableColumnsArgs): TableProps<TableRowData>['columns'] {
return table.columns.map((col) => {
// Column key = query identifier for value columns, group name otherwise. Units
@@ -97,19 +104,26 @@ export function buildTableColumns({
}
return text;
},
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
if (!col.isValueColumn || colThresholds.length === 0) {
return {};
onCell: (record: TableRowData): React.HTMLAttributes<HTMLElement> => {
const cellProps: React.HTMLAttributes<HTMLElement> = {};
if (col.isValueColumn && colThresholds.length > 0) {
const num = Number(record[key]);
if (Number.isFinite(num)) {
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {
cellProps.style = { backgroundColor: threshold.color };
}
}
}
const num = Number(record[key]);
if (!Number.isFinite(num)) {
return {};
if (onCellClick) {
cellProps.onClick = (event): void =>
onCellClick({ columnId: key, record, event });
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
}
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {
return { style: { backgroundColor: threshold.color } };
}
return {};
return cellProps;
},
};
});

View File

@@ -14,8 +14,6 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
@@ -25,7 +23,10 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { buildTimeSeriesConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -40,6 +41,7 @@ function TimeSeriesPanelRenderer({
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -58,11 +60,9 @@ function TimeSeriesPanelRenderer({
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched — matters during drag-zoom transitions before
// new data arrives. The generated request DTO is structurally the V5 request.
// new data arrives.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
@@ -156,10 +156,29 @@ function TimeSeriesPanelRenderer({
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
},
[onClick],
[onClick, flatSeries, builderQueries, data.response],
);
return (
@@ -188,7 +207,7 @@ function TimeSeriesPanelRenderer({
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
onClick={enableDrillDown ? handleChartClick : undefined}
/>
)}
</div>

View File

@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -0,0 +1,36 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
// Drilldown is the click-to-context-menu feature ported from V1. Every renderer turns its native
// click into one `DrilldownClickPayload`; the kind-agnostic orchestration layer consumes only that.
// `FilterData` is imported read-only from the V1 util so the payload feeds `buildDrilldownUrl`
// directly, with no intermediate translation.
/** The clicked point's drilldown context, derived from the flattened series/columns the renderer holds. */
export interface DrilldownContext {
/** The clicked series'/column's query. Drives query selection in `getViewQuery`. */
queryName: string;
/** Telemetry signal of the clicked query — picks the explorer the drilldown navigates to. */
signal: TelemetrytypesSignalDTO;
/** Key/value/op filters from the clicked point's group-by labels (empty when ungrouped). */
filters: FilterData[];
/** Explorer time window. Charts use the clicked bucket ±step; scalar panels use the fetched window. */
timeRange?: { startTime: number; endTime: number };
/** Series/slice display name, shown as the menu header's second line. */
label?: string;
/** Series/slice colour; tints the menu header label and item icons (charts/pie only). */
seriesColor?: string;
/** Tables only: a value column opens the aggregate menu; a group column opens filter-by-value. */
columnKind?: 'aggregate' | 'group';
/** Group-column click only: the clicked column's key, for the filter-by-value menu. */
clickedKey?: string;
/** Group-column click only: the clicked cell's value, for the filter-by-value menu. */
clickedValue?: string | number;
}
/** What each renderer's `onClick` emits: where to anchor the popover plus the drilldown context. */
export interface DrilldownClickPayload {
/** Absolute viewport coordinates for the popover anchor. */
coordinates: { x: number; y: number };
context: DrilldownContext;
}

View File

@@ -1,24 +1,5 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { PanelKind } from './panelKind';
/** Source-tagged click events; each non-chart kind carries its own drill-down context. */
export type ChartClickEvent = ChartClickData;
export type TableClickEvent = {
rowData: Record<string, unknown>;
columnId?: string;
};
export type ListClickEvent = {
rowData: Record<string, unknown>;
};
export type PieClickEvent = { label: string; value: number };
/** Union of every panel click event — switched on by `source` at the boundary. */
export type PanelClickEvent =
| ChartClickEvent
| TableClickEvent
| ListClickEvent
| PieClickEvent;
import type { DrilldownClickPayload } from './drilldown';
type DragSelect = (start: number, end: number) => void;
@@ -29,23 +10,27 @@ type CloseStandaloneView = () => void;
* Per-kind interaction props — each kind exposes only the gestures it supports.
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
* is a compile error there.
*
* Every interactive kind's `onClick` receives the unified `DrilldownClickPayload`
* its renderer enriches from the native click. Number/Value drills down on its
* single value. Histogram and List are omitted (V1 has no drill-down for either):
* they inherit the empty `object` base, so their renderers get only base props
* with no click gesture.
*/
export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onClick?: (event: DrilldownClickPayload) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onClick?: (event: DrilldownClickPayload) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
'signoz/NumberPanel': Record<string, never>;
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
};
/**
@@ -53,7 +38,7 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
* registry render boundary). The supertype the per-kind shapes are cast to once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onClick?: (event: DrilldownClickPayload) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
}

View File

@@ -30,6 +30,11 @@ export interface PanelActionCapabilities {
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
*/
search: boolean;
/**
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
* Pie/Value/Table; Histogram/List opt out. AND-ed with "has a builder query" in `useDrilldown`.
*/
drilldown: boolean;
}
export interface PanelDefinition<K extends PanelKind = PanelKind> {

View File

@@ -0,0 +1,46 @@
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelTimeRange } from '../getPanelTimeRange';
// Fallback path reads the redux global-time selection; stub both so the no-payload branch
// is deterministic.
jest.mock('store', () => ({
__esModule: true,
default: { getState: (): unknown => ({ globalTime: { selectedTime: '5m' } }) },
}));
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: (): { start: string; end: string } => ({
start: '1700',
end: '1800',
}),
}));
const request = (
start?: number,
end?: number,
): Querybuildertypesv5QueryRangeRequestDTO =>
({ start, end }) as Querybuildertypesv5QueryRangeRequestDTO;
describe('getPanelTimeRange', () => {
it('converts the request start/end from ms to seconds', () => {
expect(getPanelTimeRange(request(5_000, 9_000))).toStrictEqual({
startTime: 5,
endTime: 9,
});
});
it('falls back to the global-time window when there is no request', () => {
expect(getPanelTimeRange(undefined)).toStrictEqual({
startTime: 1700,
endTime: 1800,
});
});
it('falls back when the request is missing an endpoint', () => {
expect(getPanelTimeRange(request(5_000, undefined))).toStrictEqual({
startTime: 1700,
endTime: 1800,
});
});
});

View File

@@ -0,0 +1,483 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type {
PanelSeries,
PanelTable,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownContext } from '../../../types/drilldown';
import { buildAggregateData } from '../buildAggregateData';
import { stepClickTimeRange } from '../chartClickTimeRange';
import { enrichChartClick } from '../enrichChartClick';
import { enrichNumberClick } from '../enrichNumberClick';
import { enrichPieClick } from '../enrichPieClick';
import { enrichTableClick } from '../enrichTableClick';
import { getDataLinks } from '../getDataLinks';
import { resolvePanelContextLinks } from '../resolvePanelContextLinks';
import { resolveDrilldownSignal } from '../signal';
// The v5 BuilderQuery union is too verbose to construct field-typed inline; cast at the boundary.
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
return spec as unknown as BuilderQuery;
}
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
return {
queryName: 'A',
legend: '',
labels: { 'service.name': 'frontend' },
kind: 'series',
values: [],
aggregation: { index: 0, alias: '' },
...overrides,
};
}
function chartClick(
focusedSeries: ChartClickData['focusedSeries'],
): ChartClickData {
return {
xValue: 0,
yValue: 0,
focusedSeries,
clickedDataTimestamp: 1_700_000_000,
mouseX: 10,
mouseY: 20,
absoluteMouseX: 110,
absoluteMouseY: 220,
};
}
function focused(seriesIndex: number): ChartClickData['focusedSeries'] {
return { seriesIndex, seriesName: 'frontend', value: 1, color: '#fff' };
}
describe('resolveDrilldownSignal', () => {
it('maps logs/traces directly', () => {
expect(resolveDrilldownSignal(builderQuery({ signal: 'logs' }))).toBe('logs');
expect(resolveDrilldownSignal(builderQuery({ signal: 'traces' }))).toBe(
'traces',
);
});
it('falls back to metrics for metrics, meter and unknown/missing signals', () => {
expect(resolveDrilldownSignal(builderQuery({ signal: 'metrics' }))).toBe(
'metrics',
);
expect(resolveDrilldownSignal(builderQuery({ signal: 'meter' }))).toBe(
'metrics',
);
expect(resolveDrilldownSignal(undefined)).toBe('metrics');
});
});
describe('enrichChartClick', () => {
const series = [
panelSeries({ queryName: 'A', labels: { 'service.name': 'frontend' } }),
panelSeries({ queryName: 'B', labels: { 'service.name': 'cart' } }),
];
it('maps the uPlot series index to the (index - 1) flattened series', () => {
// uPlot series[0] is the x-axis, so data series start at 1.
const payload = enrichChartClick({
clickData: chartClick(focused(2)),
series,
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
groupBy: [{ name: 'service.name' }],
}),
builderQuery({
name: 'B',
signal: 'logs',
groupBy: [{ name: 'service.name' }],
}),
],
});
expect(payload?.context.queryName).toBe('B');
expect(payload?.context.signal).toBe('logs');
expect(payload?.context.filters).toStrictEqual([
expect.objectContaining({ filterKey: 'service.name', filterValue: 'cart' }),
]);
expect(payload?.context.seriesColor).toBe('#fff');
expect(payload?.coordinates).toStrictEqual({ x: 110, y: 220 });
});
it('passes through the caller-computed time range and resolves the signal', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series,
builderQueries: [builderQuery({ name: 'A', signal: 'traces' })],
timeRange: { startTime: 100, endTime: 200 },
});
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.timeRange).toStrictEqual({
startTime: 100,
endTime: 200,
});
});
it('returns null when there is no focused series', () => {
expect(
enrichChartClick({
clickData: chartClick(null),
series,
builderQueries: [],
}),
).toBeNull();
});
it('returns null when the series index maps to no series', () => {
expect(
enrichChartClick({
clickData: chartClick(focused(99)),
series,
builderQueries: [],
}),
).toBeNull();
});
it('returns null for formula queries (queryName starts with F)', () => {
expect(
enrichChartClick({
clickData: chartClick(focused(1)),
series: [panelSeries({ queryName: 'F1' })],
builderQueries: [],
}),
).toBeNull();
});
it('emits empty filters for an ungrouped series (drops the legend backfill label)', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series: [panelSeries({ queryName: 'A', labels: { A: 'A' } })],
builderQueries: [builderQuery({ name: 'A', signal: 'metrics' })],
});
expect(payload?.context.filters).toStrictEqual([]);
expect(payload?.context.queryName).toBe('A');
});
it('drops labels that are not group-by dimensions', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series: [
panelSeries({
queryName: 'A',
labels: { 'service.name': 'frontend', __name__: 'http_requests' },
}),
],
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
groupBy: [{ name: 'service.name' }],
}),
],
});
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
});
describe('buildAggregateData', () => {
it('projects the drilldown context onto the V1 AggregateData shape', () => {
const context: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.logs,
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
timeRange: { startTime: 1, endTime: 2 },
label: 'frontend',
seriesColor: '#abc',
columnKind: 'aggregate',
};
expect(buildAggregateData(context)).toStrictEqual({
queryName: 'A',
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
timeRange: { startTime: 1, endTime: 2 },
label: 'frontend',
seriesColor: '#abc',
});
});
});
describe('enrichNumberClick', () => {
const numberTable = (queryName: string): PanelTable => ({
queryName,
legend: '',
columns: [{ name: 'value', queryName, isValueColumn: true, id: queryName }],
rows: [{ data: { [queryName]: 42 } }],
});
it('drills down on the displayed value column with empty filters and no label', () => {
const payload = enrichNumberClick({
tables: [numberTable('A')],
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
coordinates: { x: 5, y: 6 },
timeRange: { startTime: 1, endTime: 2 },
});
// No label: the menu header falls back to the aggregation expression (V1 parity).
expect(payload?.context).toStrictEqual({
queryName: 'A',
signal: 'logs',
filters: [],
timeRange: { startTime: 1, endTime: 2 },
});
expect(payload?.coordinates).toStrictEqual({ x: 5, y: 6 });
});
it('drills into the displayed value column, not the first builder query', () => {
// Panel shows query B's value column; drilldown must target B, not A.
const payload = enrichNumberClick({
tables: [numberTable('B')],
builderQueries: [
builderQuery({ name: 'A', signal: 'logs' }),
builderQuery({ name: 'B', signal: 'traces' }),
],
coordinates: { x: 0, y: 0 },
});
expect(payload?.context.queryName).toBe('B');
expect(payload?.context.signal).toBe('traces');
});
it('returns null when there is no drillable query', () => {
expect(
enrichNumberClick({
tables: [],
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
it('returns null for a formula query', () => {
expect(
enrichNumberClick({
tables: [numberTable('F1')],
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
});
describe('enrichTableClick', () => {
const table: PanelTable = {
queryName: 'A',
legend: '',
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
],
rows: [{ data: { 'service.name': 'frontend', A: 42 } }],
};
const record = { 'service.name': 'frontend', A: 42 };
const builderQueries = [builderQuery({ name: 'A', signal: 'traces' })];
it('builds equality filters from the row group cells for a value-column click', () => {
const payload = enrichTableClick({
record,
columnId: 'A',
table,
builderQueries,
coordinates: { x: 1, y: 2 },
timeRange: { startTime: 10, endTime: 20 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.columnKind).toBe('aggregate');
expect(payload?.context.clickedKey).toBeUndefined();
// No label: the aggregate menu header falls back to the aggregation expression,
// not the value column name (V1 parity).
expect(payload?.context.label).toBeUndefined();
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
it('falls back to the row value column and carries the clicked cell for a group click', () => {
const payload = enrichTableClick({
record,
columnId: 'service.name',
table,
builderQueries,
coordinates: { x: 1, y: 2 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.columnKind).toBe('group');
expect(payload?.context.clickedKey).toBe('service.name');
expect(payload?.context.clickedValue).toBe('frontend');
});
it('returns null when the table has no value column', () => {
const groupOnly: PanelTable = {
queryName: 'A',
legend: '',
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
],
rows: [{ data: { 'service.name': 'frontend' } }],
};
expect(
enrichTableClick({
record,
columnId: 'service.name',
table: groupOnly,
builderQueries,
coordinates: { x: 1, y: 2 },
}),
).toBeNull();
});
});
describe('enrichPieClick', () => {
it('builds filters from the slice labels and resolves the signal', () => {
const payload = enrichPieClick({
slice: {
label: 'frontend',
value: 12,
color: '#abc',
queryName: 'A',
labels: { 'service.name': 'frontend' },
},
builderQueries: [
builderQuery({
name: 'A',
signal: 'traces',
groupBy: [{ name: 'service.name' }],
}),
],
coordinates: { x: 7, y: 8 },
timeRange: { startTime: 1, endTime: 2 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
it('returns null for a slice with no source query', () => {
expect(
enrichPieClick({
slice: { label: 'x', value: 1, color: '#000' },
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
});
describe('resolvePanelContextLinks', () => {
it('substitutes the clicked field value (_-prefixed) into the label and URL', () => {
const resolved = resolvePanelContextLinks(
[
{
name: 'Runbook for {{_service.name}}',
url: 'https://wiki/{{_service.name}}',
},
],
{ '_service.name': 'frontend' },
);
expect(resolved).toHaveLength(1);
expect(resolved[0].label).toBe('Runbook for frontend');
expect(resolved[0].url).toBe('https://wiki/frontend');
});
it('drops links without a URL', () => {
expect(resolvePanelContextLinks([{ name: 'No URL' }], {})).toStrictEqual([]);
expect(resolvePanelContextLinks(undefined, {})).toStrictEqual([]);
});
it('keeps the raw URL when renderVariables is false', () => {
const resolved = resolvePanelContextLinks(
[
{
name: 'Literal',
url: 'https://wiki/{{_service.name}}',
renderVariables: false,
},
],
{ '_service.name': 'frontend' },
);
expect(resolved[0].url).toBe('https://wiki/{{_service.name}}');
});
});
describe('stepClickTimeRange', () => {
it('returns [clickedTs, clickedTs + step] for a non-APM query', () => {
expect(
stepClickTimeRange({
clickedDataTimestamp: 1000,
queryName: 'A',
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
stepInterval: 30,
}),
).toStrictEqual({ startTime: 1000, endTime: 1030 });
});
it('falls back to a 60s step when no interval is provided', () => {
expect(
stepClickTimeRange({
clickedDataTimestamp: 1000,
queryName: 'A',
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
aggregations: [{ metricName: 'custom_metric' }],
}),
],
}),
).toStrictEqual({ startTime: 1000, endTime: 1060 });
});
});
describe('getDataLinks', () => {
it('adds a "View Trace Details" link when the filters carry a trace_id', () => {
expect(
getDataLinks([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
{ filterKey: 'trace_id', filterValue: 'abc123', operator: '=' },
]),
).toStrictEqual([
{
id: 'view-trace-details',
label: 'View Trace Details',
url: '/trace/abc123',
},
]);
});
it('returns no links when there is no trace_id', () => {
expect(
getDataLinks([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,18 @@
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type { DrilldownContext } from '../../types/drilldown';
/**
* Adapts a V2 `DrilldownContext` to the V1 `AggregateData` that `buildDrilldownUrl`/the drilldown
* navigate hook consume. The single boundary between the V2 click payload and the reused V1
* navigation machinery.
*/
export function buildAggregateData(context: DrilldownContext): AggregateData {
return {
queryName: context.queryName,
filters: context.filters,
timeRange: context.timeRange,
label: context.label,
seriesColor: context.seriesColor,
};
}

View File

@@ -0,0 +1,38 @@
import {
getTimeRangeFromStepInterval,
isApmMetric,
} from 'container/PanelWrapper/utils';
import type { BuilderQuery, MetricAggregation } from 'types/api/v5/queryRange';
/** Fallback step (seconds) when the response carries no per-query step interval (V1 parity). */
const DEFAULT_STEP_INTERVAL = 60;
interface StepClickTimeRangeArgs {
/** Clicked bucket timestamp, in the chart's x-unit (epoch seconds). */
clickedDataTimestamp: number;
/** The clicked series' query — used to detect APM metrics. */
queryName: string;
builderQueries: BuilderQuery[];
/** Clicked series' step (seconds); falls back to DEFAULT_STEP_INTERVAL. */
stepInterval?: number;
}
/**
* Time window for a time-axis chart click: the clicked bucket plus one step (V1 parity). APM-metric
* panels widen the window one step to the left. Shared by the TimeSeries and Bar renderers; the
* matching field remapping happens later inside `getViewQuery`.
*/
export function stepClickTimeRange({
clickedDataTimestamp,
queryName,
builderQueries,
stepInterval = DEFAULT_STEP_INTERVAL,
}: StepClickTimeRangeArgs): { startTime: number; endTime: number } {
const builderQuery = builderQueries.find((query) => query.name === queryName);
const isApm =
builderQuery?.signal === 'metrics' &&
isApmMetric(
(builderQuery?.aggregations?.[0] as MetricAggregation)?.metricName ?? '',
);
return getTimeRangeFromStepInterval(stepInterval, clickedDataTimestamp, isApm);
}

View File

@@ -0,0 +1,62 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichChartClickArgs {
clickData: ChartClickData;
/** Flattened series in the same order they were added to uPlot (see `prepareAlignedData`/`addSeries`). */
series: PanelSeries[];
/** The panel's builder queries, for resolving the clicked series' signal by `queryName`. */
builderQueries: BuilderQuery[];
/** Explorer time window; the caller computes it (clicked bucket ±step for time charts, panel window for histograms). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a uPlot click (time-series or bar) into a drilldown payload. Resolves the clicked series via
* uPlot's series index (index 0 is the x-axis, so data series start at 1 → `series[seriesIndex - 1]`)
* and builds equality filters from its group-by label values. Returns `null` when the click can't be
* attributed to a drillable series (no focused series, unmapped index, or a formula query).
*/
export function enrichChartClick({
clickData,
series,
builderQueries,
timeRange,
}: EnrichChartClickArgs): DrilldownClickPayload | null {
const { focusedSeries } = clickData;
if (!focusedSeries) {
return null;
}
const panelSeries = series[focusedSeries.seriesIndex - 1];
if (!panelSeries || !isValidQueryName(panelSeries.queryName)) {
return null;
}
const builderQuery = builderQueries.find(
(query) => query.name === panelSeries.queryName,
);
const filters = builderQuery
? getGroupByFilters(panelSeries.labels, builderQuery)
: [];
return {
coordinates: { x: clickData.absoluteMouseX, y: clickData.absoluteMouseY },
context: {
queryName: panelSeries.queryName,
signal: resolveDrilldownSignal(builderQuery),
filters,
timeRange,
label: focusedSeries.seriesName,
seriesColor: focusedSeries.color,
},
};
}

View File

@@ -0,0 +1,49 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { resolveDrilldownSignal } from './signal';
interface EnrichNumberClickArgs {
/** The panel's scalar tables — the displayed value's column selects the drilldown query. */
tables: PanelTable[];
/** The panel's builder queries; resolves the clicked query's signal by name. */
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (the value has no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a Number/Value click into a drilldown payload. Drills into the query the panel actually
* displays — the first table-with-rows' value column (mirrors `prepareNumberData`), not blindly
* `builderQueries[0]` (they diverge for multi-query panels). Returns `null` when that query isn't
* drillable (promql/formula).
*/
export function enrichNumberClick({
tables,
builderQueries,
coordinates,
timeRange,
}: EnrichNumberClickArgs): DrilldownClickPayload | null {
const valueColumn = tables
.find((table) => table.rows.length > 0)
?.columns.find((column) => column.isValueColumn);
const queryName = valueColumn?.queryName ?? builderQueries[0]?.name ?? '';
if (!isValidQueryName(queryName)) {
return null;
}
const builderQuery = builderQueries.find((query) => query.name === queryName);
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: [],
timeRange,
},
};
}

View File

@@ -0,0 +1,50 @@
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichPieClickArgs {
slice: PieSlice;
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (pie slices have no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a pie-slice click into a drilldown payload, using the slice's source-row group-by label
* values (carried by `preparePieData`) as equality filters. Returns `null` when the slice has no
* drillable query.
*/
export function enrichPieClick({
slice,
builderQueries,
coordinates,
timeRange,
}: EnrichPieClickArgs): DrilldownClickPayload | null {
const queryName = slice.queryName ?? '';
if (!isValidQueryName(queryName)) {
return null;
}
const builderQuery = builderQueries.find((query) => query.name === queryName);
const filters = builderQuery
? getGroupByFilters(slice.labels ?? {}, builderQuery)
: [];
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: filters,
timeRange,
label: slice.label,
seriesColor: slice.color,
},
};
}

View File

@@ -0,0 +1,91 @@
import { OPERATORS } from 'constants/queryBuilder';
import {
type FilterData,
isValidQueryName,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { resolveDrilldownSignal } from './signal';
interface EnrichTableClickArgs {
/** The clicked row's data, keyed by column id (see `prepareScalarTables`). */
record: Record<string, unknown>;
/** The clicked column's key (`column.id || column.name`). */
columnId: string;
table: PanelTable;
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (scalar tables have no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a table cell click into a drilldown payload. The clicked value column (or the row's first
* value column) selects the aggregate query, and the row's group-by cells become equality filters
* (V1 `getFiltersToAddToView` parity). `columnKind` records whether a value or group column was
* clicked, for the future filter-by-value menu. Returns `null` when the row has no drillable
* aggregate query.
*/
export function enrichTableClick({
record,
columnId,
table,
builderQueries,
coordinates,
timeRange,
}: EnrichTableClickArgs): DrilldownClickPayload | null {
const clickedColumn = table.columns.find(
(col) => (col.id || col.name) === columnId,
);
const valueColumn = clickedColumn?.isValueColumn
? clickedColumn
: table.columns.find((col) => col.isValueColumn);
if (!valueColumn || !isValidQueryName(valueColumn.queryName)) {
return null;
}
const filters = table.columns.reduce<FilterData[]>((acc, col) => {
if (col.isValueColumn) {
return acc;
}
const value = record[col.id || col.name];
if (value != null) {
// Group cell value → equality filter. Cast at the boundary: row data is `unknown`,
// group cells hold scalar label values.
acc.push({
filterKey: col.name,
filterValue: value as string | number,
operator: OPERATORS['='],
});
}
return acc;
}, []);
const builderQuery = builderQueries.find(
(query) => query.name === valueColumn.queryName,
);
// A group-column click filters by that single cell (V1 filter-by-value); a value-column click
// opens the aggregate menu scoped by the whole row.
const isGroupColumn = clickedColumn != null && !clickedColumn.isValueColumn;
return {
coordinates,
context: {
queryName: valueColumn.queryName,
signal: resolveDrilldownSignal(builderQuery),
filters,
timeRange,
// No `label`: like Number/Value, the aggregate menu header falls back to the
// aggregation expression (e.g. `sum(signoz_calls_total)`), not the column name (V1 parity).
columnKind: isGroupColumn ? 'group' : 'aggregate',
clickedKey: isGroupColumn ? clickedColumn?.name : undefined,
clickedValue: isGroupColumn
? (record[columnId] as string | number)
: undefined,
},
};
}

View File

@@ -0,0 +1,29 @@
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
/** An auto-generated drilldown link (label + destination URL). */
export interface DrilldownDataLink {
id: string;
label: string;
url: string;
}
/**
* Links derived automatically from the clicked point's filters — the V2 port of V1's `getDataLinks`.
* Currently a single "View Trace Details" link when the clicked row carries a `trace_id`.
*/
export function getDataLinks(filters: FilterData[]): DrilldownDataLink[] {
const links: DrilldownDataLink[] = [];
const traceId = filters.find(
(filter) => filter.filterKey === 'trace_id',
)?.filterValue;
if (traceId) {
links.push({
id: 'view-trace-details',
label: 'View Trace Details',
url: `/trace/${traceId}`,
});
}
return links;
}

View File

@@ -0,0 +1,26 @@
import {
type FilterData,
getFiltersFromMetric,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Equality filters for the clicked series/slice, restricted to the query's group-by dimensions.
* `labels` also carry a display-only legend backfill (`{queryName: queryName}` when ungrouped, see
* `resolveLegendAndLabels`); intersecting with group-by drops it so `filters` stays empty when
* ungrouped, matching V1.
*/
export function getGroupByFilters(
labels: Record<string, string>,
builderQuery: BuilderQuery,
): FilterData[] {
const groupByKeys = new Set(
(builderQuery.groupBy ?? []).map((group) => group.name),
);
if (groupByKeys.size === 0) {
return [];
}
return getFiltersFromMetric(labels).filter((filter) =>
groupByKeys.has(filter.filterKey),
);
}

View File

@@ -0,0 +1,50 @@
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
/** A panel context link with its label and URL templates resolved, ready to render. */
export interface ResolvedDrilldownLink {
id: string;
label: string;
url: string;
}
/**
* Resolves a panel's context links for the drilldown menu. Adapts each `DashboardLinkDTO` to the V1
* `ContextLinkProps` the shared `processContextLinks` resolver expects, substitutes variables in the
* label + URL, and drops links without a URL. Links with `renderVariables === false` keep their raw
* label/URL (no substitution).
*/
export function resolvePanelContextLinks(
links: DashboardLinkDTO[] | undefined,
processedVariables: Record<string, string>,
): ResolvedDrilldownLink[] {
const usable = (links ?? []).filter((link) => !!link.url);
if (usable.length === 0) {
return [];
}
const adapted: ContextLinkProps[] = usable.map((link, index) => ({
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
}));
const resolved = processContextLinks(adapted, processedVariables, 50);
return usable.map((link, index) => {
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
if (link.renderVariables === false) {
return {
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
};
}
return {
id: resolved[index].id,
label: resolved[index].label,
url: resolved[index].url,
};
});
}

View File

@@ -0,0 +1,19 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Maps a V5 builder query's `signal` to the drilldown signal. Meter and unknown signals fall back to
* `metrics` so the drilldown always targets a real explorer.
*/
export function resolveDrilldownSignal(
query: BuilderQuery | undefined,
): TelemetrytypesSignalDTO {
switch (query?.signal) {
case 'logs':
return TelemetrytypesSignalDTO.logs;
case 'traces':
return TelemetrytypesSignalDTO.traces;
default:
return TelemetrytypesSignalDTO.metrics;
}
}

View File

@@ -0,0 +1,28 @@
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import store from 'store';
/** Panel time window in epoch SECONDS (uPlot X-scale + drilldown explorer window). */
interface PanelTimeRange {
startTime: number;
endTime: number;
}
/**
* Time window a panel's data was fetched over, read off the request's `start`/`end` (ms → s).
* Falls back to the dashboard global-time window when the panel hasn't fetched yet.
*/
export function getPanelTimeRange(
request: Querybuildertypesv5QueryRangeRequestDTO | undefined,
): PanelTimeRange {
if (request?.start && request?.end) {
return { startTime: request.start / 1000, endTime: request.end / 1000 };
}
const { globalTime } = store.getState();
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: globalTime.selectedTime,
});
return { startTime: parseInt(start, 10), endTime: parseInt(end, 10) };
}

View File

@@ -0,0 +1,10 @@
.signal {
text-transform: capitalize;
}
.label {
overflow: hidden;
font-weight: normal;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,165 @@
import { useMemo } from 'react';
import {
Braces,
ChartBar,
DraftingCompass,
Link,
Loader,
ScrollText,
} from '@signozhq/icons';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import { getAggregateColumnHeader } from 'container/QueryTable/Drilldown/drilldownUtils';
import ContextMenu from 'periscope/components/ContextMenu';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { getDataLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/getDataLinks';
import { resolvePanelContextLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/resolvePanelContextLinks';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { useDrilldownContextVariables } from '../hooks/useDrilldownContextVariables';
import styles from './DrilldownAggregateMenu.module.scss';
interface DrilldownAggregateMenuProps {
context: DrilldownContext;
/** Panel's V5→V1 query — supplies the aggregation-expression header fallback. */
query: Query;
/** While dashboard variables resolve, the actions show a spinner and are disabled. */
isResolving?: boolean;
/** Panel's context links; resolved against the clicked point + variables here. */
links: DashboardLinkDTO[] | undefined;
/** Whether the clicked point exposes group-by fields to bind to dashboard variables. */
canSetDashboardVariables: boolean;
onViewLogs: () => void;
onViewTraces: () => void;
onBreakout: () => void;
/** Open the Dashboard Variables submenu (set/unset/create from the clicked value). */
onSetDashboardVariables: () => void;
/** Close the popover (context-link clicks). */
onClose: () => void;
}
/**
* The base aggregate drill-down menu: tinted header + View in Logs/Traces, Breakout, and the
* panel's context links. Mounted only while open, so the variable/link resolution runs only then.
* Metrics is omitted — V1 surfaces only Logs/Traces.
*/
function DrilldownAggregateMenu({
context,
query,
isResolving = false,
links,
canSetDashboardVariables,
onViewLogs,
onViewTraces,
onBreakout,
onSetDashboardVariables,
onClose,
}: DrilldownAggregateMenuProps): JSX.Element {
const aggregations = useMemo(
() => getAggregateColumnHeader(query, context.queryName).aggregations,
[query, context.queryName],
);
const processedVariables = useDrilldownContextVariables(context);
const contextLinks = useMemo(
() => resolvePanelContextLinks(links, processedVariables),
[links, processedVariables],
);
// Auto links derived from the clicked point itself (e.g. "View Trace Details" for a trace_id).
const dataLinks = useMemo(
() => getDataLinks(context.filters),
[context.filters],
);
return (
<>
<ContextMenu.Header>
<div className={styles.signal}>{context.signal}</div>
<div className={styles.label} style={{ color: context.seriesColor }}>
{context.label || aggregations}
</div>
</ContextMenu.Header>
{canSetDashboardVariables && (
<ContextMenu.Item
icon={
<span style={{ color: context.seriesColor }}>
<Braces size={16} />
</span>
}
onClick={onSetDashboardVariables}
>
<span data-testid="drilldown-dashboard-variables">
Dashboard Variables
</span>
</ContextMenu.Item>
)}
<ContextMenu.Item
icon={
isResolving ? (
<Loader className="animate-spin" size={16} color={context.seriesColor} />
) : (
<span style={{ color: context.seriesColor }}>
<ScrollText size={16} />
</span>
)
}
onClick={onViewLogs}
disabled={isResolving}
>
<span data-testid="drilldown-view-logs">View in Logs</span>
</ContextMenu.Item>
<ContextMenu.Item
icon={
isResolving ? (
<Loader className="animate-spin" color={context.seriesColor} size={16} />
) : (
<span style={{ color: context.seriesColor }}>
<DraftingCompass size={16} />
</span>
)
}
onClick={onViewTraces}
disabled={isResolving}
>
<span data-testid="drilldown-view-traces">View in Traces</span>
</ContextMenu.Item>
<ContextMenu.Item
icon={
<span style={{ color: context.seriesColor }}>
<ChartBar size={16} />
</span>
}
onClick={onBreakout}
>
<span data-testid="drilldown-breakout">Breakout by ..</span>
</ContextMenu.Item>
{dataLinks.map((link) => (
<ContextMenu.Item
key={link.id}
icon={<Link size={16} color={context.seriesColor} />}
onClick={(): void => {
openInNewTab(link.url);
onClose();
}}
>
<span data-testid="drilldown-data-link">{link.label}</span>
</ContextMenu.Item>
))}
{contextLinks.map((link) => (
<ContextMenu.Item
key={link.id}
icon={<Link size={16} color={context.seriesColor} />}
onClick={(): void => {
openInNewTab(link.url);
onClose();
}}
>
<span data-testid="drilldown-context-link">{link.label}</span>
</ContextMenu.Item>
))}
</>
);
}
export default DrilldownAggregateMenu;

View File

@@ -0,0 +1,9 @@
.header {
display: flex;
align-items: center;
gap: 8px;
}
.backArrow {
cursor: pointer;
}

View File

@@ -0,0 +1,45 @@
import { ArrowLeft } from '@signozhq/icons';
import BreakoutOptions from 'container/QueryTable/Drilldown/BreakoutOptions';
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
import ContextMenu from 'periscope/components/ContextMenu';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import styles from './DrilldownBreakoutMenu.module.scss';
interface DrilldownBreakoutMenuProps {
/** The clicked query's builder data — supplies the picker's available attributes. */
queryData: IBuilderQuery;
/** Regroup the clicked query by the picked attribute. */
onBreakout: (groupBy: BreakoutAttributeType) => void;
/** Return to the base aggregate menu. */
onBack: () => void;
}
/**
* The "Breakout by .." submenu — a back header + V1's read-only `BreakoutOptions` attribute picker,
* wired to `onBreakout`.
*/
function DrilldownBreakoutMenu({
queryData,
onBreakout,
onBack,
}: DrilldownBreakoutMenuProps): JSX.Element {
return (
<>
<ContextMenu.Header>
<div className={styles.header}>
<ArrowLeft
size={14}
className={styles.backArrow}
onClick={onBack}
data-testid="drilldown-breakout-back"
/>
<span>Breakout by</span>
</div>
</ContextMenu.Header>
<BreakoutOptions queryData={queryData} onColumnClick={onBreakout} />
</>
);
}
export default DrilldownBreakoutMenu;

View File

@@ -0,0 +1,9 @@
.header {
display: flex;
align-items: center;
gap: 8px;
}
.backArrow {
cursor: pointer;
}

View File

@@ -0,0 +1,79 @@
import { ArrowLeft, Plus, Settings, X } from '@signozhq/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
type DrilldownVariableAction,
DrilldownVariableActionKind,
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables';
import ContextMenu from 'periscope/components/ContextMenu';
import styles from './DrilldownDashboardVariablesMenu.module.scss';
interface DrilldownDashboardVariablesMenuProps {
/** Resolved entries from `useDrilldownDashboardVariables`. */
actions: DrilldownVariableAction[];
/** Return to the base aggregate menu. */
onBack: () => void;
}
const ICON_BY_KIND: Record<DrilldownVariableActionKind, JSX.Element> = {
[DrilldownVariableActionKind.Set]: <Settings size={16} />,
[DrilldownVariableActionKind.Unset]: <X size={16} />,
[DrilldownVariableActionKind.Create]: <Plus size={16} />,
};
/**
* The "Dashboard Variables" drilldown submenu — renders the entries resolved by
* `useDrilldownDashboardVariables` (Set/Unset an existing dynamic variable, or Create one).
*/
function DrilldownDashboardVariablesMenu({
actions,
onBack,
}: DrilldownDashboardVariablesMenuProps): JSX.Element {
return (
<>
<ContextMenu.Header>
<div className={styles.header}>
<ArrowLeft
size={14}
className={styles.backArrow}
onClick={onBack}
data-testid="drilldown-var-back"
/>
<span>Dashboard Variables</span>
</div>
</ContextMenu.Header>
<OverlayScrollbar
style={{ maxHeight: '200px' }}
options={{ overflow: { x: 'hidden' } }}
>
<>
{actions.map(({ fieldName, fieldValue, kind, onClick }) => (
<ContextMenu.Item
key={fieldName}
icon={ICON_BY_KIND[kind]}
onClick={onClick}
>
{kind === DrilldownVariableActionKind.Unset && (
<span data-testid="drilldown-var-unset">
Unset <strong>${fieldName}</strong>
</span>
)}
{kind === DrilldownVariableActionKind.Set && (
<span data-testid="drilldown-var-set">
Set <strong>${fieldName}</strong> to <strong>{fieldValue}</strong>
</span>
)}
{kind === DrilldownVariableActionKind.Create && (
<span data-testid="drilldown-var-create">
Create var <strong>${fieldName}</strong>:<strong>{fieldValue}</strong>
</span>
)}
</ContextMenu.Item>
))}
</>
</OverlayScrollbar>
</>
);
}
export default DrilldownDashboardVariablesMenu;

View File

@@ -0,0 +1,40 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getGroupContextMenuConfig } from 'container/QueryTable/Drilldown/contextConfig';
import type { ClickedData } from 'periscope/components/ContextMenu';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
interface DrilldownFilterMenuProps {
/** The panel's V5→V1 query the operator menu builds filters against. */
v1Query: Query;
/** The clicked group column's key. */
clickedKey: string;
/** Apply the chosen operator (adds the filter and opens the View modal). */
onFilter: (operator: string) => void;
}
/**
* The group-column "filter by value" submenu — the operator list from V1's read-only
* `getGroupContextMenuConfig`, wired to `onFilter`.
*/
function DrilldownFilterMenu({
v1Query,
clickedKey,
onFilter,
}: DrilldownFilterMenuProps): JSX.Element {
const clickedData: ClickedData = {
column: { dataIndex: clickedKey },
record: { key: clickedKey, timestamp: 0 },
};
return (
<>
{getGroupContextMenuConfig({
query: v1Query,
clickedData,
panelType: PANEL_TYPES.TABLE,
onColumnClick: onFilter,
}).items ?? null}
</>
);
}
export default DrilldownFilterMenu;

View File

@@ -3,11 +3,13 @@ import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
@@ -68,6 +70,7 @@ function Panel({
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
return (
<div
@@ -100,8 +103,11 @@ function Panel({
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
)}
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}

View File

@@ -294,4 +294,13 @@ describe('usePanelActionItems', () => {
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
it('create-alert seeds an alert from this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const createAlert = result.current.items.find(
(i) => 'key' in i && i.key === 'create-alert',
);
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
});

View File

@@ -3,12 +3,13 @@ import type { ComponentTypes } from 'utils/permission';
/**
* Every action the panel menu can offer: per-kind gated capabilities (minus
* `search`, a header control) plus the chrome actions every kind gets. The
* `Record<PanelActionId, …>` below forces a meta entry per id, so adding an
* action without declaring its gates is a compile error.
* `search` and `drilldown`, which are renderer-wired controls, not menu items)
* plus the chrome actions every kind gets. The `Record<PanelActionId, …>` below
* forces a meta entry per id, so adding an action without declaring its gates is
* a compile error.
*/
export type PanelActionId =
| Exclude<keyof PanelActionCapabilities, 'search'>
| Exclude<keyof PanelActionCapabilities, 'search' | 'drilldown'>
| 'move'
| 'delete';

View File

@@ -3,6 +3,7 @@ import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
@@ -37,6 +38,10 @@ interface PanelBodyProps {
pagination?: PanelPagination;
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
/** Opens the drill-down context menu; threaded to interactive renderers. */
onClick?: AnyPanelInteractionProps['onClick'];
/** Gate for the drill-down menu — kind supported and the panel has a builder query. */
enableDrillDown?: boolean;
}
/**
@@ -58,6 +63,8 @@ function PanelBody({
searchTerm,
pagination,
onCloseStandaloneView,
onClick,
enableDrillDown = false,
}: PanelBodyProps): JSX.Element {
// A retained response (keepPreviousData) counts as data only if its type matches the current
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
@@ -120,7 +127,8 @@ function PanelBody({
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
enableDrillDown={false}
enableDrillDown={enableDrillDown}
onClick={onClick}
dashboardPreference={dashboardPreference}
searchTerm={searchTerm}
pagination={pagination}

View File

@@ -0,0 +1,64 @@
import { type KeyboardEvent, useCallback } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Run/Cancel button state. */
isLoadingQueries: boolean;
/** Run the current query (Run Query button / ⌘↵). */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch. */
onCancelQuery: () => void;
}
/**
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
* is query-builder only, exactly as V1.
*/
function ViewPanelQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
}: ViewPanelQueryBuilderProps): JSX.Element {
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
onStageRunQuery();
}
},
[onStageRunQuery],
);
return (
<div
className={styles.queryBuilder}
data-testid="view-panel-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<QueryBuilderV2
panelType={panelType}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
/>
<div className={styles.queryBuilderToolbar}>
<RightToolbarActions
handleCancelQuery={onCancelQuery}
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
/>
</div>
</div>
);
}
export default ViewPanelQueryBuilder;

View File

@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import {
type DrilldownVariableAction,
DrilldownVariableActionKind,
} from '../hooks/useDrilldownDashboardVariables';
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
function action(
overrides: Partial<DrilldownVariableAction> = {},
): DrilldownVariableAction {
return {
fieldName: 'service.name',
fieldValue: 'frontend',
kind: DrilldownVariableActionKind.Set,
onClick: jest.fn(),
...overrides,
};
}
describe('DrilldownDashboardVariablesMenu', () => {
it('renders each kind with its own label and fires its onClick', async () => {
const onSet = jest.fn();
const onUnset = jest.fn();
const onCreate = jest.fn();
render(
<DrilldownDashboardVariablesMenu
onBack={jest.fn()}
actions={[
action({
fieldName: 'a',
kind: DrilldownVariableActionKind.Set,
onClick: onSet,
}),
action({
fieldName: 'b',
kind: DrilldownVariableActionKind.Unset,
onClick: onUnset,
}),
action({
fieldName: 'c',
kind: DrilldownVariableActionKind.Create,
onClick: onCreate,
}),
]}
/>,
);
expect(screen.getByTestId('drilldown-var-set')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-var-unset')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(onSet).toHaveBeenCalledTimes(1);
});
it('returns to the base menu when the back arrow is clicked', async () => {
const onBack = jest.fn();
render(
<DrilldownDashboardVariablesMenu onBack={onBack} actions={[action()]} />,
);
await userEvent.click(screen.getByTestId('drilldown-var-back'));
expect(onBack).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,299 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { useDrilldown } from '../hooks/useDrilldown';
const mockOpenViewWithQuery = jest.fn();
const mockNavigate = jest.fn();
const mockGetBuilderQueries = jest.fn();
let mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
let mockDashboardVariables: {
hasFieldVariables: boolean;
actions: unknown[];
} = {
hasFieldVariables: true,
actions: [],
};
// Boundaries tested elsewhere / needing external context — mocked so this suite isolates
// useDrilldown's orchestration (gating, which menu shows, the View-modal handoff).
jest.mock('../hooks/useViewPanel', () => ({
useViewPanel: (): unknown => ({ openViewWithQuery: mockOpenViewWithQuery }),
}));
// Variable-substitution boundary (redux/store/react-query) — its own logic is out of scope here.
jest.mock('../hooks/useResolvedDrilldownQuery', () => ({
useResolvedDrilldownQuery: (): unknown => mockResolved,
}));
jest.mock('../hooks/useDrilldownBreakout', () => ({
useDrilldownBreakout: (): unknown => ({
queryData: { queryName: 'A' },
onBreakout: jest.fn(),
}),
}));
jest.mock('../DrilldownMenu/DrilldownBreakoutMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="breakout-submenu" />,
}));
jest.mock('../hooks/useDrilldownDashboardVariables', () => ({
useDrilldownDashboardVariables: (): unknown => mockDashboardVariables,
}));
jest.mock('../DrilldownMenu/DrilldownDashboardVariablesMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-variables-submenu" />,
}));
// Context-link variable map (redux global time + store) — out of scope for this suite.
jest.mock('../hooks/useDrilldownContextVariables', () => ({
useDrilldownContextVariables: (): unknown => ({}),
}));
jest.mock('container/QueryTable/Drilldown/useBaseDrilldownNavigate', () => ({
__esModule: true,
default: (): unknown => mockNavigate,
}));
jest.mock('container/QueryTable/Drilldown/contextConfig', () => ({
getGroupContextMenuConfig: ({
onColumnClick,
}: {
onColumnClick: (op: string) => void;
}): unknown => ({
items: (
<button
type="button"
data-testid="filter-op"
onClick={(): void => onColumnClick('=')}
>
Is this
</button>
),
}),
}));
jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
addFilterToQuery: jest.fn(() => 'REFINED_QUERY'),
getAggregateColumnHeader: (): unknown => ({
aggregations: 'sum(x)',
dataSource: 'metrics',
}),
getBaseMeta: (): unknown => undefined,
isNumberDataType: (): boolean => false,
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
fromPerses: (): string => 'V1_QUERY',
toPerses: jest.fn(() => [{ kind: 'REFINED' }]),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
() => ({
getBuilderQueries: (...args: unknown[]): unknown =>
mockGetBuilderQueries(...args),
}),
);
// Capability lookup mocked (its per-kind values are data in the definitions); avoids
// importing the whole renderer registry into the test.
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: (kind: string): unknown => ({
actions: { drilldown: kind !== 'signoz/ListPanel' },
}),
}));
function panelOfKind(kind: string): DashboardtypesPanelDTO {
return {
spec: { plugin: { kind, spec: {} }, queries: [{ x: 1 }] },
display: { name: 'P' },
} as unknown as DashboardtypesPanelDTO;
}
const tsPanel = panelOfKind('signoz/TimeSeriesPanel');
const aggregateContext: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.metrics,
filters: [],
label: 'frontend',
seriesColor: '#fff',
};
const groupContext: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.metrics,
filters: [],
columnKind: 'group',
clickedKey: 'service.name',
clickedValue: 'frontend',
};
describe('useDrilldown', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuilderQueries.mockReturnValue([{ name: 'A' }]);
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
mockDashboardVariables = {
hasFieldVariables: true,
actions: [],
};
});
describe('enableDrillDown', () => {
it('is true when the kind declares drilldown and has a builder query', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
expect(result.current.enableDrillDown).toBe(true);
});
it('is false when there is no builder query', () => {
mockGetBuilderQueries.mockReturnValue([]);
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
expect(result.current.enableDrillDown).toBe(false);
});
it('is false for a kind that opts out of drilldown', () => {
const { result } = renderHook(() =>
useDrilldown(panelOfKind('signoz/ListPanel'), 'p1'),
);
expect(result.current.enableDrillDown).toBe(false);
});
});
describe('aggregate menu', () => {
it('shows View in Logs/Traces + Breakout on an aggregate click', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(screen.getByTestId('drilldown-view-logs')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-view-traces')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-breakout')).toBeInTheDocument();
});
it('navigates to logs when View in Logs is clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-view-logs'));
expect(mockNavigate).toHaveBeenCalledWith('view_logs');
});
it('disables navigation while dashboard variables resolve', async () => {
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: true };
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-view-logs'));
expect(mockNavigate).not.toHaveBeenCalled();
});
it('swaps to the breakout submenu when "Breakout by .." is clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
const view = render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-breakout'));
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
expect(screen.getByTestId('breakout-submenu')).toBeInTheDocument();
});
it('shows the Dashboard Variables entry when the click has group-by fields', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.getByTestId('drilldown-dashboard-variables'),
).toBeInTheDocument();
});
it('hides the Dashboard Variables entry when there are no group-by fields', () => {
mockDashboardVariables = { hasFieldVariables: false, actions: [] };
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.queryByTestId('drilldown-dashboard-variables'),
).not.toBeInTheDocument();
});
it('swaps to the Dashboard Variables submenu when clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
const view = render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-dashboard-variables'));
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.getByTestId('dashboard-variables-submenu'),
).toBeInTheDocument();
});
});
describe('filter-by-value', () => {
it('opens the View modal with the refined query on a group-column filter', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: groupContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('filter-op'));
// Opens the View modal on the refined query at the panel's kind — persisted in the URL.
expect(mockOpenViewWithQuery).toHaveBeenCalledWith(
'p1',
'REFINED_QUERY',
PANEL_TYPES.TIME_SERIES,
);
});
});
});

View File

@@ -0,0 +1,213 @@
import { render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import { useDrilldownDashboardVariables } from '../hooks/useDrilldownDashboardVariables';
// Fake dashboard variables + runtime selection, mutated per test. `dtoToFormModel` is mocked to
// derive `type` from the plugin kind (like the real adapter), so these DTOs carry the plugin
// discriminant plus the flat form-model fields the hook reads.
let mockVariables: Array<{
name: string;
dynamicAttribute?: string;
multiSelect?: boolean;
spec: { plugin: { kind: string } };
}> = [];
let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
{};
const mockSetVariableValue = jest.fn();
const mockSetUrlValues = jest.fn();
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
const DYNAMIC_KIND = 'signoz/DynamicVariable';
const QUERY_KIND = 'signoz/QueryVariable';
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => ({
data: { data: { spec: { variables: mockVariables } } },
}),
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch',
() => ({
useOptimisticPatch: (): unknown => ({ patchAsync: mockPatchAsync }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({
dashboardId: 'd1',
variableValues: { d1: mockSelectionMap },
setVariableValue: mockSetVariableValue,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
() => ({
dtoToFormModel: (dto: {
spec?: { plugin?: { kind?: string } };
}): unknown => ({
...dto,
type:
dto.spec?.plugin?.kind === 'signoz/DynamicVariable' ? 'DYNAMIC' : 'QUERY',
}),
formModelToDto: (model: { name: string }): unknown => ({ dto: model.name }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
() => ({
emptyVariableFormModel: (): unknown => ({}),
DYNAMIC_SIGNAL_ALL: 'all',
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
() => ({
ALL_SELECTED: '__ALL__',
variablesUrlParser: { withOptions: (): unknown => ({}) },
}),
);
jest.mock('nuqs', () => ({
useQueryState: (): unknown => [null, mockSetUrlValues],
}));
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const filters: FilterData[] = [
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
];
function renderItems(): void {
const { result } = renderHook(() =>
useDrilldownDashboardVariables({
filters,
signal: TelemetrytypesSignalDTO.metrics,
onClose: jest.fn(),
}),
);
render(
<DrilldownDashboardVariablesMenu
actions={result.current.actions}
onBack={jest.fn()}
/>,
);
}
describe('useDrilldownDashboardVariables', () => {
beforeEach(() => {
jest.clearAllMocks();
mockVariables = [];
mockSelectionMap = {};
});
it('hasFieldVariables reflects the clicked point group-by fields', () => {
const withFields = renderHook(() =>
useDrilldownDashboardVariables({ filters, onClose: jest.fn() }),
);
expect(withFields.result.current.hasFieldVariables).toBe(true);
const noFields = renderHook(() =>
useDrilldownDashboardVariables({ filters: [], onClose: jest.fn() }),
);
expect(noFields.result.current.hasFieldVariables).toBe(false);
});
it('offers Set — writing an array for a multi-select var so the selector shows it', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: true,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: ['backend'], allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: ['frontend'],
allSelected: false,
});
});
it('offers Set — writing a scalar for a single-select var', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: false,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: 'backend', allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: 'frontend',
allSelected: false,
});
});
it('offers Unset when the matching dynamic var already holds the clicked value', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: true,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: ['frontend'], allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-unset'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: [],
allSelected: false,
});
});
it('offers Create and persists a new dynamic variable when none matches', async () => {
mockVariables = [];
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-create'));
// Persisted to the spec via the optimistic patch...
expect(mockPatchAsync).toHaveBeenCalledTimes(1);
// ...and seeded (as an array — the created var is multi-select) with the clicked value.
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'service.name', {
value: ['frontend'],
allSelected: false,
});
});
it('ignores a non-dynamic variable with the same attribute (still offers Create)', () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
spec: { plugin: { kind: QUERY_KIND } },
},
];
renderItems();
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
expect(screen.queryByTestId('drilldown-var-set')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,243 @@
import { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useBaseDrilldownNavigate from 'container/QueryTable/Drilldown/useBaseDrilldownNavigate';
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
import type {
DrilldownClickPayload,
DrilldownContext,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildAggregateData';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import DrilldownAggregateMenu from '../DrilldownMenu/DrilldownAggregateMenu';
import DrilldownBreakoutMenu from '../DrilldownMenu/DrilldownBreakoutMenu';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import DrilldownFilterMenu from '../DrilldownMenu/DrilldownFilterMenu';
import { useDrilldownBreakout } from './useDrilldownBreakout';
import { useDrilldownCoordinates } from './useDrilldownCoordinates';
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
import { useDrilldownFilter } from './useDrilldownFilter';
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
import { useViewPanel } from './useViewPanel';
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
enum DrilldownSubMenu {
Base = 'base',
Breakout = 'breakout',
DashboardVariables = 'dashboardVariables',
}
/** Stable empty-filters ref so the dashboard-variables hook doesn't re-run on every no-click render. */
const EMPTY_FILTERS: FilterData[] = [];
/** Props the panel shell spreads onto `<ContextMenu>`. */
export interface DrilldownContextMenuProps {
coordinates: Coordinates | null;
popoverPosition: PopoverPosition | null;
items: ReactNode;
onClose: () => void;
}
export interface UseDrilldownResult {
/** Whether interactive renderers should arm the drill-down click. */
enableDrillDown: boolean;
/** Renderer `onClick` handler — opens the menu at the clicked point. */
onPanelClick: (payload: DrilldownClickPayload) => void;
contextMenuProps: DrilldownContextMenuProps;
}
/**
* Orchestrates panel drill-down: owns the popover + which submenu is open, and routes the clicked
* point to the base aggregate menu (View in Logs/Traces), the group filter menu, or the breakout picker.
*/
export function useDrilldown(
panel: DashboardtypesPanelDTO,
panelId: string,
): UseDrilldownResult {
const kind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[kind];
const queries = panel.spec.queries;
// Kind must opt in via its capability AND have a builder query to drill into.
const enableDrillDown = useMemo(
() =>
getPanelDefinition(kind).actions.drilldown &&
getBuilderQueries(queries).length > 0,
[kind, queries],
);
const v1Query = useMemo(
() => fromPerses(queries, panelType),
[queries, panelType],
);
const {
coordinates,
popoverPosition,
clickedData: context,
onClick,
onClose,
} = useDrilldownCoordinates<DrilldownContext>();
const aggregateData = useMemo(
() => (context ? buildAggregateData(context) : null),
[context],
);
// A fresh click and any close reset to the base menu.
const [subMenu, setSubMenu] = useState<DrilldownSubMenu>(
DrilldownSubMenu.Base,
);
const openBreakout = useCallback(
(): void => setSubMenu(DrilldownSubMenu.Breakout),
[],
);
const backToBase = useCallback(
(): void => setSubMenu(DrilldownSubMenu.Base),
[],
);
const openDashboardVariables = useCallback(
(): void => setSubMenu(DrilldownSubMenu.DashboardVariables),
[],
);
const onPanelClick = useCallback(
(payload: DrilldownClickPayload): void => {
setSubMenu(DrilldownSubMenu.Base);
onClick(payload.coordinates, payload.context);
},
[onClick],
);
const handleClose = useCallback((): void => {
setSubMenu(DrilldownSubMenu.Base);
onClose();
}, [onClose]);
const { openViewWithQuery } = useViewPanel();
const breakout = useDrilldownBreakout({
panelId,
v1Query,
panelType,
aggregateData,
openViewWithQuery,
onClose: handleClose,
});
const filter = useDrilldownFilter({
context,
v1Query,
panelId,
panelType,
openViewWithQuery,
onClose: handleClose,
});
const dashboardVariables = useDrilldownDashboardVariables({
filters: context?.filters ?? EMPTY_FILTERS,
signal: context?.signal,
onClose: handleClose,
});
// The aggregate menu (View in Logs/Traces) shows for a non-group click on the base menu; the
// group click routes to filter-by-value instead. Only that menu resolves variables —
// filter/breakout open the View modal, which resolves at query-run time.
const showAggregateMenu =
subMenu === DrilldownSubMenu.Base && !!context && !filter.isGroupColumnClick;
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelType,
v1Query,
enabled: showAggregateMenu,
});
const navigate = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: handleClose,
});
const items = useMemo<ReactNode>(() => {
if (subMenu === DrilldownSubMenu.Breakout) {
return breakout.queryData ? (
<DrilldownBreakoutMenu
queryData={breakout.queryData}
onBreakout={breakout.onBreakout}
onBack={backToBase}
/>
) : null;
}
if (subMenu === DrilldownSubMenu.DashboardVariables) {
return (
<DrilldownDashboardVariablesMenu
actions={dashboardVariables.actions}
onBack={backToBase}
/>
);
}
if (filter.isGroupColumnClick && context?.clickedKey) {
return (
<DrilldownFilterMenu
v1Query={v1Query}
clickedKey={context.clickedKey}
onFilter={filter.onFilter}
/>
);
}
if (!context) {
return null;
}
return (
<DrilldownAggregateMenu
context={context}
query={v1Query}
isResolving={isResolving}
links={panel.spec.links}
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
onViewLogs={(): void => navigate('view_logs')}
onViewTraces={(): void => navigate('view_traces')}
onBreakout={openBreakout}
onSetDashboardVariables={openDashboardVariables}
onClose={handleClose}
/>
);
}, [
subMenu,
breakout.queryData,
breakout.onBreakout,
dashboardVariables.actions,
dashboardVariables.hasFieldVariables,
filter.isGroupColumnClick,
filter.onFilter,
context,
v1Query,
isResolving,
panel.spec.links,
navigate,
openBreakout,
openDashboardVariables,
backToBase,
handleClose,
]);
return {
enableDrillDown,
onPanelClick,
contextMenuProps: {
coordinates,
popoverPosition,
items,
onClose: handleClose,
},
};
}

View File

@@ -0,0 +1,76 @@
import { useCallback, useMemo } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getQueryData } from 'container/QueryTable/Drilldown/drilldownUtils';
import {
getBreakoutPanelType,
getBreakoutQuery,
} from 'container/QueryTable/Drilldown/tableDrilldownUtils';
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
interface UseDrilldownBreakoutArgs {
panelId: string;
/** The panel's V5→V1 query; the breakout regroups the clicked query within it. */
v1Query: Query;
/** The panel's current V1 panel type — drives the breakout target type. */
panelType: PANEL_TYPES;
aggregateData: AggregateData | null;
/** Opens the View modal on the breakout query (at the breakout's target kind), persisting it in the URL. */
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the popover after navigating to the View modal. */
onClose: () => void;
}
export interface UseDrilldownBreakoutApi {
/** The clicked query's builder data for the attribute picker; `undefined` when there's no aggregate to break out. */
queryData: IBuilderQuery | undefined;
/** Regroup the clicked query by the picked attribute and open the result in the View modal. */
onBreakout: (groupBy: BreakoutAttributeType) => void;
}
/**
* The "Breakout by .." submenu logic: regroup the clicked query by a picked attribute and open the
* result in the View modal. Reuses V1's read-only `getBreakoutQuery`/`getBreakoutPanelType`; the
* caller renders `DrilldownBreakoutMenu` (V1's `BreakoutOptions` picker) from this hook's return.
*/
export function useDrilldownBreakout({
panelId,
v1Query,
panelType,
aggregateData,
openViewWithQuery,
onClose,
}: UseDrilldownBreakoutArgs): UseDrilldownBreakoutApi {
const onBreakout = useCallback(
(groupBy: BreakoutAttributeType): void => {
if (!aggregateData) {
return;
}
const breakoutQuery = getBreakoutQuery(
v1Query,
aggregateData,
groupBy,
aggregateData.filters ?? [],
);
openViewWithQuery(panelId, breakoutQuery, getBreakoutPanelType(panelType));
onClose();
},
[aggregateData, v1Query, panelType, panelId, openViewWithQuery, onClose],
);
const queryData = useMemo(
() =>
aggregateData ? getQueryData(v1Query, aggregateData.queryName) : undefined,
[aggregateData, v1Query],
);
return { queryData, onBreakout };
}

View File

@@ -0,0 +1,58 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- context links resolve global-time variables off redux (V1 parity)
import { useSelector } from 'react-redux';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
/**
* Builds the variable map that resolves context-link templates, mirroring V1's `useContextVariables`
* but sourced from V2 state: dashboard variable selections (by name), the global time window
* (`timestamp_start`/`timestamp_end`, ms), and the clicked point's field values (`_`-prefixed, the
* V1 convention that keeps them from clashing with dashboard variable names).
*/
export function useDrilldownContextVariables(
context: DrilldownContext | null,
): Record<string, string> {
// dashboardId from the store's edit context (set once by DashboardContainer), the same
// source the rest of V2 uses — not react-router params.
const dashboardId = useDashboardStore((state) => state.dashboardId);
// Select the stable top-level map (not `state.variableValues[id] ?? {}`, whose fresh `{}` each
// render makes useSyncExternalStore loop); index by dashboard inside the memo.
const variableValuesByDashboard = useDashboardStore(
(state) => state.variableValues,
);
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
return useMemo(() => {
const result: Record<string, string> = {};
const variableValues = variableValuesByDashboard[dashboardId] ?? {};
Object.entries(variableValues).forEach(([name, selection]) => {
const { value } = selection;
if (value == null) {
return;
}
result[name] = Array.isArray(value) ? value.join(', ') : String(value);
});
// Global time window, ns → ms (V1 parity).
result.timestamp_start = String(Math.floor(globalTime.minTime / 1e6));
result.timestamp_end = String(Math.floor(globalTime.maxTime / 1e6));
context?.filters.forEach(({ filterKey, filterValue }) => {
result[`_${filterKey}`] = String(filterValue);
});
return result;
}, [
variableValuesByDashboard,
dashboardId,
globalTime.minTime,
globalTime.maxTime,
context,
]);
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react';
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
import { calculatePopoverPosition } from '../utils/calculatePopoverPosition';
/**
* Popover state for the drill-down context menu. V2's strongly-typed counterpart to V1's
* `useCoordinates` (whose `clickedData` is `any`): the caller pins `TData` to the click payload,
* so it flows through without a cast. `null` fields mean the menu is closed.
*/
export interface UseDrilldownCoordinatesResult<TData> {
coordinates: Coordinates | null;
popoverPosition: PopoverPosition | null;
clickedData: TData | null;
onClick: (coordinates: Coordinates, data: TData) => void;
onClose: () => void;
}
export function useDrilldownCoordinates<
TData,
>(): UseDrilldownCoordinatesResult<TData> {
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition | null>(
null,
);
const [clickedData, setClickedData] = useState<TData | null>(null);
const onClick = useCallback((coords: Coordinates, data: TData): void => {
setClickedData(data);
setCoordinates(coords);
setPopoverPosition(calculatePopoverPosition(coords));
}, []);
const onClose = useCallback((): void => {
setCoordinates(null);
setPopoverPosition(null);
setClickedData(null);
}, []);
return { coordinates, popoverPosition, clickedData, onClick, onClose };
}

View File

@@ -0,0 +1,191 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useQueryState } from 'nuqs';
import {
dtoToFormModel,
formModelToDto,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
emptyVariableFormModel,
type VariableFormModel,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel';
import { buildVariablesPatch } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variablePatchOps';
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
import { useOptimisticPatch } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch';
import { selectVariableValues } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/selectionTypes';
import {
ALL_SELECTED,
variablesUrlParser,
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
interface UseDrilldownDashboardVariablesArgs {
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
filters: FilterData[];
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
signal?: TelemetrytypesSignalDTO;
/** Close the popover after an action. */
onClose: () => void;
}
/** Set/Unset a matching dynamic variable's value, or Create one when none matches. */
export enum DrilldownVariableActionKind {
Set = 'set',
Unset = 'unset',
Create = 'create',
}
/** A resolved "Dashboard Variables" menu entry; the caller renders it. */
export interface DrilldownVariableAction {
fieldName: string;
fieldValue: string | number;
kind: DrilldownVariableActionKind;
/** Applies the action and closes the popover. */
onClick: () => void;
}
export interface UseDrilldownDashboardVariablesApi {
/** Whether the clicked point exposes any field to bind to a variable (gates the base-menu entry). */
hasFieldVariables: boolean;
/** Resolved menu entries, one per group-by field on the clicked point. */
actions: DrilldownVariableAction[];
}
/**
* "Dashboard Variables" submenu logic (V1 `useDashboardVarConfig` parity). Set/Unset are runtime-only
* (store + URL — V2 selections don't persist); Create is the one path that patches `spec.variables`.
*/
export function useDrilldownDashboardVariables({
filters,
signal,
onClose,
}: UseDrilldownDashboardVariablesArgs): UseDrilldownDashboardVariablesApi {
const dashboardId = useDashboardStore((state) => state.dashboardId);
const { variables } = useDashboardFetchRequired();
const dynamicVariables = useMemo(
() => variables.map(dtoToFormModel).filter((v) => v.type === 'DYNAMIC'),
[variables],
);
const existingNames = useMemo(
() => new Set(variables.map((v) => dtoToFormModel(v).name)),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
const [, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
const { patchAsync } = useOptimisticPatch();
const fieldVariables = useMemo<[string, string | number][]>(
() =>
filters
.filter(
(f) => f.filterKey && f.filterValue !== undefined && f.filterValue !== '',
)
.map((f) => [f.filterKey, f.filterValue]),
[filters],
);
// Runtime-only write (store + URL), never the spec — mirrors VariablesBar's setSelection.
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
const handleCreate = useCallback(
async (fieldName: string, fieldValue: string | number): Promise<void> => {
if (existingNames.has(fieldName)) {
toast.error(`Variable "${fieldName}" already exists`);
return;
}
const model: VariableFormModel = {
...emptyVariableFormModel(),
name: fieldName,
description: `Created from panel drilldown (field: ${fieldName})`,
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(
buildVariablesPatch([...variables, formModelToDto(model)]),
);
// Multi-select var → seed the value as an array (the selector renders scalars as empty).
setSelection(fieldName, { value: [fieldValue], allSelected: false });
toast.success(`Created variable "${fieldName}"`);
} catch {
toast.error('Failed to create variable');
}
onClose();
},
[existingNames, signal, variables, patchAsync, setSelection, onClose],
);
const actions = useMemo<DrilldownVariableAction[]>(
() =>
fieldVariables.map(([fieldName, fieldValue]) => {
const existing = dynamicVariables.find(
(v) => v.dynamicAttribute === fieldName,
);
if (!existing) {
return {
fieldName,
fieldValue,
kind: DrilldownVariableActionKind.Create,
onClick: (): void => {
void handleCreate(fieldName, fieldValue);
},
};
}
const current = selection[existing.name]?.value;
const isSame = Array.isArray(current)
? current.length === 1 && current[0] === fieldValue
: current === fieldValue;
// Multi-select values must be arrays (a scalar renders as empty in the selector).
const cleared = existing.multiSelect ? [] : '';
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
return {
fieldName,
fieldValue,
kind: isSame
? DrilldownVariableActionKind.Unset
: DrilldownVariableActionKind.Set,
onClick: (): void => {
setSelection(existing.name, {
value: isSame ? cleared : assigned,
allSelected: false,
});
onClose();
},
};
}),
[
fieldVariables,
dynamicVariables,
selection,
setSelection,
handleCreate,
onClose,
],
);
return { hasFieldVariables: fieldVariables.length > 0, actions };
}

View File

@@ -0,0 +1,72 @@
import { useCallback } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
addFilterToQuery,
getBaseMeta,
isNumberDataType,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
interface UseDrilldownFilterArgs {
/** The clicked point's context; the filter menu only appears for a group-column click. */
context: DrilldownContext | null;
/** The panel's V5→V1 query the filter is added to. */
v1Query: Query;
panelId: string;
/** Panel's V1 type — the kind the refined query opens the View modal as. */
panelType: PANEL_TYPES;
/** Opens the View modal on the refined query, persisting it in the URL. */
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the popover after opening the View modal. */
onClose: () => void;
}
export interface UseDrilldownFilterApi {
/** True for a group-column click — the caller renders the filter-by-value menu. */
isGroupColumnClick: boolean;
/** Apply the chosen operator: add `key <op> value` and open the refined result in the View modal. */
onFilter: (operator: string) => void;
}
/**
* The group-column "filter by value" drill-down (V1 parity): adds `key <op> value` to the panel's
* query and opens the refined result in the View modal. The caller renders `DrilldownFilterMenu`
* (V1's read-only `getGroupContextMenuConfig`) from this hook's return.
*/
export function useDrilldownFilter({
context,
v1Query,
panelId,
panelType,
openViewWithQuery,
onClose,
}: UseDrilldownFilterArgs): UseDrilldownFilterApi {
const onFilter = useCallback(
(operator: string): void => {
if (!context?.clickedKey) {
return;
}
let filterValue: string | number = context.clickedValue ?? '';
const baseMeta = getBaseMeta(v1Query, context.clickedKey);
if (baseMeta && isNumberDataType(baseMeta.dataType) && filterValue !== '') {
filterValue = Number(filterValue);
}
const refinedQuery = addFilterToQuery(v1Query, [
{ filterKey: context.clickedKey, filterValue, operator },
]);
openViewWithQuery(panelId, refinedQuery, panelType);
onClose();
},
[context, v1Query, panelType, panelId, openViewWithQuery, onClose],
);
const isGroupColumnClick =
!!context && context.columnKind === 'group' && !!context.clickedKey;
return { isGroupColumnClick, onFilter };
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
interface UseResolvedDrilldownQueryArgs {
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
v1Query: Query;
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
enabled: boolean;
}
interface UseResolvedDrilldownQueryResult {
/** The variable-substituted query for View-in-X navigation. */
resolvedQuery: Query;
/** True while the round-trip is in flight — View-in-X shows a spinner and is disabled. */
isResolving: boolean;
}
/**
* Resolves the panel's dashboard-variable references (`$var`) into concrete values before
* View in Logs/Traces builds the explorer URL — V1 parity (`useBaseAggregateOptions` runs the
* same `/substitute_vars` round-trip). Skipped when the dashboard has no selections: the raw
* query already carries the refs verbatim and the round-trip would be a no-op. Mirrors the
* V2-native path in {@link useCreateAlertFromPanel}.
*/
export function useResolvedDrilldownQuery({
queries,
panelType,
v1Query,
enabled,
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { mutate: substituteVars, data, isLoading } = useReplaceVariables();
const hasVariables = Object.keys(variables).length > 0;
useEffect(() => {
if (!enabled || !hasVariables) {
return;
}
// Redux global time is nanoseconds; the request DTO takes integer epoch ms — the
// backend rejects fractional bounds, so floor after the ns→ms divide.
substituteVars({
data: buildQueryRangeRequest({
queries,
panelType,
startMs: Math.floor(minTime / 1e6),
endMs: Math.floor(maxTime / 1e6),
variables,
}),
});
}, [
enabled,
hasVariables,
queries,
panelType,
minTime,
maxTime,
variables,
substituteVars,
]);
const resolvedQuery = useMemo(() => {
if (!hasVariables || !data) {
return v1Query;
}
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
}, [hasVariables, data, v1Query, panelType]);
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
}

View File

@@ -1,22 +1,33 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
openView: (panelId: string) => void;
/** Close the View modal by clearing the URL param. */
/**
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
*/
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the View modal by clearing its URL params. */
closeView: () => void;
}
/**
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
* the open state is shareable, survives refresh, and the browser back-button
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
* is shareable, survives refresh, and the browser back-button closes it.
*/
export function useViewPanel(): UseViewPanelApi {
const { safeNavigate } = useSafeNavigate();
@@ -39,6 +50,22 @@ export function useViewPanel(): UseViewPanelApi {
[pathname, safeNavigate, urlQuery],
);
const openViewWithQuery = useCallback(
(panelId: string, query: Query, panelType: PANEL_TYPES): void => {
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
next.set(QueryParams.graphType, panelType);
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
);
const closeView = useCallback((): void => {
const next = new URLSearchParams(urlQuery);
next.delete(QueryParams.expandedWidgetId);
@@ -50,5 +77,5 @@ export function useViewPanel(): UseViewPanelApi {
safeNavigate(search ? `${pathname}?${search}` : pathname);
}, [pathname, safeNavigate, urlQuery]);
return { expandedPanelId, openView, closeView };
return { expandedPanelId, openView, openViewWithQuery, closeView };
}

View File

@@ -0,0 +1,53 @@
import { calculatePopoverPosition } from '../calculatePopoverPosition';
// Popover is 300×254 with a 10px offset; these are the edges the placement flips against.
const setViewport = (width: number, height: number): void => {
Object.defineProperty(window, 'innerWidth', {
value: width,
configurable: true,
});
Object.defineProperty(window, 'innerHeight', {
value: height,
configurable: true,
});
};
describe('calculatePopoverPosition', () => {
beforeEach(() => setViewport(1920, 1080));
it('anchors to the right of the click when there is room', () => {
expect(calculatePopoverPosition({ x: 100, y: 100 })).toStrictEqual({
left: 110,
top: 90,
placement: 'right',
});
});
it('flips to the left near the right edge', () => {
const { left, placement } = calculatePopoverPosition({ x: 1900, y: 100 });
expect(placement).toBe('left');
expect(left).toBe(1900 - 300 + 10);
});
it('clamps back in when the left flip would overflow a narrow viewport', () => {
setViewport(320, 1080);
// Right flip fires (60 + 300 > 320) but its left (50 - 300 + 10) is off-screen, so it clamps.
expect(calculatePopoverPosition({ x: 50, y: 100 })).toMatchObject({
left: 10,
placement: 'right',
});
});
it('drops below the click near the top edge', () => {
expect(calculatePopoverPosition({ x: 100, y: 2 })).toMatchObject({
top: 10,
placement: 'bottomRight',
});
});
it('lifts above the click near the bottom edge', () => {
const { top, placement } = calculatePopoverPosition({ x: 100, y: 1070 });
expect(placement).toBe('topRight');
expect(top).toBe(1080 - 254 - 10);
});
});

View File

@@ -0,0 +1,46 @@
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
// Kept in sync with the popover's `overlayStyle` in `ContextMenu` (width 300, maxHeight 254).
const POPOVER_WIDTH = 300;
const POPOVER_HEIGHT = 254;
const OFFSET = 10;
/**
* Places the drill-down popover next to the clicked point, flipping/clamping so it stays within the
* viewport. Anchors to the right by default and mirrors to the left (and top/bottom) near an edge.
*/
export function calculatePopoverPosition({
x,
y,
}: Coordinates): PopoverPosition {
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
let left = x + OFFSET;
let top = y - OFFSET;
let placement: PopoverPosition['placement'] = 'right';
if (left + POPOVER_WIDTH > windowWidth) {
left = x - POPOVER_WIDTH + OFFSET;
placement = 'left';
}
if (left < 0) {
left = OFFSET;
placement = 'right';
}
if (top < 0) {
top = OFFSET;
placement = placement === 'right' ? 'bottomRight' : 'bottomLeft';
}
if (top + POPOVER_HEIGHT > windowHeight) {
top = windowHeight - POPOVER_HEIGHT - OFFSET;
placement = placement === 'right' ? 'topRight' : 'topLeft';
}
return { left, top, placement };
}

View File

@@ -30,7 +30,9 @@ function PanelsAndSectionsLayout({
// Single View-modal host for the whole dashboard, driven by the URL
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
// subscriptions, and the expanded panel is looked up by id from the map.
// subscriptions, and the expanded panel is looked up by id from the map. A
// drilldown refinement rides in the URL (`compositeQuery`/`graphType`) and is
// hydrated inside the modal, so the host just hands it the saved panel.
const { expandedPanelId, closeView } = useViewPanel();
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;

View File

@@ -20,11 +20,12 @@ import {
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
export const variablesUrlParser = parseAsJson<
Record<string, SelectedVariableValue>
>((v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {

View File

@@ -38,22 +38,14 @@ function DashboardContainer({
user.role,
);
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
useEffect(() => {
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
});
}, [
dashboard.id,
dashboard.locked,
editDashboardPermission,
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
setEditContext,
]);
});
// Resolve the variable selection into the V5 query payload and publish it to
// the store, so each panel's query substitutes the bar's selected values.

View File

@@ -24,11 +24,20 @@ export const createEditContextSlice: StateCreator<
[['zustand/persist', unknown]],
[],
EditContextSlice
> = (set) => ({
> = (set, get) => ({
dashboardId: '',
isEditable: false,
refetch: (): void => undefined,
// Idempotent (no-op when unchanged) so it's safe to call during render.
setEditContext: (ctx): void => {
const { dashboardId, isEditable, refetch } = get();
if (
dashboardId === ctx.dashboardId &&
isEditable === ctx.isEditable &&
refetch === ctx.refetch
) {
return;
}
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,

View File

@@ -130,27 +130,6 @@
justify-content: flex-end;
}
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
height: 28px;
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: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
.details {
margin-top: 12px;
display: flex;

View File

@@ -1,4 +1,4 @@
import { Badge } from '@signozhq/ui/badge';
import TagBadge from 'components/TagBadge/TagBadge';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
@@ -105,14 +105,10 @@ function DashboardRow({
{tags.length > 0 && (
<div className={styles.tags}>
{tags.slice(0, 3).map((tag) => (
<Badge className={styles.tag} key={tag}>
{tag}
</Badge>
<TagBadge key={tag}>{tag}</TagBadge>
))}
{tags.length > 3 && (
<Badge className={styles.tag} key={tags[3]}>
+ <Typography.Text> {tags.length - 3} </Typography.Text>
</Badge>
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
)}
</div>
)}