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
115 changed files with 3685 additions and 2522 deletions

View File

@@ -4251,8 +4251,6 @@ components:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- clusterName
- clusterCPU
@@ -4261,7 +4259,6 @@ components:
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesClusters:
@@ -4317,8 +4314,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- daemonSetName
- daemonSetCPU
@@ -4330,7 +4325,6 @@ components:
- desiredNodes
- currentNodes
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDaemonSets:
@@ -4386,8 +4380,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- deploymentName
- deploymentCPU
@@ -4399,7 +4391,6 @@ components:
- desiredPods
- availablePods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDeployments:
@@ -4532,8 +4523,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
successfulPods:
type: integer
required:
@@ -4549,7 +4538,6 @@ components:
- failedPods
- successfulPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesJobs:
@@ -4640,14 +4628,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- namespaceName
- namespaceCPU
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesNamespaces:
@@ -4713,14 +4698,11 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
@@ -4766,64 +4748,6 @@ components:
- failed
- unknown
type: object
InframonitoringtypesPodCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
evicted:
type: integer
failed:
type: integer
imagePullBackOff:
type: integer
nodeAffinity:
type: integer
nodeLost:
type: integer
oomKilled:
type: integer
pending:
type: integer
running:
type: integer
shutdown:
type: integer
unexpectedAdmissionError:
type: integer
unknown:
type: integer
required:
- pending
- running
- failed
- unknown
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- evicted
- nodeAffinity
- nodeLost
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -4854,8 +4778,6 @@ components:
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
podMemory:
format: double
type: number
@@ -4867,11 +4789,6 @@ components:
type: number
podPhase:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podRestarts:
format: int64
type: integer
podStatus:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
podUID:
type: string
required:
@@ -4884,34 +4801,9 @@ components:
- podMemoryLimit
- podPhase
- podCountsByPhase
- podStatus
- podCountsByStatus
- podRestarts
- podAge
- meta
type: object
InframonitoringtypesPodStatus:
enum:
- pending
- running
- failed
- unknown
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- evicted
- nodeaffinity
- nodelost
- shutdown
- unexpectedadmissionerror
- no_data
type: string
InframonitoringtypesPods:
properties:
endTimeBeforeRetention:
@@ -5210,8 +5102,6 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
statefulSetCPU:
format: double
type: number
@@ -5243,7 +5133,6 @@ components:
- desiredPods
- currentPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesStatefulSets:

View File

@@ -5693,81 +5693,6 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
unknown: number;
}
export interface InframonitoringtypesPodCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
evicted: number;
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
nodeAffinity: number;
/**
* @type integer
*/
nodeLost: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
shutdown: number;
/**
* @type integer
*/
unexpectedAdmissionError: number;
/**
* @type integer
*/
unknown: number;
}
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
@@ -5799,7 +5724,6 @@ export interface InframonitoringtypesClusterRecordDTO {
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export enum InframonitoringtypesResponseTypeDTO {
@@ -5903,7 +5827,6 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDaemonSetsDTO {
@@ -5981,7 +5904,6 @@ export interface InframonitoringtypesDeploymentRecordDTO {
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDeploymentsDTO {
@@ -6148,7 +6070,6 @@ export interface InframonitoringtypesJobRecordDTO {
*/
meta: InframonitoringtypesJobRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type integer
*/
@@ -6202,7 +6123,6 @@ export interface InframonitoringtypesNamespaceRecordDTO {
*/
namespaceName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNamespacesDTO {
@@ -6269,7 +6189,6 @@ export interface InframonitoringtypesNodeRecordDTO {
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNodesDTO {
@@ -6307,27 +6226,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6354,7 +6252,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -6371,12 +6268,6 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podMemoryRequest: number;
podPhase: InframonitoringtypesPodPhaseDTO;
/**
* @type integer
* @format int64
*/
podRestarts: number;
podStatus: InframonitoringtypesPodStatusDTO;
/**
* @type string
*/
@@ -6694,7 +6585,6 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
*/
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double

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>
)}

View File

@@ -117,16 +117,8 @@ var podsSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{
"k8s.pod.phase",
"k8s.container.restarts", // pod restart count (default-on)
},
OptionalMetrics: []string{
// kubectl-style pod display status (default-off in the receiver).
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -157,10 +149,6 @@ var nodesSpec = checkSpec{
// By default, only k8s.node.condition_ready is enabled. (Check https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/4f9a578b210a6dcb9f9bf47942f27208b5765298/receiver/k8sclusterreceiver/metadata.yaml#L802)
"k8s.pod.phase", // pod counts per node by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -194,10 +182,6 @@ var deploymentsSpec = checkSpec{
"k8s.deployment.desired",
"k8s.deployment.available",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -236,10 +220,6 @@ var daemonsetsSpec = checkSpec{
"k8s.daemonset.desired_scheduled_nodes",
"k8s.daemonset.current_scheduled_nodes",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -278,10 +258,6 @@ var statefulsetsSpec = checkSpec{
"k8s.statefulset.desired_pods",
"k8s.statefulset.current_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -322,10 +298,6 @@ var jobsSpec = checkSpec{
"k8s.job.failed_pods",
"k8s.job.successful_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -352,12 +324,8 @@ var namespacesSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -392,10 +360,6 @@ var clustersSpec = checkSpec{
"k8s.node.condition_ready", // node counts by readiness
"k8s.pod.phase", // pod counts per cluster by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{

View File

@@ -20,7 +20,6 @@ func buildClusterRecords(
metadataMap map[string]map[string]string,
nodeConditionCountsMap map[string]nodeConditionCounts,
podPhaseCountsMap map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.ClusterRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -70,10 +69,6 @@ func buildClusterRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -27,8 +27,6 @@ var clustersTableMetricNamesList = []string{
"k8s.node.allocatable_memory",
"k8s.node.condition_ready", //TODO(nikhilmantri0902): should these metrics be used to count groups k8s.node.condition_ready and k8s.pod.phase
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
}
var clusterAttrKeysForMetadata = []string{

View File

@@ -19,7 +19,6 @@ func buildDaemonSetRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.DaemonSetRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -78,10 +77,6 @@ func buildDaemonSetRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,8 +23,6 @@ var daemonSetNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var daemonSetsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

View File

@@ -19,7 +19,6 @@ func buildDeploymentRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.DeploymentRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -78,10 +77,6 @@ func buildDeploymentRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,8 +23,6 @@ var deploymentNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var deploymentsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

View File

@@ -440,30 +440,6 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
return whereClause.WhereClause, nil
}
// mergeQueryWarnings combines any number of query warnings. It is nil-safe and
// skips nil entries. The first non-nil warning becomes the primary; each
// subsequent one contributes its message (as an additional warning) and its
// own additional warnings. Returns nil when all inputs are nil.
func mergeQueryWarnings(warnings ...*qbtypes.QueryWarnData) *qbtypes.QueryWarnData {
var merged *qbtypes.QueryWarnData
for _, w := range warnings {
if w == nil {
continue
}
if merged == nil {
// Copy so we don't mutate the caller's warning.
primary := *w
merged = &primary
continue
}
if w.Message != "" {
merged.Warnings = append(merged.Warnings, qbtypes.QueryWarnDataAdditional{Message: w.Message})
}
merged.Warnings = append(merged.Warnings, w.Warnings...)
}
return merged
}
// NOTE: this method is not specific to infra monitoring — it queries attributes_metadata generically.
// Consider moving to telemetryMetaStore when a second use case emerges.
//

View File

@@ -26,60 +26,6 @@ type podPhaseCounts struct {
Unknown int
}
// podStatusCounts holds per-group pod counts bucketed by latest kubectl-style
// display status in window. Mirrors inframonitoringtypes.PodCountsByStatus.
type podStatusCounts struct {
// Phase fallback.
Pending int
Running int
Failed int
Unknown int
// Container-level reasons.
CrashLoopBackOff int
ImagePullBackOff int
ErrImagePull int
CreateContainerConfigError int
ContainerCreating int
OOMKilled int
Completed int
Error int
ContainerCannotRun int
// Pod-level reasons.
Evicted int
NodeAffinity int
NodeLost int
Shutdown int
UnexpectedAdmissionError int
}
// podStatusCountsToResponse copies the internal per-group status counts into the
// public response struct. Shared by every entity that surfaces pod status
// counts (pods, nodes, namespaces, clusters, workloads).
func podStatusCountsToResponse(podStatuses podStatusCounts) inframonitoringtypes.PodCountsByStatus {
return inframonitoringtypes.PodCountsByStatus{
Pending: podStatuses.Pending,
Running: podStatuses.Running,
Failed: podStatuses.Failed,
Unknown: podStatuses.Unknown,
CrashLoopBackOff: podStatuses.CrashLoopBackOff,
ImagePullBackOff: podStatuses.ImagePullBackOff,
ErrImagePull: podStatuses.ErrImagePull,
CreateContainerConfigError: podStatuses.CreateContainerConfigError,
ContainerCreating: podStatuses.ContainerCreating,
OOMKilled: podStatuses.OOMKilled,
Completed: podStatuses.Completed,
Error: podStatuses.Error,
ContainerCannotRun: podStatuses.ContainerCannotRun,
Evicted: podStatuses.Evicted,
NodeAffinity: podStatuses.NodeAffinity,
NodeLost: podStatuses.NodeLost,
Shutdown: podStatuses.Shutdown,
UnexpectedAdmissionError: podStatuses.UnexpectedAdmissionError,
}
}
// nodeConditionCounts holds per-group node counts bucketed by latest condition_ready in window.
type nodeConditionCounts struct {
Ready int

View File

@@ -19,7 +19,6 @@ func buildJobRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.JobRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -86,10 +85,6 @@ func buildJobRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,8 +23,6 @@ var jobNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var jobsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

View File

@@ -17,7 +17,6 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/sync/errgroup"
)
type module struct {
@@ -214,37 +213,23 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newListHostsQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Compute per-group active/inactive host counts.
// When host.name is in groupBy, each row = one host, so counts are derived
// directly from activeHostsMap in buildHostRecords (no extra query needed).
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
// using the same filter expression as the main query (including user filters + page groups IN clause).
hostCounts := make(map[string]groupHostStatusCounts)
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.HostNameAttrKey)
var (
queryResp *qbtypes.QueryRangeResponse
hostCounts = make(map[string]groupHostStatusCounts)
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
if !isHostNameInGroupBy {
g.Go(func() error {
var err error
hostCounts, err = m.getPerGroupHostStatusCounts(gCtx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, orgID, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
if err != nil {
return nil, err
}
}
resp.Records = buildHostRecords(isHostNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap, hostCounts)
@@ -314,45 +299,19 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
isPodUIDInGroupBy := isKeyInGroupByAttrs(req.GroupBy, podUIDAttrKey)
resp.Records = buildPodRecords(isPodUIDInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, statusCounts, restartCounts, req.End)
resp.Warning = mergeQueryWarnings(queryResp.Warning, statusWarning)
resp.Records = buildPodRecords(isPodUIDInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, req.End)
resp.Warning = queryResp.Warning
return resp, nil
}
@@ -418,45 +377,24 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCounts map[string]nodeConditionCounts
podPhaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podPhaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.NodeNameAttrKey)
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts, podStatusCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}
@@ -522,38 +460,18 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
resp.Records = buildNamespaceRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildNamespaceRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}
@@ -619,46 +537,25 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
// with a custom groupBy, they aggregate across clusters in that group.
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
podPhaseCountsMap map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podPhaseCountsMap, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap, podStatusCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
// with a custom groupBy, they aggregate across clusters in that group.
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap)
resp.Warning = queryResp.Warning
return resp, nil
}
@@ -808,38 +705,18 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
resp.Records = buildDeploymentRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildDeploymentRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}
@@ -911,40 +788,20 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
// so default-groupBy gives per-statefulset phase counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
resp.Records = buildStatefulSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
// so default-groupBy gives per-statefulset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildStatefulSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}
@@ -1016,40 +873,20 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
// gives per-job phase counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
resp.Records = buildJobRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
// gives per-job phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildJobRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}
@@ -1121,40 +958,20 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
// so default-groupBy gives per-daemonset phase counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
phaseCounts map[string]podPhaseCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
g.Go(func() error {
var err error
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
resp.Records = buildDaemonSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts, podStatusCounts)
resp.Warning = mergeQueryWarnings(queryResp.Warning, podStatusWarning)
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
// so default-groupBy gives per-daemonset phase counts automatically.
phaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildDaemonSetRecords(queryResp, pageGroups, req.GroupBy, metadataMap, phaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}

View File

@@ -18,7 +18,6 @@ func buildNamespaceRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.NamespaceRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -53,10 +52,6 @@ func buildNamespaceRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,8 +23,6 @@ var namespacesTableMetricNamesList = []string{
"k8s.pod.cpu.usage",
"k8s.pod.memory.working_set",
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
}
var namespaceAttrKeysForMetadata = []string{

View File

@@ -26,7 +26,6 @@ func buildNodeRecords(
metadataMap map[string]map[string]string,
nodeConditionCounts map[string]nodeConditionCounts,
podPhaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.NodeRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -87,10 +86,6 @@ func buildNodeRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -26,8 +26,6 @@ var nodesTableMetricNamesList = []string{
"k8s.node.allocatable_memory",
"k8s.node.condition_ready",
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
}
var nodeAttrKeysForMetadata = []string{

View File

@@ -26,8 +26,6 @@ func buildPodRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
statusCounts map[string]podStatusCounts,
restartCounts map[string]int64,
reqEnd int64,
) []inframonitoringtypes.PodRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -40,8 +38,6 @@ func buildPodRecords(
record := inframonitoringtypes.PodRecord{ // initialize with default values
PodUID: podUID,
PodPhase: inframonitoringtypes.PodPhaseNoData,
PodStatus: inframonitoringtypes.PodStatusNoData,
PodRestarts: -1,
PodCPU: -1,
PodCPURequest: -1,
PodCPULimit: -1,
@@ -99,57 +95,6 @@ func buildPodRecords(
}
}
if statusCountsForGroup, ok := statusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(statusCountsForGroup)
// In list mode each group is one pod; the count==1 bucket identifies the status.
if isPodUIDInGroupBy {
switch {
case statusCountsForGroup.Pending == 1:
record.PodStatus = inframonitoringtypes.PodStatusPending
case statusCountsForGroup.Running == 1:
record.PodStatus = inframonitoringtypes.PodStatusRunning
case statusCountsForGroup.Failed == 1:
record.PodStatus = inframonitoringtypes.PodStatusFailed
case statusCountsForGroup.Unknown == 1:
record.PodStatus = inframonitoringtypes.PodStatusUnknown
case statusCountsForGroup.CrashLoopBackOff == 1:
record.PodStatus = inframonitoringtypes.PodStatusCrashLoopBackOff
case statusCountsForGroup.ImagePullBackOff == 1:
record.PodStatus = inframonitoringtypes.PodStatusImagePullBackOff
case statusCountsForGroup.ErrImagePull == 1:
record.PodStatus = inframonitoringtypes.PodStatusErrImagePull
case statusCountsForGroup.CreateContainerConfigError == 1:
record.PodStatus = inframonitoringtypes.PodStatusCreateContainerConfigError
case statusCountsForGroup.ContainerCreating == 1:
record.PodStatus = inframonitoringtypes.PodStatusContainerCreating
case statusCountsForGroup.OOMKilled == 1:
record.PodStatus = inframonitoringtypes.PodStatusOOMKilled
case statusCountsForGroup.Completed == 1:
record.PodStatus = inframonitoringtypes.PodStatusCompleted
case statusCountsForGroup.Error == 1:
record.PodStatus = inframonitoringtypes.PodStatusError
case statusCountsForGroup.ContainerCannotRun == 1:
record.PodStatus = inframonitoringtypes.PodStatusContainerCannotRun
case statusCountsForGroup.Evicted == 1:
record.PodStatus = inframonitoringtypes.PodStatusEvicted
case statusCountsForGroup.NodeAffinity == 1:
record.PodStatus = inframonitoringtypes.PodStatusNodeAffinity
case statusCountsForGroup.NodeLost == 1:
record.PodStatus = inframonitoringtypes.PodStatusNodeLost
case statusCountsForGroup.Shutdown == 1:
record.PodStatus = inframonitoringtypes.PodStatusShutdown
case statusCountsForGroup.UnexpectedAdmissionError == 1:
record.PodStatus = inframonitoringtypes.PodStatusUnexpectedAdmissionError
}
}
}
// Restart count: pod's own sum (list mode) or group total (grouped mode).
if restartCountForGroup, ok := restartCounts[compositeKey]; ok {
record.PodRestarts = restartCountForGroup
}
if attrs, ok := metadataMap[compositeKey]; ok {
// podAge only makes sense when pod uid is in groupBy. Otherwise the
// group can contain multiple pods with different start times.
@@ -389,519 +334,3 @@ func (m *module) getPerGroupPodPhaseCounts(
}
return result, nil
}
// getPerGroupPodStatusCountsWithReqMetricChecks gates getPerGroupPodStatusCounts
// on the required metrics being present. If any of podStatusMetricNamesList has
// never been reported, it skips the query and returns a warning instead (the
// status query would otherwise silently degrade to bare phase). Otherwise it
// runs the query. The returned counts map is empty (never nil) when gated off.
func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
ctx context.Context,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
present, err := m.getMetricsExistence(ctx, podStatusMetricNamesList)
if err != nil {
return nil, nil, err
}
var missing []string
for _, name := range podStatusMetricNamesList {
if !present[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
warning := &qbtypes.QueryWarnData{
Message: fmt.Sprintf(
"Pod status could not be computed: required metric(s) not found: %s. "+
"Enable the optional k8s.pod.status_reason and k8s.container.status.reason "+
"metrics in the k8s_cluster receiver to see pod statuses.",
strings.Join(missing, ", "),
),
Url: docLinkK8sClusterReceiver,
}
return map[string]podStatusCounts{}, warning, nil
}
counts, err := m.getPerGroupPodStatusCounts(ctx, start, end, filter, groupBy, pageGroups)
if err != nil {
return nil, nil, err
}
return counts, nil, nil
}
// getPerGroupPodStatusCounts computes per-group pod counts bucketed by each
// pod's latest kubectl-style display status in the requested window. Caller
// must ensure the required metrics exist (getPerGroupPodStatusCountsWithReqMetricChecks).
// Pipeline (mirrors getPerGroupPodPhaseCounts, more CTEs):
//
// phase_fps / phase_per_pod: latest k8s.pod.phase per pod (+ groupBy cols).
// pod_reason_fps / pod_reason_per_pod: latest k8s.pod.status_reason per pod.
// container_reason_fps / container_reason_per_pod:
// highest-priority active k8s.container.status.reason per pod.
// pod_status: display status per pod (container > pod reason > phase).
// countPodsPerStatus: per-group uniqExactIf into the fixed status buckets.
//
// Groups absent from the result map have implicit zero counts (caller default).
func (m *module) getPerGroupPodStatusCounts(
ctx context.Context,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]podStatusCounts, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]podStatusCounts{}, nil
}
// Merge user filter with page-groups IN clauses.
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
// Build the merged filter clause once; it's identical across the three fps
// CTEs, and buildFilterClause hits the metadata store + parses the
// expression, so we don't want to repeat it per CTE. AddWhereClause only
// reads the clause, so the same instance is safe to attach to each builder.
var (
filterClause *sqlbuilder.WhereClause
err error
)
if mergedFilterExpr != "" {
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
}
// ----- phase_fps (carries groupBy cols) -----
phaseFps := sqlbuilder.NewSelectBuilder()
phaseFpsCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", phaseFps.Var(podUIDAttrKey)),
}
for _, key := range groupBy {
phaseFpsCols = append(phaseFpsCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", phaseFps.Var(key.Name), quoteIdentifier(key.Name)),
)
}
phaseFps.Select(phaseFpsCols...)
phaseFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
phaseFps.Where(
phaseFps.E("metric_name", podPhaseMetricName),
phaseFps.GE("unix_milli", tsAdjustedStart),
phaseFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
phaseFps.AddWhereClause(filterClause)
}
phaseFpsGroupBy := []string{"fingerprint", "pod_uid"}
for _, key := range groupBy {
phaseFpsGroupBy = append(phaseFpsGroupBy, quoteIdentifier(key.Name))
}
phaseFps.GroupBy(phaseFpsGroupBy...)
phaseFpsSQL, phaseFpsArgs := phaseFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- phase_per_pod -----
phasePerPod := sqlbuilder.NewSelectBuilder()
phasePerPodCols := []string{"fps.pod_uid AS pod_uid"}
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
phasePerPodCols = append(phasePerPodCols, fmt.Sprintf("argMax(fps.%s, samples.unix_milli) AS %s", col, col))
}
phasePerPodCols = append(phasePerPodCols, fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS phase_value", valueCol))
phasePerPod.Select(phasePerPodCols...)
phasePerPod.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN phase_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
phasePerPod.Where(
phasePerPod.E("samples.metric_name", podPhaseMetricName),
phasePerPod.GE("samples.unix_milli", samplesStartMs),
phasePerPod.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
phasePerPod.GroupBy("pod_uid")
phasePerPodSQL, phasePerPodArgs := phasePerPod.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- pod_reason_fps -----
podReasonFps := sqlbuilder.NewSelectBuilder()
podReasonFps.Select(
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", podReasonFps.Var(podUIDAttrKey)),
)
podReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
podReasonFps.Where(
podReasonFps.E("metric_name", podStatusReasonMetricName),
podReasonFps.GE("unix_milli", tsAdjustedStart),
podReasonFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
podReasonFps.AddWhereClause(filterClause)
}
podReasonFps.GroupBy("fingerprint", "pod_uid")
podReasonFpsSQL, podReasonFpsArgs := podReasonFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- pod_reason_per_pod -----
podReasonPerPod := sqlbuilder.NewSelectBuilder()
podReasonPerPod.Select(
"fps.pod_uid AS pod_uid",
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS reason_value", valueCol),
)
podReasonPerPod.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN pod_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
podReasonPerPod.Where(
podReasonPerPod.E("samples.metric_name", podStatusReasonMetricName),
podReasonPerPod.GE("samples.unix_milli", samplesStartMs),
podReasonPerPod.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
podReasonPerPod.GroupBy("pod_uid")
podReasonPerPodSQL, podReasonPerPodArgs := podReasonPerPod.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- container_reason_fps -----
containerReasonFps := sqlbuilder.NewSelectBuilder()
containerReasonFps.Select(
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", containerReasonFps.Var(podUIDAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", containerReasonFps.Var(containerNameAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS reason", containerReasonFps.Var(containerStatusReasonAttrKey)),
)
containerReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
containerReasonFps.Where(
containerReasonFps.E("metric_name", containerStatusReasonMetricName),
containerReasonFps.GE("unix_milli", tsAdjustedStart),
containerReasonFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
containerReasonFps.AddWhereClause(filterClause)
}
containerReasonFps.GroupBy("fingerprint", "pod_uid", "container_name", "reason")
containerReasonFpsSQL, containerReasonFpsArgs := containerReasonFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- container_reason_per_pod -----
// Inner: latest value per (pod, container, reason) -> kills stale
// fingerprints from old container incarnations; keep only active (=1).
// Outer: highest-priority active reason per pod (waiting > terminated).
priorityCase := "CASE fps.reason " +
"WHEN 'CrashLoopBackOff' THEN 8 " +
"WHEN 'ImagePullBackOff' THEN 7 " +
"WHEN 'ErrImagePull' THEN 6 " +
"WHEN 'CreateContainerConfigError' THEN 5 " +
"WHEN 'ContainerCreating' THEN 4 " +
"WHEN 'OOMKilled' THEN 3 " +
"WHEN 'Error' THEN 2 " +
"WHEN 'ContainerCannotRun' THEN 1 " +
"ELSE 0 END"
containerInner := sqlbuilder.NewSelectBuilder()
containerInner.Select(
"fps.pod_uid AS pod_uid",
"fps.container_name AS container_name",
"fps.reason AS reason",
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS is_active", valueCol),
priorityCase+" AS priority",
)
containerInner.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN container_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
containerInner.Where(
containerInner.E("samples.metric_name", containerStatusReasonMetricName),
containerInner.GE("samples.unix_milli", samplesStartMs),
containerInner.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
containerInner.GroupBy("fps.pod_uid", "fps.container_name", "fps.reason")
containerInner.Having("is_active = 1")
containerInnerSQL, containerInnerArgs := containerInner.BuildWithFlavor(sqlbuilder.ClickHouse)
containerPerPodSQL := fmt.Sprintf(
"SELECT pod_uid, argMax(reason, priority) AS active_reason FROM (%s) GROUP BY pod_uid",
containerInnerSQL,
)
// ----- pod_status (display status per pod) -----
// container reason > pod-level reason > phase fallback. Numeric literals
// match the k8s.pod.status_reason and k8s.pod.phase metric encodings.
displayStatusExpr := "multiIf(" +
"cr.active_reason != '', cr.active_reason, " +
"pr.reason_value = 1, 'Evicted', " +
"pr.reason_value = 2, 'NodeAffinity', " +
"pr.reason_value = 3, 'NodeLost', " +
"pr.reason_value = 4, 'Shutdown', " +
"pr.reason_value = 5, 'UnexpectedAdmissionError', " +
"pp.phase_value = 1, 'Pending', " +
"pp.phase_value = 2, 'Running', " +
"pp.phase_value = 3, 'Completed', " +
"pp.phase_value = 4, 'Failed', " +
"pp.phase_value = 5, 'Unknown', " +
"'Unknown')"
podStatusSelectCols := []string{"pp.pod_uid AS pod_uid"}
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
podStatusSelectCols = append(podStatusSelectCols, fmt.Sprintf("pp.%s AS %s", col, col))
}
podStatusSelectCols = append(podStatusSelectCols, displayStatusExpr+" AS display_status")
podStatusSQL := fmt.Sprintf(
"SELECT %s FROM phase_per_pod AS pp "+
"LEFT JOIN pod_reason_per_pod AS pr ON pp.pod_uid = pr.pod_uid "+
"LEFT JOIN container_reason_per_pod AS cr ON pp.pod_uid = cr.pod_uid",
strings.Join(podStatusSelectCols, ", "),
)
// ----- countPodsPerStatus (outer SELECT) -----
// Fixed status order; MUST match the podStatusCounts assignment in the
// scan loop below.
statusCountCols := []string{
"uniqExactIf(pod_uid, display_status = 'Pending') AS pending_count",
"uniqExactIf(pod_uid, display_status = 'Running') AS running_count",
"uniqExactIf(pod_uid, display_status = 'Failed') AS failed_count",
"uniqExactIf(pod_uid, display_status = 'Unknown') AS unknown_count",
"uniqExactIf(pod_uid, display_status = 'CrashLoopBackOff') AS crash_loop_back_off_count",
"uniqExactIf(pod_uid, display_status = 'ImagePullBackOff') AS image_pull_back_off_count",
"uniqExactIf(pod_uid, display_status = 'ErrImagePull') AS err_image_pull_count",
"uniqExactIf(pod_uid, display_status = 'CreateContainerConfigError') AS create_container_config_error_count",
"uniqExactIf(pod_uid, display_status = 'ContainerCreating') AS container_creating_count",
"uniqExactIf(pod_uid, display_status = 'OOMKilled') AS oom_killed_count",
"uniqExactIf(pod_uid, display_status = 'Completed') AS completed_count",
"uniqExactIf(pod_uid, display_status = 'Error') AS error_count",
"uniqExactIf(pod_uid, display_status = 'ContainerCannotRun') AS container_cannot_run_count",
"uniqExactIf(pod_uid, display_status = 'Evicted') AS evicted_count",
"uniqExactIf(pod_uid, display_status = 'NodeAffinity') AS node_affinity_count",
"uniqExactIf(pod_uid, display_status = 'NodeLost') AS node_lost_count",
"uniqExactIf(pod_uid, display_status = 'Shutdown') AS shutdown_count",
"uniqExactIf(pod_uid, display_status = 'UnexpectedAdmissionError') AS unexpected_admission_error_count",
}
countSelectCols := make([]string, 0, len(groupBy)+len(statusCountCols))
countGroupBy := make([]string, 0, len(groupBy))
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
countSelectCols = append(countSelectCols, col)
countGroupBy = append(countGroupBy, col)
}
countSelectCols = append(countSelectCols, statusCountCols...)
countSQL := fmt.Sprintf(
"SELECT %s FROM pod_status GROUP BY %s",
strings.Join(countSelectCols, ", "),
strings.Join(countGroupBy, ", "),
)
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
cteFragments := []string{
fmt.Sprintf("phase_fps AS (%s)", phaseFpsSQL),
fmt.Sprintf("phase_per_pod AS (%s)", phasePerPodSQL),
fmt.Sprintf("pod_reason_fps AS (%s)", podReasonFpsSQL),
fmt.Sprintf("pod_reason_per_pod AS (%s)", podReasonPerPodSQL),
fmt.Sprintf("container_reason_fps AS (%s)", containerReasonFpsSQL),
fmt.Sprintf("container_reason_per_pod AS (%s)", containerPerPodSQL),
fmt.Sprintf("pod_status AS (%s)", podStatusSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + countSQL
finalArgs := querybuilder.PrependArgs([][]any{
phaseFpsArgs, phasePerPodArgs,
podReasonFpsArgs, podReasonPerPodArgs,
containerReasonFpsArgs, containerInnerArgs,
}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]podStatusCounts)
for rows.Next() {
groupVals := make([]string, len(groupBy))
counts := make([]uint64, len(statusCountCols))
scanPtrs := make([]any, 0, len(groupBy)+len(statusCountCols))
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
for i := range counts {
scanPtrs = append(scanPtrs, &counts[i])
}
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
result[compositeKeyFromList(groupVals)] = podStatusCounts{
Pending: int(counts[0]),
Running: int(counts[1]),
Failed: int(counts[2]),
Unknown: int(counts[3]),
CrashLoopBackOff: int(counts[4]),
ImagePullBackOff: int(counts[5]),
ErrImagePull: int(counts[6]),
CreateContainerConfigError: int(counts[7]),
ContainerCreating: int(counts[8]),
OOMKilled: int(counts[9]),
Completed: int(counts[10]),
Error: int(counts[11]),
ContainerCannotRun: int(counts[12]),
Evicted: int(counts[13]),
NodeAffinity: int(counts[14]),
NodeLost: int(counts[15]),
Shutdown: int(counts[16]),
UnexpectedAdmissionError: int(counts[17]),
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// getPerGroupPodRestartCounts computes the absolute pod restart count per group
// from k8s.container.restarts (default-enabled, so no existence gate). In list
// mode (groupBy=pod_uid) each group is one pod -> its restart count; in grouped
// mode it's the summed restarts across all pods in the group. Mirrors DD:
// argMax(value, unix_milli) per (pod, container) takes the current cumulative
// count from the latest incarnation (handling fingerprint staleness/pruning),
// then sum.
//
// restart_fps: fp ↔ (pod_uid, container_name, groupBy cols) from time_series.
// container_restarts: INNER JOIN samples, latest restartCount per (pod, container).
// (outer): per-group sum(restart_count).
//
// Groups absent from the result map have no data (caller default).
func (m *module) getPerGroupPodRestartCounts(
ctx context.Context,
start, end int64,
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
) (map[string]int64, error) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]int64{}, nil
}
// Merge user filter with page-groups IN clauses.
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
var (
filterClause *sqlbuilder.WhereClause
err error
)
if mergedFilterExpr != "" {
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
return nil, err
}
}
// ----- restart_fps (carries groupBy cols) -----
restartFps := sqlbuilder.NewSelectBuilder()
restartFpsCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", restartFps.Var(podUIDAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", restartFps.Var(containerNameAttrKey)),
}
for _, key := range groupBy {
restartFpsCols = append(restartFpsCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", restartFps.Var(key.Name), quoteIdentifier(key.Name)),
)
}
restartFps.Select(restartFpsCols...)
restartFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
restartFps.Where(
restartFps.E("metric_name", containerRestartsMetricName),
restartFps.GE("unix_milli", tsAdjustedStart),
restartFps.LE("unix_milli", flooredEndMs),
)
if filterClause != nil {
restartFps.AddWhereClause(filterClause)
}
restartFpsGroupBy := []string{"fingerprint", "pod_uid", "container_name"}
for _, key := range groupBy {
restartFpsGroupBy = append(restartFpsGroupBy, quoteIdentifier(key.Name))
}
restartFps.GroupBy(restartFpsGroupBy...)
restartFpsSQL, restartFpsArgs := restartFps.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- container_restarts (latest cumulative count per container) -----
containerRestarts := sqlbuilder.NewSelectBuilder()
containerRestartsCols := []string{
"fps.pod_uid AS pod_uid",
"fps.container_name AS container_name",
}
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
containerRestartsCols = append(containerRestartsCols, fmt.Sprintf("argMax(fps.%s, samples.unix_milli) AS %s", col, col))
}
containerRestartsCols = append(containerRestartsCols, fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS restart_count", valueCol))
containerRestarts.Select(containerRestartsCols...)
containerRestarts.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN restart_fps AS fps ON samples.fingerprint = fps.fingerprint",
telemetrymetrics.DBName, distributedSamplesTable,
))
containerRestarts.Where(
containerRestarts.E("samples.metric_name", containerRestartsMetricName),
containerRestarts.GE("samples.unix_milli", samplesStartMs),
containerRestarts.L("samples.unix_milli", flooredEndMs),
"fps.pod_uid != ''",
)
containerRestarts.GroupBy("fps.pod_uid", "fps.container_name")
containerRestartsSQL, containerRestartsArgs := containerRestarts.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- outer: per-group sum across containers (hence across pods) -----
sumSelectCols := make([]string, 0, len(groupBy)+1)
sumGroupBy := make([]string, 0, len(groupBy))
for _, key := range groupBy {
col := quoteIdentifier(key.Name)
sumSelectCols = append(sumSelectCols, col)
sumGroupBy = append(sumGroupBy, col)
}
sumSelectCols = append(sumSelectCols, "sum(restart_count) AS total_restarts")
sumSQL := fmt.Sprintf(
"SELECT %s FROM container_restarts GROUP BY %s",
strings.Join(sumSelectCols, ", "),
strings.Join(sumGroupBy, ", "),
)
cteFragments := []string{
fmt.Sprintf("restart_fps AS (%s)", restartFpsSQL),
fmt.Sprintf("container_restarts AS (%s)", containerRestartsSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + sumSQL
finalArgs := querybuilder.PrependArgs([][]any{restartFpsArgs, containerRestartsArgs}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]int64)
for rows.Next() {
groupVals := make([]string, len(groupBy))
var totalRestarts float64
scanPtrs := make([]any, 0, len(groupBy)+1)
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
scanPtrs = append(scanPtrs, &totalRestarts)
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
result[compositeKeyFromList(groupVals)] = int64(totalRestarts)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -8,26 +8,11 @@ import (
)
const (
podUIDAttrKey = "k8s.pod.uid"
podStartTimeAttrKey = "k8s.pod.start_time"
containerNameAttrKey = "k8s.container.name"
containerStatusReasonAttrKey = "k8s.container.status.reason"
podPhaseMetricName = "k8s.pod.phase"
podStatusReasonMetricName = "k8s.pod.status_reason"
containerStatusReasonMetricName = "k8s.container.status.reason"
containerRestartsMetricName = "k8s.container.restarts"
podUIDAttrKey = "k8s.pod.uid"
podStartTimeAttrKey = "k8s.pod.start_time"
podPhaseMetricName = "k8s.pod.phase"
)
// podStatusMetricNamesList are the metrics required to derive the kubectl-style
// pod display status. All three must be present (getMetricsExistence gate)
// before getPerGroupPodStatusCounts runs.
var podStatusMetricNamesList = []string{
podPhaseMetricName,
podStatusReasonMetricName,
containerStatusReasonMetricName,
}
var podUIDGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: podUIDAttrKey,
@@ -44,9 +29,6 @@ var podsTableMetricNamesList = []string{
"k8s.pod.memory_request_utilization",
"k8s.pod.memory_limit_utilization",
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.container.restarts",
}
var podAttrKeysForMetadata = []string{

View File

@@ -19,7 +19,6 @@ func buildStatefulSetRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.StatefulSetRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -78,10 +77,6 @@ func buildStatefulSetRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,8 +23,6 @@ var statefulSetNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var statefulSetsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

View File

@@ -27,7 +27,6 @@ type ClusterRecord struct {
ClusterMemoryAllocatable float64 `json:"clusterMemoryAllocatable" required:"true"`
NodeCountsByReadiness NodeCountsByReadiness `json:"nodeCountsByReadiness" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}

View File

@@ -17,17 +17,16 @@ type DaemonSets struct {
}
type DaemonSetRecord struct {
DaemonSetName string `json:"daemonSetName" required:"true"`
DaemonSetCPU float64 `json:"daemonSetCPU" required:"true"`
DaemonSetCPURequest float64 `json:"daemonSetCPURequest" required:"true"`
DaemonSetCPULimit float64 `json:"daemonSetCPULimit" required:"true"`
DaemonSetMemory float64 `json:"daemonSetMemory" required:"true"`
DaemonSetMemoryRequest float64 `json:"daemonSetMemoryRequest" required:"true"`
DaemonSetMemoryLimit float64 `json:"daemonSetMemoryLimit" required:"true"`
DaemonSetName string `json:"daemonSetName" required:"true"`
DaemonSetCPU float64 `json:"daemonSetCPU" required:"true"`
DaemonSetCPURequest float64 `json:"daemonSetCPURequest" required:"true"`
DaemonSetCPULimit float64 `json:"daemonSetCPULimit" required:"true"`
DaemonSetMemory float64 `json:"daemonSetMemory" required:"true"`
DaemonSetMemoryRequest float64 `json:"daemonSetMemoryRequest" required:"true"`
DaemonSetMemoryLimit float64 `json:"daemonSetMemoryLimit" required:"true"`
DesiredNodes int `json:"desiredNodes" required:"true"`
CurrentNodes int `json:"currentNodes" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}

View File

@@ -27,7 +27,6 @@ type DeploymentRecord struct {
DesiredPods int `json:"desiredPods" required:"true"`
AvailablePods int `json:"availablePods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}

View File

@@ -29,7 +29,6 @@ type JobRecord struct {
FailedPods int `json:"failedPods" required:"true"`
SuccessfulPods int `json:"successfulPods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}

View File

@@ -17,12 +17,11 @@ type Namespaces struct {
}
type NamespaceRecord struct {
NamespaceName string `json:"namespaceName" required:"true"`
NamespaceCPU float64 `json:"namespaceCPU" required:"true"`
NamespaceMemory float64 `json:"namespaceMemory" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
NamespaceName string `json:"namespaceName" required:"true"`
NamespaceCPU float64 `json:"namespaceCPU" required:"true"`
NamespaceMemory float64 `json:"namespaceMemory" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableNamespaces is the request body for the v2 namespaces list API.

View File

@@ -28,7 +28,6 @@ type NodeRecord struct {
Condition NodeCondition `json:"condition" required:"true"`
NodeCountsByReadiness NodeCountsByReadiness `json:"nodeCountsByReadiness" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
NodeCPU float64 `json:"nodeCPU" required:"true"`
NodeCPUAllocatable float64 `json:"nodeCPUAllocatable" required:"true"`
NodeMemory float64 `json:"nodeMemory" required:"true"`

Some files were not shown because too many files have changed in this diff Show More