Compare commits

...

14 Commits

Author SHA1 Message Date
Abhi Kumar
634a1d285a chore: fmt fix + panel fetch fix 2026-06-08 17:48:35 +05:30
Abhi Kumar
9eca33ed3d refactor(dashboards-v2): move panel rendering into DashboardPageV2 structure
The dashboard page now lives under pages/DashboardPageV2/DashboardContainer;
container/DashboardContainerV2 was dead. Move the panel-rendering infra into
the new structure, wire the panel to render, and delete the old container.

- git mv Panels/ (registry + renderers + config/data/sections + utils + hooks),
  queryAdapter/, and hooks/usePanelQuery into the new DashboardContainer;
  rewrite the absolute imports old -> new path.
- Panel.tsx: render via getPanelDefinition + usePanelQuery (loading and
  unknown-kind states, cursor-sync preference, drag-select); lazy-fetch gated
  on isVisible.
- Remove a stray `debugger` in getPanelDefinition and an unused import in
  toPerses.
- Fix the persesRoundTrip testdata path for the deeper dir, and align the
  fromPerses/toPerses unit tests to the backend contract (time_series/raw
  outer query kinds, per the canonical perses.json testdata).
- Delete the dead container/DashboardContainerV2.
2026-06-08 16:57:41 +05:30
Abhi Kumar
7a1d8bd6fb feat(dashboards-v2): adopt shared Pie chart in PiePanel
Wire the V2 PiePanel renderer to the shared @visx Pie chart: pass the panel id
(for per-slice hide/unhide persistence) and the resolved legend position.

Carries the shared chart changes the panel depends on — the presentational
Legend + UPlotLegend split and the reusable Pie chart (charts/Pie) — mirrored
from #11583; these reconcile when this branch rebases onto the merged PR.
2026-06-08 15:41:40 +05:30
Abhi Kumar
67ba89bcab feat(dashboards-v2): port PieChartPanel renderer
Add the V2 PieChartPanel — the first non-uPlot panel kind.

- Shared visx donut component (charts/Pie/) with PieSlice/PieChartProps,
  self-measuring draw area, leader labels, centre total, interactive legend
  and a cursor-following tooltip.
- V2 PiePanel/ (renderer + data + sections) registered in the panel registry.
- preparePieData reads the reduced value from the scalar table
  (result[].table, fallback newResult) since a pie issues a TABLE request.
- Renderer emits the typed PieClickEvent ({ label, value }).
2026-06-08 15:41:40 +05:30
Abhi Kumar
63f467fb7c feat(dashboards-v2): port HistogramPanel renderer + fix histogram requestType
Renderer reads from the perses panel spec
(DashboardtypesHistogramPanelSpecDTO) and reuses the shared utils added
with TimeSeriesPanel. Two pieces are Histogram-specific:

- HistogramPanel/data.ts (prepareHistogramData) — bins raw series values
  into a uPlot-aligned histogram. Reuses V1's bucket primitives
  (buildHistogramBuckets, mergeAlignedDataTables, ...). Mirrors V1's
  prepareHistogramPanelData with named helpers.
- HistogramPanel/config.ts — overrides x/y scales (time: false, auto:
  true) and cursor (no drag, tight focus) after buildBaseConfig, since
  histograms have no time axis. spec.visualization?.mergeAllActiveQueries
  collapses to a single merged series.

Fix: usePanelQuery now routes the resolved PANEL_TYPES through
getGraphType before passing it as graphType. Without this, HISTOGRAM was
sent as requestType 'distribution' by V5's mapPanelTypeToRequestType — a
shape prepareHistogramData can't bin. The remap (HISTOGRAM and BAR ->
TIME_SERIES) matches V1's behavior. Tests pin both remaps.

Registers signoz/HistogramPanel in Panels/index.ts.
2026-06-08 15:41:40 +05:30
Abhi Kumar
e175838678 feat(dashboards-v2): port BarPanel renderer
Reuses the shared infrastructure introduced with TimeSeriesPanel
(useTimeScale, useGroupByPerQuery, baseConfigBuilder, resolveSeriesLabel,
chartAppearanceMappings).

Bar-specific config in BarPanel/config.ts:

- spec.visualization.stackedBarChart → setBands(getInitialStackedBands(...))
  before the series loop.
- Per-series stepInterval read from
  apiResponse.payload.data.newResult.meta.stepIntervals[queryName] so bar
  widths match the actual sampling cadence per query.
- DrawStyle.Bar (no chartAppearance — the perses bar spec carries none).

Registers signoz/BarChartPanel in Panels/index.ts.
2026-06-08 15:41:40 +05:30
Abhi Kumar
69805d0214 feat(dashboards-v2): port TimeSeriesPanel renderer + shared infra
Reads from the perses panel spec (DashboardtypesTimeSeriesPanelSpecDTO) and
v5 BuilderQuery types directly — no bespoke summary types between adapter
layers.

Shared infrastructure (used by every V2 visualization panel):

- utils/baseConfigBuilder — chart scaffolding (scales, thresholds, axes,
  drag-zoom, click). onDragSelect is optional so non-time panels can opt
  out without passing a no-op.
- utils/getBuilderQueries — flattens panel.spec.queries (top-level +
  CompositeQuery envelopes) into a list of v5 BuilderQuery.
- utils/resolveSeriesLabel — V1 legend matrix on v5 types.
- utils/chartAppearanceMappings — perses ↔ uPlot enum bridges plus
  resolveDecimalPrecision / resolveSpanGaps / resolveLegendPosition.
- hooks/useTimeScale — derives X-axis clamps from the query-range
  response; isolates the data.params unknown→QueryRangeRequestV5 cast.
- hooks/useGroupByPerQuery — converts v5 GroupByKey to the V1
  BaseAutocompleteData shape the TimeSeries chart and tooltip consume.
2026-06-08 15:41:40 +05:30
Abhi Kumar
c95c8d8d53 feat(dashboards-v2): wire PanelV2 to query adapter via usePanelQuery hook
Extracts panel fetch wiring out of PanelV2 into a dedicated hook that owns
the fromPerses adapter call, global time selection, and useGetQueryRange
invocation with a composed cache key. Auto-disables the fetch when the
panel has no queries so the unknown-kind fallback never triggers a wasted
request. Adds 11 unit tests covering adapter passthrough, graphType
derivation, V5 entity version, data/error propagation, loading/fetching
combination, and cache key composition.
2026-06-08 15:41:40 +05:30
Abhi Kumar
4998edde74 feat(dashboards-v2): add perses ↔ V1 query adapter
Bridges the perses on-wire query envelope to the V1 in-memory Query shape
QueryBuilderContext consumes (and back). Lets saved V2 panels be edited and
rendered without refactoring the QB context — which is shared across
dashboards, alerts, and the explorer, so refactoring is not an option.

Architecture
- toPerses(query, graphType): V1 → DashboardtypesQueryDTO[]
  Reuses prepareQueryRangePayloadV5's existing v5 envelope conversion (and
  with it the legacy → v5 field renames). Wraps the resulting envelopes into
  the perses outer DTO with one of the 6 SigNoz plugin kinds per the
  collapse rules below. Backend invariant respected: always returns at most
  one envelope (panel.queries.length === 1).

- fromPerses(persesQueries): DashboardtypesQueryDTO[] → Query
  Per-plugin-kind dispatch with inverse field renames (name → key,
  fieldDataType → dataType, fieldContext → type; {key.name, direction} →
  {columnName, order}). Always inflates to the full
  {queryData, queryFormulas, queryTraceOperator} shape so the QB UI never
  branches on "is the builder hydrated yet."

Plugin kind coverage (all 6, both directions)
- signoz/BuilderQuery   (bare; single builder query)
- signoz/CompositeQuery (multi/mixed builder queries + formulas + trace ops)
- signoz/PromQLQuery    (bare on single; CompositeQuery on multi)
- signoz/ClickHouseSQL  (bare on single; CompositeQuery on multi)
- signoz/Formula        (only valid as composite subquery, never bare)
- signoz/TraceOperator  (only valid as composite subquery, never bare)

Semantic invariant guards
Formulas (`A + 1`) and trace operators (`A && B`) reference builder queries
by name; they cannot exist alone.
- toPerses throws on save-time violation so corrupt state can't be persisted.
- fromPerses console.warn-and-drops on read so existing/legacy/manually-
  edited dashboards still load with a clear diagnostic.

Outer envelope kind
- LIST panel → LogQuery (rows-oriented data; covers both logs and traces)
- everything else → TimeSeriesQuery
Only these two values appear anywhere in the backend testdata or code; there
is no TraceQuery kind.

Typing discipline
- All helpers signature against the local v5 types from
  types/api/v5/queryRange — no Record<string, unknown> in adapter code.
- One nominal cast per plugin-kind branch bridges the perses-generated DTO
  union to the structurally-identical local v5 union.
- v5 Step (string | number) → V1 stepInterval (number | null) explicitly
  coerces strings to null (documented in source).
- v5 QueryBuilderFormula on the wire carries `disabled` even though the
  local interface omits it — handled with `& { disabled?: boolean }`
  intersection at the helper signature.
- Defensive guards: skip when plugin.spec or subquery.spec is missing so
  malformed inputs don't crash the helpers.

Tests (70)
- toPerses unit tests (Phases 1–6): empty contract, bare BuilderQuery
  (signals + renames + filter + outer kind), CompositeQuery wrapping,
  formula/trace-op invariant throws, PromQL/ClickHouse single + multi,
  unrecognized queryType fallthrough.
- fromPerses unit tests (Phases 1–6): empty/missing/unknown plugin,
  BuilderQuery (signals + renames + filter + stepInterval string
  defensiveness), CompositeQuery distribution, top-level Formula/TraceOp
  warn-and-drop, queryType resolution for all-promql/all-clickhouse/mixed,
  malformed spec defensiveness, defensive single-DTO consumption.
- Round-trip tests cover bare BuilderQuery, multi-builder CompositeQuery,
  mixed composite (builder + formula + trace-op), bare PromQL, bare
  ClickHouseSQL, all-promql composite (PROM queryType preserved).
- Fixture suite (Phase 7) round-trips every panel in
  pkg/types/dashboardtypes/testdata/perses.json — covers every panel kind ×
  query plugin combination the backend actually emits.
2026-06-08 15:41:40 +05:30
Abhi Kumar
5d645e28f5 feat(dashboards-v2): add panel plugin registry
Foundational scaffolding for V2 panel rendering. Each panel kind (perses
plugin.kind) registers a Renderer + section config + supported signals into
PANELS; the dashboard shell dispatches generically via getPanelDefinition.

- PanelKind union + SpecForKind<K> conditional mapping each kind to its
  concrete perses spec
- PanelRegistry (keyed by kind) — cast-free registration; getPanelDefinition
  helper localizes the unavoidable widening cast for polymorphic lookup
- SECTIONS metadata table (title + icon per section) as the single source
  of truth for section kinds; SectionKind / SectionControls / SectionConfig
  derive from it
- TimeSeriesPanel stub renderer + declarative sections list
2026-06-08 15:41:40 +05:30
Ashwin Bhatkal
9cd5f3e1c9 feat: dashboard configuration 2026-06-08 15:41:40 +05:30
Ashwin Bhatkal
2255334ce1 feat: dashboard configuration 2026-06-08 15:41:36 +05:30
Ashwin Bhatkal
4d7f25d7ec refactor(dashboards-list-v2): group state components under states/
Move EmptyState, ErrorState, LoadingState, and NoResultsState under
components/states/. They're a coherent family (interchangeable view
branches in the list orchestrator) and grouping them sets up shared
styling extraction next.

Pure relocation — git mv preserves history; only DashboardsList.tsx's
imports change.
2026-06-08 15:37:46 +05:30
Ashwin Bhatkal
2e659c0407 feat(dashboards-list-v2): loading / error / empty / no-results state components 2026-06-08 15:33:17 +05:30
62 changed files with 5957 additions and 127 deletions

View File

@@ -0,0 +1,6 @@
services:
signoz:
environment:
# Enable dashboard v2 (maps to flagger.config.boolean.use_dashboard_v2: true)
# Double underscore is the key separator; single underscore stays part of the key.
- SIGNOZ_FLAGGER__CONFIG__BOOLEAN__USE_DASHBOARD_V2=true

View File

@@ -641,6 +641,103 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)

View File

@@ -4681,6 +4681,61 @@ export interface DashboardtypesGettableDashboardV2DTO {
updatedBy?: string;
}
export interface DashboardtypesGettableDashboardWithPinDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned?: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesGettablePublicDasbhboardDTO {
/**
* @type string
@@ -9576,6 +9631,42 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @type string
* @description undefined
*/
sort?: string;
/**
* @type string
* @description undefined
*/
order?: string;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
import {
LegendPosition,
TooltipRenderArgs,
@@ -47,7 +47,7 @@ export default function ChartWrapper({
return null;
}
return (
<Legend
<UPlotLegend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}

View File

@@ -0,0 +1,67 @@
.pieChartWrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.pieChartNoData {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-size: 14px;
}
// Size is set inline from the computed chart dimensions (mirrors the uPlot
// chart/legend split); this just centres the donut within that box.
.pieChartContainer {
flex: 0 0 auto;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.pieChartTooltip {
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
background-color: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.pieTooltipContent {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.pieChartIndicator {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
display: inline-block;
}
.tooltipValue {
font-weight: bold;
margin-top: 4px;
}
// Wraps the shared chart Legend. Its width/height are set inline from the
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
// box (right column / bottom rows) the uPlot charts use.
.pieLegend {
flex: 0 0 auto;
min-height: 0;
min-width: 0;
padding: 8px;
}

View File

@@ -0,0 +1,241 @@
import { useCallback, useMemo, useRef } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie as VisxPie } from '@visx/shape';
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { PieChartProps, PieSlice } from '../types';
import { calculateChartDimensions } from '../utils';
import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { usePieInteractions } from './usePieInteractions';
import { getFillColor } from './utils';
interface PieTooltipData {
label: string;
value: string;
color: string;
}
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
* same `calculateChartDimensions` logic as the uPlot charts (right column /
* up-to-two bottom rows), renders the shared chart Legend, and delegates the
* arcs, centre total and interaction state to PieArc / PieCenterLabel /
* usePieInteractions. Pure presentation — slices are pre-resolved by the caller.
*/
export default function Pie({
data,
yAxisUnit,
decimalPrecision,
isDarkMode,
position = LegendPosition.BOTTOM,
id,
onSliceClick,
'data-testid': testId,
}: PieChartProps): JSX.Element {
const {
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = usePieInteractions(data, id);
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<PieTooltipData>();
const { containerRef, TooltipInPortal } = useTooltipInPortal({
scroll: true,
detectBounds: true,
});
const wrapperRef = useRef<HTMLDivElement>(null);
const { width: containerWidth, height: containerHeight } =
useResizeObserver(wrapperRef);
// Reuse the uPlot chart/legend split so the donut + legend get the same area
// allocation (right column, or up-to-two bottom rows) as every other panel.
const dimensions = useMemo(
() =>
calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, data],
);
const size = Math.min(dimensions.width, dimensions.height);
const radius = size * 0.35;
const innerRadius = radius * 0.6;
const totalValue = visibleData.reduce((sum, slice) => sum + slice.value, 0);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor = active?.color ?? null;
const handleSliceEnter = useCallback(
(slice: PieSlice, centroidX: number, centroidY: number): void => {
showTooltip({
tooltipData: {
label: slice.label,
value: getYAxisFormattedValue(
slice.value.toString(),
yAxisUnit || 'none',
decimalPrecision,
),
color: slice.color,
},
tooltipTop: centroidY + dimensions.height / 2,
tooltipLeft: centroidX + dimensions.width / 2,
});
setActive(slice);
},
[
showTooltip,
setActive,
yAxisUnit,
decimalPrecision,
dimensions.height,
dimensions.width,
],
);
const handleSliceLeave = useCallback((): void => {
hideTooltip();
setActive(null);
}, [hideTooltip, setActive]);
if (!data.length) {
return (
<div
ref={wrapperRef}
className={styles.pieChartWrapper}
data-testid={testId}
>
<div className={styles.pieChartNoData}>No data</div>
</div>
);
}
const isRightLegend = position === LegendPosition.RIGHT;
return (
<div
ref={wrapperRef}
className={styles.pieChartWrapper}
style={{ flexDirection: isRightLegend ? 'row' : 'column' }}
data-testid={testId}
>
<div
className={styles.pieChartContainer}
style={{ width: dimensions.width, height: dimensions.height }}
>
{size > 0 && (
<svg
width={dimensions.width}
height={dimensions.height}
ref={containerRef}
>
<Group top={dimensions.height / 2} left={dimensions.width / 2}>
<VisxPie
data={visibleData}
pieValue={(slice: PieSlice): number => slice.value}
outerRadius={radius}
innerRadius={innerRadius}
padAngle={0.01}
cornerRadius={3}
width={size}
height={size}
>
{(pie): JSX.Element[] =>
pie.arcs.map((arc) => (
<PieArc
key={`arc-${arc.data.label}-${arc.data.value}-${arc.startAngle.toFixed(
6,
)}`}
slice={arc.data}
arcPath={pie.path(arc) || ''}
centroid={pie.path.centroid(arc)}
startAngle={arc.startAngle}
endAngle={arc.endAngle}
radius={radius}
totalValue={totalValue}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
labelColor={labelColor}
fill={getFillColor(arc.data.color, activeColor)}
onEnter={handleSliceEnter}
onLeave={handleSliceLeave}
onClick={onSliceClick}
/>
))
}
</VisxPie>
<PieCenterLabel
total={totalValue}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
radius={radius}
innerRadius={innerRadius}
color={labelColor}
/>
</Group>
</svg>
)}
{tooltipOpen && tooltipData && (
<TooltipInPortal
top={tooltipTop}
left={tooltipLeft}
className={styles.pieChartTooltip}
style={{
...defaultStyles,
color: labelColor,
}}
>
<div
className={styles.pieChartIndicator}
style={{ background: tooltipData.color }}
/>
<div className={styles.pieTooltipContent}>
<span>{tooltipData.label}</span>
<span className={styles.tooltipValue}>{tooltipData.value}</span>
</div>
</TooltipInPortal>
)}
</div>
<div
className={styles.pieLegend}
style={{
width: dimensions.legendWidth,
height: dimensions.legendHeight,
}}
>
<Legend
items={legendItems}
position={position}
averageLegendWidth={dimensions.averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { PieSlice } from '../types';
import { getArcGeometry } from './utils';
// Slices below this share of the total don't get a leader label (too cramped).
const MIN_LABEL_SHARE = 0.03;
const MAX_LABEL_LENGTH = 15;
interface PieArcProps {
slice: PieSlice;
/** SVG path `d` for the arc, from the visx pie generator. */
arcPath: string;
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
centroid: [number, number];
startAngle: number;
endAngle: number;
radius: number;
/** Sum of visible slice values — drives the show-label threshold. */
totalValue: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
labelColor: string;
/** Resolved fill (already dimmed if another slice is active). */
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
onLeave: () => void;
onClick?: (slice: PieSlice) => void;
}
/**
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
* out to an external label + value. Pure presentation — interaction is
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
*/
export default function PieArc({
slice,
arcPath,
centroid,
startAngle,
endAngle,
radius,
totalValue,
yAxisUnit,
decimalPrecision,
labelColor,
fill,
onEnter,
onLeave,
onClick,
}: PieArcProps): JSX.Element {
const { label, value } = slice;
const [centroidX, centroidY] = centroid;
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
startAngle,
endAngle,
radius,
);
const displayValue = getYAxisFormattedValue(
value.toString(),
yAxisUnit || 'none',
decimalPrecision,
);
const shortenedLabel =
label.length > MAX_LABEL_LENGTH ? `${label.substring(0, 12)}...` : label;
const shouldShowLabel = value / totalValue > MIN_LABEL_SHARE;
return (
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
onMouseLeave={onLeave}
onClick={(): void => onClick?.(slice)}
>
<path d={arcPath} fill={fill} />
{shouldShowLabel && (
<>
<line
x1={centroidX}
y1={centroidY}
x2={lineEndX}
y2={lineEndY}
stroke={labelColor}
strokeWidth={1}
/>
<line
x1={lineEndX}
y1={lineEndY}
x2={labelX}
y2={labelY}
stroke={labelColor}
strokeWidth={1}
/>
<text
x={labelX}
y={labelY - 8}
dy=".33em"
fill={labelColor}
fontSize={10}
textAnchor={textAnchor}
pointerEvents="none"
>
{shortenedLabel}
</text>
<text
x={labelX}
y={labelY + 8}
dy=".33em"
fill={labelColor}
fontSize={10}
fontWeight="bold"
textAnchor={textAnchor}
pointerEvents="none"
>
{displayValue}
</text>
</>
)}
</g>
);
}

View File

@@ -0,0 +1,57 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { getScaledFontSize } from './utils';
interface PieCenterLabelProps {
/** Sum of the visible slice values, shown in the donut hole. */
total: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
radius: number;
innerRadius: number;
color: string;
}
/**
* The total shown in the centre of the donut. Splits the formatted value into
* its numeric part and unit so each can be sized independently, and scales the
* numeric font down for long values so it never overflows the hole.
*/
export default function PieCenterLabel({
total,
yAxisUnit,
decimalPrecision,
radius,
innerRadius,
color,
}: PieCenterLabelProps): JSX.Element {
const formattedTotal = getYAxisFormattedValue(
total.toString(),
yAxisUnit || 'none',
decimalPrecision,
);
const matches = formattedTotal.match(/([\d.]+[KMB]?)(.*)$/);
const numericTotal = matches?.[1] || formattedTotal;
const unitTotal = matches?.[2]?.trim() || '';
const numericFontSize = getScaledFontSize({
text: numericTotal,
baseSize: radius * 0.3,
innerRadius,
});
const unitFontSize = numericFontSize * 0.5;
return (
<text textAnchor="middle" dominantBaseline="central" fill={color}>
<tspan fontSize={numericFontSize} fontWeight="bold">
{numericTotal}
</tspan>
{unitTotal && (
<tspan fontSize={unitFontSize} opacity={0.9} dx={2}>
{unitTotal}
</tspan>
)}
</text>
);
}

View File

@@ -0,0 +1,168 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from '../../panels/utils/legendVisibilityUtils';
import { PieSlice } from '../types';
export interface UsePieInteractionsResult {
/** The hovered/focused slice (drives donut dimming + tooltip). */
active: PieSlice | null;
setActive: Dispatch<SetStateAction<PieSlice | null>>;
/** Slices currently shown (hidden ones removed). */
visibleData: PieSlice[];
/** Legend item per slice (`show` reflects hide state). */
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
// event target (the shared Legend tags each item with its seriesIndex).
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = el?.dataset.legendItemId;
return id != null ? Number(id) : null;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
* uPlot legend — marker toggles one, label isolates), and persistence of the
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
* reloads. Returns the visible slices, legend items, focus index, and the
* legend container handlers.
*/
export function usePieInteractions(
data: PieSlice[],
id?: string,
): UsePieInteractionsResult {
const [active, setActive] = useState<PieSlice | null>(null);
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const isolatedIndexRef = useRef<number | null>(null);
const legendItems = useMemo<LegendItem[]>(
() =>
data.map((slice, index) => ({
seriesIndex: index,
label: slice.label,
color: slice.color,
show: !hiddenIndices.has(index),
})),
[data, hiddenIndices],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
const visibleData = useMemo(
() => data.filter((_, index) => !hiddenIndices.has(index)),
[data, hiddenIndices],
);
// Rehydrate hide/unhide from localStorage (matched by label) whenever the
// data set changes — including first load and every refetch, since the store
// is the source of truth and toggles write back to it.
useEffect(() => {
if (!id || !data.length) {
return;
}
const stored = getStoredSeriesVisibility(id);
if (!stored) {
return;
}
const hidden = new Set<number>();
data.forEach((slice, index) => {
if (stored.find((s) => s.label === slice.label)?.show === false) {
hidden.add(index);
}
});
setHiddenIndices(hidden);
}, [id, data]);
// Apply a new hidden set and persist it (label + show) to localStorage.
const applyHidden = useCallback(
(hidden: Set<number>): void => {
setHiddenIndices(hidden);
if (id) {
updateSeriesVisibilityToLocalStorage(
id,
data.map((slice, index) => ({
label: slice.label,
show: !hidden.has(index),
})),
);
}
},
[id, data],
);
const onLegendMouseMove = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
},
[data, hiddenIndices],
);
// Marker click toggles just that slice on/off; label click isolates it
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
const onLegendClick = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
if (index == null) {
return;
}
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
if (isMarker) {
const next = new Set(hiddenIndices);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
applyHidden(next);
return;
}
const isReset = isolatedIndexRef.current === index;
isolatedIndexRef.current = isReset ? null : index;
if (isReset) {
applyHidden(new Set());
return;
}
const next = new Set<number>();
data.forEach((_, i) => {
if (i !== index) {
next.add(i);
}
});
applyHidden(next);
},
[data, hiddenIndices, applyHidden],
);
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
const focusedIndex = active ? data.indexOf(active) : -1;
return {
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -0,0 +1,110 @@
/**
* Pure presentation helpers for the Pie chart. Kept out of the component file
* so the renderer stays declarative (per the one-component-per-file rule).
*/
interface ScaledFontSizeArgs {
text: string;
baseSize: number;
innerRadius: number;
}
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
* the donut hole. Ported from the V1 PiePanelWrapper.
*/
export function getScaledFontSize({
text,
baseSize,
innerRadius,
}: ScaledFontSizeArgs): number {
if (!text) {
return baseSize;
}
const { length } = text;
// More aggressive scaling for very long numbers.
const scaleFactor = Math.max(0.3, 1 - (length - 3) * 0.09);
// Don't use more than 90% of the inner radius.
const maxSize = innerRadius * 0.9;
return Math.min(baseSize * scaleFactor, maxSize);
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;
labelY: number;
/** Elbow point where the leader line bends toward the label. */
lineEndX: number;
lineEndY: number;
/** Anchor the label left/right depending on which half of the circle it's in. */
textAnchor: 'start' | 'end';
}
/**
* Computes the leader-line / label geometry for one arc from its angular span.
* Pulled out of the render prop so the SVG markup stays declarative.
*/
export function getArcGeometry(
startAngle: number,
endAngle: number,
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * 1.3;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,
labelY: -Math.cos(angle) * labelRadius,
lineEndX: Math.sin(angle) * lineEndRadius,
lineEndY: -Math.cos(angle) * lineEndRadius,
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
};
}
interface ParsedRgb {
r: number;
g: number;
b: number;
}
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Returns an rgba() string for `color` at the given opacity. Used to dim the
* non-hovered slices. Falls back to the original colour if it can't be parsed.
*/
export function lightenColor(color: string, opacity: number): string {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
}
/**
* Resolves the fill for a slice given the currently-hovered slice colour:
* everything but the active slice dims to 40% opacity. With nothing hovered
* (`activeColor === null`) every slice keeps its full colour.
*/
export function getFillColor(
color: string,
activeColor: string | null,
): string {
if (activeColor === null) {
return color;
}
return activeColor === color ? color : lightenColor(color, 0.4);
}

View File

@@ -3,13 +3,14 @@ import { PrecisionOption } from 'components/Graph/types';
import {
IRenderTooltipFooterArgs,
LegendConfig,
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
ChartClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -22,10 +23,10 @@ interface BaseChartProps {
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
onClick?: (clickData: ChartClickData) => void;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
@@ -69,3 +70,36 @@ export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.
*/
export interface PieSlice {
label: string;
value: number;
color: string;
}
/**
* Props for the Pie chart. Unlike the others above, Pie is NOT uPlot-based
* (it renders with @visx), so it deliberately does not extend BaseChartProps /
* UPlotBasedChartProps — it takes pre-resolved slices and self-measures its
* draw area rather than receiving a uPlot config + aligned data.
*/
export interface PieChartProps {
data: PieSlice[];
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
isDarkMode: boolean;
/** Legend placement. Drives the chart-vs-legend layout. Default BOTTOM. */
position?: LegendPosition;
/**
* Widget id used to persist per-slice hide/unhide state to localStorage
* (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;
'data-testid'?: string;
}

View File

@@ -171,17 +171,20 @@
}
.legend-copy-button {
display: none;
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
display: flex;
opacity: 0;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 2px;
margin: 0;
border: none;
background: transparent;
color: var(--l2-foreground);
cursor: pointer;
border-radius: 4px;
opacity: 1;
transition:
opacity 0.15s ease,
color 0.15s ease;
@@ -192,9 +195,8 @@
}
&:hover {
background: color-mix(in srgb, var(--l1-foreground) 5%, transparent);
background: var(--l3-background);
.legend-copy-button {
display: flex;
opacity: 1;
}
}

View File

@@ -1,39 +1,40 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input, Tooltip as AntdTooltip } from 'antd';
import { Input } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { Check, Copy } from '@signozhq/icons';
import { useLegendActions } from '../../hooks/useLegendActions';
import { LegendPosition, LegendProps } from '../types';
import './Legend.styles.scss';
export const MAX_LEGEND_WIDTH = 240;
/**
* Presentational legend. Renders the supplied `items` (markers + labels, an
* optional copy button, and a search box for the RIGHT position) and delegates
* all interaction to the container handlers. Source-agnostic — the uPlot
* charts feed it via UPlotLegend; Pie feeds it directly.
*/
export default function Legend({
items,
position = LegendPosition.BOTTOM,
config,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex = null,
onClick,
onMouseMove,
onMouseLeave,
showCopy = true,
showSearch,
}: LegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
const legendItems = useMemo(
() => Object.values(legendItemsMap),
[legendItemsMap],
);
const searchEnabled = showSearch ?? position === LegendPosition.RIGHT;
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
@@ -41,21 +42,19 @@ export default function Legend({
}
const containerWidth = legendContainerRef.current.clientWidth;
const totalLegendWidth = legendItems.length * (averageLegendWidth + 16);
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, legendContainerRef, legendItems.length, position]);
}, [averageLegendWidth, items.length, position]);
const visibleLegendItems = useMemo(() => {
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
return legendItems;
if (!searchEnabled || !legendSearchQuery.trim()) {
return items;
}
const query = legendSearchQuery.trim().toLowerCase();
return legendItems.filter((item) =>
item.label?.toLowerCase().includes(query),
);
}, [position, legendSearchQuery, legendItems]);
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const handleCopyLegendItem = useCallback(
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
@@ -68,6 +67,9 @@ export default function Legend({
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => {
const isCopied = copiedId === item.seriesIndex;
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
return (
<div
key={item.seriesIndex}
@@ -77,54 +79,56 @@ export default function Legend({
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<AntdTooltip title={item.label}>
<TooltipSimple title={item.label} arrow side="top">
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
style={{ borderColor: markerColor }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
<AntdTooltip title={isCopied ? 'Copied' : 'Copy'}>
<button
type="button"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
</AntdTooltip>
</TooltipSimple>
{showCopy && (
<TooltipSimple title={isCopied ? 'Copied' : 'Copy'} arrow side="top">
<button
type="button"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
</TooltipSimple>
)}
</div>
);
},
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position],
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
);
const isEmptyState = useMemo(() => {
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
if (!searchEnabled || !legendSearchQuery.trim()) {
return false;
}
return visibleLegendItems.length === 0;
}, [position, legendSearchQuery, visibleLegendItems]);
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
onClick={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
>
{position === LegendPosition.RIGHT && (
{searchEnabled && (
<div className="legend-search-container">
<Input
allowClear

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import { LegendPosition, UPlotLegendProps } from '../types';
import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the toggle/focus interactions from
* the plot context (useLegendActions), then renders the presentational Legend.
* Must be rendered inside a PlotContextProvider.
*/
export default function UPlotLegend({
position = LegendPosition.BOTTOM,
config,
averageLegendWidth,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
return (
<Legend
items={items}
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
);
}

View File

@@ -7,11 +7,12 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import Legend from '../Legend/Legend';
import UPlotLegend from '../Legend/UPlotLegend';
import { LegendPosition } from '../types';
const mockWriteText = jest.fn().mockResolvedValue(undefined);
@@ -47,7 +48,7 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
describe('Legend', () => {
describe('UPlotLegend', () => {
beforeAll(() => {
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
Object.defineProperty(navigator, 'clipboard', {
@@ -115,11 +116,13 @@ describe('Legend', () => {
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<Legend
position={position}
// config is not used directly in the component, it's consumed by the mocked hook
config={{} as any}
/>,
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {

View File

@@ -1,9 +1,10 @@
import { ReactNode } from 'react';
import { MouseEventHandler, ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -109,7 +110,33 @@ export enum LegendPosition {
export interface LegendConfig {
position: LegendPosition;
}
/**
* Presentational legend props. Source-agnostic: it renders whatever
* `items` it's given and delegates interaction to the container handlers, so
* it serves both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
*/
export interface LegendProps {
items: LegendItem[];
position?: LegendPosition;
averageLegendWidth?: number;
/** Series index to highlight (hovered/focused). */
focusedSeriesIndex?: number | null;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick?: MouseEventHandler<HTMLDivElement>;
onMouseMove?: MouseEventHandler<HTMLDivElement>;
onMouseLeave?: () => void;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
/** Show the filter search box. Default: only for the RIGHT position. */
showSearch?: boolean;
}
/** Props for the uPlot legend controller, which derives items + interaction
* from the chart config and renders the presentational Legend. */
export interface UPlotLegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
averageLegendWidth?: number;

View File

@@ -37,7 +37,7 @@ export interface TooltipViewState {
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
clickData: TooltipClickData | null;
clickData: ChartClickData | null;
contents?: ReactNode;
}
@@ -59,17 +59,17 @@ export interface TooltipPluginProps {
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
onClick?: (clickData: ChartClickData) => void;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
pinnedTooltipElement?: (clickData: ChartClickData) => ReactNode;
maxWidth?: number;
maxHeight?: number;
}
export interface TooltipClickData {
export interface ChartClickData {
xValue: number;
yValue: number;
focusedSeries: {
@@ -101,7 +101,7 @@ export interface TooltipControllerState {
hoverActive: boolean;
isAnySeriesActive: boolean;
pinned: boolean;
clickData: TooltipClickData | null;
clickData: ChartClickData | null;
style: TooltipViewState['style'];
horizontalOffset: number;
verticalOffset: number;

View File

@@ -2,7 +2,7 @@ import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
TOOLTIP_OFFSET,
TooltipClickData,
ChartClickData,
TooltipLayoutInfo,
TooltipViewState,
} from './types';
@@ -167,14 +167,11 @@ export function createLayoutObserver(
}
/**
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
* Resolves a ChartClickData snapshot from a MouseEvent (real or synthetic)
* and the current uPlot instance. Shared by the overlay click handler and the
* keyboard-pin handler (which synthesises an event from the cursor position).
*/
export function buildClickData(
event: MouseEvent,
plot: uPlot,
): TooltipClickData {
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);

View File

@@ -7,10 +7,20 @@ import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const isV2 = useIsDashboardV2();
if (isV2) {
return <DashboardPageV2 />;
}
return <DashboardPageV1 />;
}
function DashboardPageV1(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const [onModal, Content] = Modal.useModal();

View File

@@ -0,0 +1,152 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { useGroupByPerQuery } from '../hooks/useGroupByPerQuery';
import { useTimeScale } from '../hooks/useTimeScale';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildBarChartConfig } from './config';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function BarPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesBarChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const { minTimeScale, maxTimeScale } = useTimeScale(data);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const config = useMemo(
() =>
buildBarChartConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(
() => (data?.payload ? prepareChartData(data.payload) : []),
[data?.payload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default BarPanelRenderer;

View File

@@ -0,0 +1,140 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
export function buildBarChartConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesBarChartPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds one bar series per result row, plus uPlot bands for stacking when
* `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
const result = apiResponse?.payload?.data?.result;
if (!result) {
return;
}
const stepIntervals =
apiResponse?.payload?.data?.newResult?.meta?.stepIntervals;
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(result.length + 1));
}
result.forEach((series) => {
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
const stepInterval = series.queryName
? stepIntervals?.[series.queryName]
: undefined;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
stepInterval,
metric: series.metric,
});
});
}

View File

@@ -0,0 +1,9 @@
import type { SectionConfig } from '../types';
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
];

View File

@@ -0,0 +1,126 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import { resolveLegendPosition } from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildHistogramConfig } from './config';
import { prepareHistogramData } from './data';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
panel,
data,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesHistogramPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const config = useMemo(
() =>
buildHistogramConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
}),
[panelId, spec, builderQueries, data, isDarkMode, timezone, panelMode],
);
const chartData = useMemo(
() =>
prepareHistogramData({
payload: data?.payload,
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
}),
[
data?.payload,
spec.histogramBuckets?.bucketWidth,
spec.histogramBuckets?.bucketCount,
spec.histogramBuckets?.mergeAllActiveQueries,
],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Histogram
key={panelId}
config={config}
data={chartData as uPlot.AlignedData}
legendConfig={{ position: legendPosition }}
canPinTooltip
isQueriesMerged={isQueriesMerged}
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default HistogramPanelRenderer;

View File

@@ -0,0 +1,142 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
*/
export function buildHistogramConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,
apiResponse,
});
builder.setCursor({
drag: { x: false, y: false, setScale: true },
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesHistogramPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
const mergeAllActiveQueries =
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
if (mergeAllActiveQueries) {
builder.addSeries({
scaleKey: 'y',
label: '',
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
lineColor: MERGED_SERIES_LINE_COLOR,
fillColor: MERGED_SERIES_FILL_COLOR,
isDarkMode,
});
return;
}
const result = apiResponse?.payload?.data?.result;
if (!result) {
return;
}
result.forEach((series) => {
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
isDarkMode,
});
});
}

View File

@@ -0,0 +1,148 @@
import { histogramBucketSizes } from '@grafana/data';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
export interface PrepareHistogramDataArgs {
payload: MetricRangePayloadProps | undefined;
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
payload,
bucketWidth,
bucketCount = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramDataArgs): AlignedData {
if (!payload) {
return [[]];
}
const result = payload.data.result;
const values = extractNumericValues(result);
if (values.length === 0) {
return [[]];
}
const sorted = [...values].sort(sortAscending);
const range = sorted[sorted.length - 1] - sorted[0];
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(result, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
if (histograms.length === 0) {
return [[]];
}
const merged = mergeAlignedDataTables(histograms);
replaceUndefinedWithNullInAlignedData(merged);
prependNullBinToFirstHistogramSeries(merged, bucketSize);
return merged;
}
function extractNumericValues(
result: MetricRangePayloadProps['data']['result'],
): number[] {
const values: number[] = [];
for (const item of result) {
for (const [, valueStr] of item.values) {
values.push(Number.parseFloat(valueStr) || 0);
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
function buildFrames(
result: MetricRangePayloadProps['data']['result'],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = result.map((item) =>
item.values.map(([, valueStr]) => Number.parseFloat(valueStr) || 0),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}

View File

@@ -0,0 +1,6 @@
import type { SectionConfig } from '../types';
export const sections: SectionConfig[] = [
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
];

View File

@@ -0,0 +1,76 @@
import { useCallback, useMemo } 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';
import { useIsDarkMode } from 'hooks/useDarkMode';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { preparePieData } from './data';
function PiePanelRenderer({
panelId,
panel,
data,
onClick,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/PieChartPanel'`, so the cast is a
// documented boundary narrowing. Memoized so the `?? {}` fallback doesn't
// produce a fresh object on each render.
const spec = useMemo<DashboardtypesPieChartPanelSpecDTO>(
() => (panel?.spec?.plugin?.spec ?? {}) as DashboardtypesPieChartPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const slices = useMemo(
() =>
preparePieData({
payload: data?.payload,
customColors: spec.legend?.customColors,
isDarkMode,
}),
[data?.payload, spec.legend?.customColors, isDarkMode],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const handleSliceClick = useCallback(
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
},
[onClick],
);
return (
<div data-testid="pie-panel-renderer" className={PanelStyles.panelContainer}>
<Pie
data={slices}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={handleSliceClick}
data-testid="pie-chart"
/>
</div>
);
}
export default PiePanelRenderer;

View File

@@ -0,0 +1,89 @@
import { themeColors } from 'constants/theme';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export interface PreparePieDataArgs {
payload: MetricRangePayloadProps | undefined;
/** Per-label colour overrides from `spec.legend.customColors`. */
customColors?: Record<string, string> | null;
isDarkMode: boolean;
}
// Local view of the scalar/table response. A pie issues a TABLE request (see
// `getGraphType`), which the API returns web-formatted: one aggregation column
// holds the value, the remaining (group) columns form the label, and each row's
// `data` is keyed by `column.id || column.name`. The generated `QueryData.table`
// types its columns loosely (no `isValueColumn`), so we cast to this precise
// shape once at the boundary rather than threading `any` through.
interface ScalarTableColumn {
name: string;
id?: string;
isValueColumn: boolean;
}
interface ScalarTableEntry {
queryName?: string;
legend?: string;
table?: {
columns: ScalarTableColumn[];
rows: { data: Record<string, string | number | null> }[];
};
}
/**
* Turns a query response into pie slices: one slice per group row.
*
* The reduced value-per-series lives in the scalar `table`, not the time-series
* `result[].values`. Because the pie's graphType is `TABLE`, the response is
* web-formatted and the table sits on `result[]`; we fall back to `newResult`
* for the non-`formatForWeb` shape. Labels come from the group column(s),
* colours honour `customColors` then fall back to a deterministic palette
* colour, and non-positive / non-numeric values are dropped.
*/
export function preparePieData({
payload,
customColors,
isDarkMode,
}: PreparePieDataArgs): PieSlice[] {
const primary = (payload?.data?.result ?? []) as unknown as ScalarTableEntry[];
const fallback = (payload?.data?.newResult?.data?.result ??
[]) as unknown as ScalarTableEntry[];
const entries = primary.some((entry) => entry.table) ? primary : fallback;
const colorMap = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const slices: PieSlice[] = [];
entries.forEach((entry) => {
const { table } = entry;
if (!table) {
return;
}
const valueColumn = table.columns.find((column) => column.isValueColumn);
if (!valueColumn) {
return;
}
const valueKey = valueColumn.id || valueColumn.name;
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
table.rows.forEach((row) => {
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.join(', ') ||
entry.legend ||
entry.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});
return slices.filter(
(slice) => Number.isFinite(slice.value) && slice.value > 0,
);
}

View File

@@ -0,0 +1,8 @@
import type { SectionConfig } from '../types';
// Pie has no axes, thresholds, or stacking — just value formatting and a
// legend. `mode` is omitted: the pie legend is always interactive swatches.
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'legend', controls: { position: true } },
];

View File

@@ -0,0 +1,154 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { useGroupByPerQuery } from '../hooks/useGroupByPerQuery';
import { useTimeScale } from '../hooks/useTimeScale';
import PanelStyles from '../styles/panel.module.scss';
import { PanelRendererProps } from '../types';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../utils/chartAppearanceMappings';
import { getBuilderQueries } from '../utils/getBuilderQueries';
import { buildTimeSeriesConfig } from './config';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function TimeSeriesPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/TimeSeriesPanel'`, so the cast is a
// documented boundary narrowing — not a blind assertion. Memoized so the
// `?? {}` fallback doesn't produce a fresh object on each render.
const spec = useMemo<DashboardtypesTimeSeriesPanelSpecDTO>(
() =>
(panel?.spec?.plugin?.spec ?? {}) as DashboardtypesTimeSeriesPanelSpecDTO,
[panel?.spec?.plugin?.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel?.spec?.queries),
[panel?.spec?.queries],
);
const { minTimeScale, maxTimeScale } = useTimeScale(data);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const config = useMemo(
() =>
buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
apiResponse: data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
data,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(
() => (data?.payload ? prepareChartData(data.payload) : []),
[data?.payload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
/**
* The uPlot key prop is the only way to force a full teardown and re-mount
* of the chart. By including the syncMode and syncFilterMode in the key,
* we ensure that changes to these preferences trigger a fresh chart instance,
* preventing stale sync settings from being inherited.
*/
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="time-series-renderer"
className={PanelStyles.panelContainer}
>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default TimeSeriesPanelRenderer;

View File

@@ -0,0 +1,163 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
FILL_MODE_MAP,
LINE_INTERPOLATION_MAP,
LINE_STYLE_MAP,
resolveSpanGaps,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/chartAppearanceMappings';
import { resolveSeriesLabel } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const DEFAULT_POINT_SIZE = 5;
export interface BuildTimeSeriesConfigArgs {
panelId: string;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a TimeSeries panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the TimeSeries-specific concern: one series per result, with visuals
* resolved from `spec.chartAppearance`.
*/
export function buildTimeSeriesConfig({
panelId,
spec,
builderQueries,
apiResponse,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.TIME_SERIES,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesTimeSeriesPanelSpecDTO;
builderQueries: BuilderQuery[];
apiResponse: MetricQueryRangeSuccessResponse | undefined;
isDarkMode: boolean;
}
/**
* Adds one uPlot series per result row to the scaffolded builder. The visual
* resolution (line style, interpolation, fill mode, span gaps) reads from
* `spec.chartAppearance`; the label is resolved via the legend matrix in
* `resolveSeriesLabel`. Mutates the builder in place.
*/
function addSeriesFromResponse({
builder,
spec,
builderQueries,
apiResponse,
isDarkMode,
}: AddSeriesArgs): void {
if (!apiResponse?.payload?.data?.result) {
return;
}
const chartAppearance = spec.chartAppearance;
// `customColors` is nullable on the spec; coerce so `addSeries` always gets
// a defined record (it dereferences keys without a guard).
const colorMapping = spec.legend?.customColors ?? {};
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]
: LineStyle.Solid;
const lineInterpolation = chartAppearance?.lineInterpolation
? LINE_INTERPOLATION_MAP[chartAppearance.lineInterpolation]
: LineInterpolation.Spline;
const fillMode = chartAppearance?.fillMode
? FILL_MODE_MAP[chartAppearance.fillMode]
: FillMode.None;
apiResponse.payload.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabel = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = resolveSeriesLabel(series, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
// A single visible point can't be drawn as a line — degrade to points
// so the user still sees the datum (matches V1 behavior).
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping,
spanGaps,
lineStyle,
lineInterpolation,
showPoints: chartAppearance?.showPoints || hasSingleValidPoint,
pointSize: DEFAULT_POINT_SIZE,
fillMode,
isDarkMode,
metric: series.metric,
});
});
}

View File

@@ -0,0 +1,15 @@
import type { SectionConfig } from '../types';
export const sections: SectionConfig[] = [
{
kind: 'formatting',
controls: {
unit: true,
decimals: true,
},
},
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { lineStyle: true, fillOpacity: true } },
];

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Builds a record keyed by builder-query name to that query's groupBy keys
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
* call site that needs the V1 shape; the rest of V2 panel code stays on
* v5 types.
*/
export function useGroupByPerQuery(
builderQueries: BuilderQuery[],
): Record<string, BaseAutocompleteData[]> {
return useMemo(() => {
const result: Record<string, BaseAutocompleteData[]> = {};
builderQueries.forEach((q) => {
if (!q.name) {
return;
}
result[q.name] = (q.groupBy ?? []).map((g) => ({
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
}));
});
return result;
}, [builderQueries]);
}

View File

@@ -0,0 +1,29 @@
import { useMemo } from 'react';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
interface TimeScale {
minTimeScale: number | undefined;
maxTimeScale: number | undefined;
}
/**
* Derives the X-axis time-scale clamps from a query-range response. Reads
* `start`/`end` off `data.params` (the request that produced this payload)
* so each panel pins to the window it actually fetched — important during
* drag-zoom transitions when the time picker has moved but new data hasn't
* arrived yet. Falls back to the global time picker via the helper when
* `data` is absent.
*/
export function useTimeScale(
data: MetricQueryRangeSuccessResponse | undefined,
): TimeScale {
return useMemo(() => {
// `data.params` is typed `unknown` on this branch; PR 11562 narrows it
// to `QueryRangeRequestV5`. Drop this cast when that lands.
const params = data?.params as QueryRangeRequestV5 | undefined;
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(params);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data]);
}

View File

@@ -0,0 +1,70 @@
import { DataSource } from 'types/common/queryBuilder';
import BarPanelRenderer from './BarPanel/Renderer';
import { sections as barSections } from './BarPanel/sections';
import HistogramPanelRenderer from './HistogramPanel/Renderer';
import { sections as histogramSections } from './HistogramPanel/sections';
import PiePanelRenderer from './PiePanel/Renderer';
import { sections as pieSections } from './PiePanel/sections';
import TimeSeriesRenderer from './TimeSeriesPanel/Renderer';
import { sections as timeSeriesSections } from './TimeSeriesPanel/sections';
import type {
PanelDefinition,
PanelKind,
PanelRegistry,
RenderablePanelDefinition,
} from './types';
const TimeSeriesPanelDef: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
Renderer: TimeSeriesRenderer,
sections: timeSeriesSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const BarChartPanelDef: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer: BarPanelRenderer,
sections: barSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const HistogramPanelDef: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer: HistogramPanelRenderer,
sections: histogramSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
const PiePanelDef: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
Renderer: PiePanelRenderer,
sections: pieSections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};
export const PANELS: PanelRegistry = {
'signoz/TimeSeriesPanel': TimeSeriesPanelDef,
'signoz/BarChartPanel': BarChartPanelDef,
'signoz/HistogramPanel': HistogramPanelDef,
'signoz/PieChartPanel': PiePanelDef,
};
export function getPanelDefinition(
kind: string | undefined,
): RenderablePanelDefinition | undefined {
if (!kind) {
return undefined;
}
// The registry is correlated by kind, so a string lookup yields a union over
// every kind's exactly-typed definition. The renderer cannot be validated
// against that union at the JSX boundary, so widen to the kind-agnostic
// surface here — the single, intentional cast for the whole panel system.
return PANELS[kind as PanelKind] as unknown as
| RenderablePanelDefinition
| undefined;
}

View File

@@ -0,0 +1,59 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
/**
* Source-tagged click events. The three uPlot panels share `ChartClickEvent`;
* each non-chart kind carries the context its drill-down needs. The `source`
* tag lets a kind-agnostic consumer (the render boundary, a shared drill-down
* handler) discriminate without assuming a chart shape.
*/
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;
type DragSelect = (start: number, end: number) => void;
/**
* Per-kind interaction props. Each panel kind exposes ONLY the gestures it
* supports: chart panels get a chart-shaped `onClick`, time-axis charts add
* `onDragSelect`, histograms have no drag-to-zoom, a NumberPanel has no
* interactions at all. Keys mirror `PanelKind`; `PanelRendererProps<K>` in
* types.ts indexes this map, so a missing kind is a compile error there.
*/
export interface PanelInteractionMap {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
};
'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>;
}
/**
* Widest interaction surface — used where the panel kind is not known
* statically (the registry render boundary; see `getPanelDefinition`). It is
* the structural supertype the per-kind shapes are cast to exactly once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
}

View File

@@ -0,0 +1,9 @@
.panelContainer {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}

View File

@@ -0,0 +1,177 @@
import type { ComponentType } from 'react';
import {
BarChart,
Columns3,
Hash,
ListEnd,
Palette,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DataSource } from 'types/common/queryBuilder';
import type {
AnyPanelInteractionProps,
PanelInteractionMap,
} from './interactions';
export type PanelKind =
| 'signoz/TimeSeriesPanel'
| 'signoz/BarChartPanel'
| 'signoz/NumberPanel'
| 'signoz/PieChartPanel'
| 'signoz/TablePanel'
| 'signoz/HistogramPanel'
| 'signoz/ListPanel';
// Derived from an actual icon component so the type stays exact (size is a
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
type SectionIcon = typeof Hash;
export interface SectionMetadata {
title: string;
icon: SectionIcon;
description?: string;
}
// Source of truth for sections. Its keys define SectionKind; its values are the
// runtime UI metadata (consumed by PanelEditor in 1.8). Adding a new section =
// one entry here + one entry in SectionControls.
export const SECTIONS = {
formatting: { title: 'Formatting', icon: Hash },
axes: { title: 'Axes', icon: Ruler },
legend: { title: 'Legend', icon: ListEnd },
thresholds: { title: 'Thresholds', icon: SlidersHorizontal },
chartAppearance: { title: 'Chart appearance', icon: Palette },
columnUnits: { title: 'Column units', icon: Columns3 },
buckets: { title: 'Buckets', icon: BarChart },
} as const satisfies Record<string, SectionMetadata>;
export type SectionKind = keyof typeof SECTIONS;
// Per-kind control toggles (type-only — runtime metadata is in SECTIONS).
// Section components type their controls prop via `SectionControls['axes']`.
export type SectionControls = {
formatting: { unit?: boolean; decimals?: boolean };
axes: { minMax?: boolean; unit?: boolean; logScale?: boolean };
legend: { position?: boolean; mode?: boolean };
thresholds: { list?: boolean };
chartAppearance: {
lineStyle?: boolean;
fillOpacity?: boolean;
stacked?: boolean;
};
columnUnits: { perColumnUnit?: boolean };
buckets: { count?: boolean; min?: boolean; max?: boolean };
};
// Discriminated union derived from SectionControls — kept in lockstep automatically.
export type SectionConfig = {
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
}[SectionKind];
/**
* Dashboard-wide rendering preferences propagated down to every panel renderer
* on the same dashboard. Lets the shell push cross-panel concerns (cursor
* sync, tooltip filter mode, dashboard id for scoped state) without each
* renderer rediscovering them via hooks. All fields are optional — non-
* dashboard render contexts (PanelEditor preview, standalone view) can pass
* an empty object and the renderer will fall back to sensible defaults.
*/
export interface DashboardPreference {
/**
* Cursor-sync mode for the dashboard. Drives the uPlot tooltip plugin so
* hovering one panel highlights the corresponding x on every other panel.
*/
syncMode?: DashboardCursorSync;
/**
* Filter applied to the synced tooltip across panels (e.g. only show series
* whose label matches the hovered series).
*/
syncFilterMode?: SyncTooltipFilterMode;
/**
* Dashboard id — useful for renderers that scope per-dashboard state
* (e.g. pinned-tooltip persistence, drill-down history).
*/
dashboardId?: string;
}
// Kind-agnostic props every renderer receives, regardless of panel kind. The
// kind-specific interaction props (onClick payload, onDragSelect) are layered
// on per-kind by PanelRendererProps<K>.
export interface BaseRendererProps {
panelId: string;
/**
* The whole perses panel — renderers derive their concrete `spec` and the
* perses-shaped `queries` from this. Passing the full panel keeps the prop
* surface stable as new panel-level fields are added to the wire format.
*/
panel: DashboardtypesPanelDTO | undefined;
data: MetricQueryRangeSuccessResponse | undefined;
isLoading: boolean;
error: Error | null;
/** Gate for the drill-down right-click menu. Off by default in V2. */
enableDrillDown?: boolean;
/**
* Render context — varies behavior (e.g. dashboard widget vs. standalone
* full-screen vs. inside the editor). See PanelMode for the contract.
*/
panelMode: PanelMode;
/**
* Dashboard-level preferences that should propagate to every panel
* (cursor sync, tooltip filter mode, dashboard id). The shell owns
* resolving these; the renderer just consumes them.
*/
dashboardPreference?: DashboardPreference;
}
// Renderer props for a specific panel kind: the shared base plus that kind's
// interaction surface (PanelInteractionMap[K]). Each renderer annotates with
// its own kind — e.g. PanelRendererProps<'signoz/TimeSeriesPanel'> — so it can
// only reference the gestures that kind supports. Indexing PanelInteractionMap
// here forces the map to cover every PanelKind. The default K = PanelKind
// yields the widest surface (a union over all kinds).
export type PanelRendererProps<K extends PanelKind = PanelKind> =
BaseRendererProps & PanelInteractionMap[K];
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
supportedSignals: DataSource[];
}
// Keyed registry that preserves the kind ↔ definition correlation: indexing
// with a literal kind yields that kind's exactly-typed PanelDefinition.
export type PanelRegistry = { [K in PanelKind]?: PanelDefinition<K> };
// A PanelDefinition whose Renderer is widened to the kind-agnostic prop surface.
// At the render boundary the concrete kind isn't known statically (a registry
// lookup returns a union over kinds), so getPanelDefinition resolves to this —
// concentrating the single unavoidable cast in one place instead of leaking it
// to every call site.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
'signoz/NumberPanel': PANEL_TYPES.VALUE,
'signoz/PieChartPanel': PANEL_TYPES.PIE,
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
};

View File

@@ -0,0 +1,208 @@
import type {
DashboardtypesPanelFormattingDTO,
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
} from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
/**
* Inputs for the shared V2 chart pipeline. Mirrors the V1 helper of the same
* name but accepts perses-shaped inputs directly (so callers don't translate
* once per panel). The series-rendering step is panel-specific and lives in
* each panel's `utils.ts` — this helper only wires the scaffolding (scales,
* thresholds, axes, drag-to-zoom, click plugin).
*/
export interface BuildBaseConfigArgs {
panelId: string;
panelType: PANEL_TYPES;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
/** From `spec.axes` — drives the Y scale and (when log) both scales' base. */
isLogScale?: boolean;
softMin?: number;
softMax?: number;
/** From `spec.formatting.unit` — drives Y axis tick formatting + threshold formatting. */
formatting?: DashboardtypesPanelFormattingDTO;
/** From `spec.thresholds` — perses shape, mapped to the draw-hook shape internally. */
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
/** Query-range response — used by the click plugin to map pixel → data. */
apiResponse: MetricQueryRangeSuccessResponse | undefined;
/** Time-range clamps for the X scale (typically from `getTimeRange(apiResponse)`). */
minTimeScale?: number;
maxTimeScale?: number;
/** Optional — histogram and other non-time panels omit drag-to-zoom. */
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
}
/**
* Builds the panel-agnostic scaffolding of a uPlot chart: scales, thresholds,
* axes, drag-to-zoom, click plugin. Callers (TimeSeriesPanel, BarPanel, …)
* then call `addSeries`/`addPlugin` on the returned builder for their own
* panel-specific rendering.
*/
export function buildBaseConfig({
panelId,
panelType,
isDarkMode,
timezone,
panelMode,
isLogScale,
softMin,
softMax,
formatting,
thresholds,
apiResponse,
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
}: BuildBaseConfigArgs): UPlotConfigBuilder {
const yAxisUnit = formatting?.unit;
const builder = new UPlotConfigBuilder({
id: panelId,
onDragSelect,
tzDate: makeTzDate(timezone),
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
stepInterval: resolveStepInterval(apiResponse),
});
const thresholdOptions: ThresholdsDrawHookOptions = {
scaleKey: 'y',
thresholds: mapThresholds(thresholds),
yAxisUnit,
};
builder.addThresholds(thresholdOptions);
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
builder.addScale({
scaleKey: 'y',
time: false,
min: undefined,
max: undefined,
softMin,
softMax,
thresholds: thresholdOptions,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
: DistributionType.Linear,
});
if (typeof onClick === 'function') {
builder.addPlugin(
onClickPlugin({ onClick, apiResponse: apiResponse?.payload }),
);
}
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
isLogScale,
panelType,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;
}
function makeTzDate(
timezone: Timezone,
): ((timestamp: number) => Date) | undefined {
if (!timezone) {
return undefined;
}
return (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
}
function resolveSelectionPreferencesSource(
panelMode: PanelMode,
): SelectionPreferencesSource {
return panelMode === PanelMode.DASHBOARD_VIEW ||
panelMode === PanelMode.STANDALONE_VIEW
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY;
}
// Perses-shape thresholds → the draw-hook shape uPlotV2 consumes. Exported so
// panels that need to feed the same threshold list elsewhere (e.g. to a series
// `addSeries` thresholds hook) don't have to redo the mapping.
export function mapThresholds(
thresholds: DashboardtypesThresholdWithLabelDTO[] | null | undefined,
): ThresholdsDrawHookOptions['thresholds'] {
if (!thresholds || thresholds.length === 0) {
return [];
}
return thresholds.map((t) => ({
thresholdValue: t.value,
thresholdColor: t.color,
thresholdUnit: t.unit,
thresholdLabel: t.label,
}));
}
/**
* V5 backend reports per-query step intervals; we feed the smallest one through
* to uPlot so the X-axis tick density matches the densest query. An empty map
* yields `Infinity` from `Math.min`, which would corrupt downstream scale math —
* fall back to `undefined` (uPlot's "auto") in that case.
*/
export function resolveStepInterval(
apiResponse: MetricQueryRangeSuccessResponse | undefined,
): number | undefined {
const stepIntervals =
apiResponse?.payload?.data?.newResult?.meta?.stepIntervals;
if (!stepIntervals) {
return undefined;
}
const values = Object.values(stepIntervals);
if (values.length === 0) {
return undefined;
}
const min = Math.min(...values);
return Number.isFinite(min) ? min : undefined;
}

View File

@@ -0,0 +1,108 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
DashboardtypesPrecisionOptionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
/**
* Bridges the V2 dashboard wire-format enums (snake_case, generated from Go)
* to the uPlotV2 chart enums (PascalCase). String values diverge between the
* two — don't coerce, map.
*
* Kept as a single source of truth so every panel that reads chart-appearance
* fields stays in sync as either side's enum evolves.
*/
export const LINE_STYLE_MAP: Record<DashboardtypesLineStyleDTO, LineStyle> = {
[DashboardtypesLineStyleDTO.solid]: LineStyle.Solid,
[DashboardtypesLineStyleDTO.dashed]: LineStyle.Dashed,
};
export const LINE_INTERPOLATION_MAP: Record<
DashboardtypesLineInterpolationDTO,
LineInterpolation
> = {
[DashboardtypesLineInterpolationDTO.linear]: LineInterpolation.Linear,
[DashboardtypesLineInterpolationDTO.spline]: LineInterpolation.Spline,
[DashboardtypesLineInterpolationDTO.step_after]: LineInterpolation.StepAfter,
[DashboardtypesLineInterpolationDTO.step_before]: LineInterpolation.StepBefore,
};
export const FILL_MODE_MAP: Record<DashboardtypesFillModeDTO, FillMode> = {
[DashboardtypesFillModeDTO.solid]: FillMode.Solid,
[DashboardtypesFillModeDTO.gradient]: FillMode.Gradient,
[DashboardtypesFillModeDTO.none]: FillMode.None,
};
export const LEGEND_POSITION_MAP: Record<
DashboardtypesLegendPositionDTO,
LegendPosition
> = {
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
};
/**
* `spec.formatting.decimalPrecision` is a stringified-digit enum on the wire
* (`'0'``'4'` plus the sentinel `'full'`). The chart consumes a numeric
* `PrecisionOption` (`0``4`) or the same `'full'` sentinel from its own
* enum. Missing / unknown → `undefined` (chart uses its default).
*/
export function resolveDecimalPrecision(
precision: DashboardtypesPrecisionOptionDTO | undefined,
): PrecisionOption | undefined {
if (!precision) {
return undefined;
}
if (precision === DashboardtypesPrecisionOptionDTO.full) {
return PrecisionOptionsEnum.FULL;
}
const parsed = Number(precision);
if (
parsed === 0 ||
parsed === 1 ||
parsed === 2 ||
parsed === 3 ||
parsed === 4
) {
return parsed;
}
return undefined;
}
/**
* `spec.chartAppearance.spanGaps.fillLessThan` is a stringified number on the
* wire. Empty / missing → span all gaps (the chart default). Numeric → forward
* the threshold so uPlot only bridges short runs of nulls.
*/
export function resolveSpanGaps(
fillLessThan: string | undefined,
): boolean | number {
if (!fillLessThan) {
return true;
}
const parsed = Number(fillLessThan);
return Number.isFinite(parsed) ? parsed : true;
}
/**
* Resolves the legend position for a panel. Missing / unknown values fall
* back to `BOTTOM` to match the chart's default and the V1 behavior.
*/
export function resolveLegendPosition(
position: DashboardtypesLegendPositionDTO | undefined,
): LegendPosition {
if (position && position in LEGEND_POSITION_MAP) {
return LEGEND_POSITION_MAP[position];
}
return LegendPosition.BOTTOM;
}

View File

@@ -0,0 +1,38 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into the list of builder queries it contains —
* unwrapping `CompositeQuery` envelopes along the way. Non-builder kinds
* (PromQL, ClickHouseSQL, Formula, TraceOperator) are dropped: they don't
* carry the legend / groupBy / aggregation context downstream code needs.
*
* Returns the generated v5 `BuilderQuery` shape directly — no intermediate
* summary type — so callers consume the same type the wire format defines.
*/
export function getBuilderQueries(
queries: DashboardtypesQueryDTO[] | undefined,
): BuilderQuery[] {
if (!queries) {
return [];
}
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope?.spec?.plugin;
if (!plugin) {
return;
}
if (plugin.kind === 'signoz/BuilderQuery') {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries ?? []).forEach((sub) => {
if (sub.type === 'builder_query') {
flattened.push(sub.spec as BuilderQuery);
}
});
}
});
return flattened;
}

View File

@@ -0,0 +1,112 @@
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { QueryData } from 'types/api/widgets/getQuery';
/**
* Resolves the display label for one series, applying the V1 legend matrix:
* `single-vs-many builder queries × with/without groupBy × single-vs-many
* aggregations`. Returns `baseLabel` unchanged for panels without builder
* queries (PromQL, ClickHouseSQL) and for builder series whose aggregation
* carries no alias/expression — metric aggregations don't have those fields,
* so they naturally short-circuit to the base label here.
*/
export function resolveSeriesLabel(
series: QueryData,
builderQueries: BuilderQuery[],
baseLabel: string,
): string {
if (builderQueries.length === 0) {
return baseLabel;
}
const matching = builderQueries.find((q) => q.name === series.queryName);
if (!matching) {
return baseLabel;
}
// `series.metaData.index` points to the aggregation in the matched query
// that produced this series. Default to 0 so single-aggregation panels
// still resolve when the backend omits the field.
const aggIndex = series.metaData?.index ?? 0;
const aggregations = matching.aggregations ?? [];
const aggregation = aggregations[aggIndex];
// `alias` / `expression` exist on Log/Trace aggregations only —
// `MetricAggregation` carries `metricName`/`temporality`/… instead. The
// `in` guards narrow the union without a cast.
const aggregationAlias =
aggregation && 'alias' in aggregation ? (aggregation.alias ?? '') : '';
const aggregationExpression =
aggregation && 'expression' in aggregation
? (aggregation.expression ?? '')
: '';
if (!aggregationAlias && !aggregationExpression) {
return baseLabel || series.metaData?.queryName || matching.name || '';
}
const ctx: FormatContext = {
aggregationAlias,
aggregationExpression,
baseLabel,
legend: matching.legend ?? '',
hasGroupBy: (matching.groupBy?.length ?? 0) > 0,
singleAggregation: aggregations.length === 1,
};
return builderQueries.length === 1
? formatForSinglePanelQuery(ctx)
: formatForMultiplePanelQueries(ctx);
}
interface FormatContext {
aggregationAlias: string;
aggregationExpression: string;
baseLabel: string;
legend: string;
hasGroupBy: boolean;
singleAggregation: boolean;
}
// Panel has one builder query — ports V1's `getLegendForSingleAggregation`.
function formatForSinglePanelQuery({
aggregationAlias,
aggregationExpression,
baseLabel,
legend,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || legend || aggregationExpression;
}
return aggregationAlias || aggregationExpression;
}
// Panel has multiple builder queries — ports V1's `getLegendForMultipleAggregations`.
// Differs from the single-query path in two cells: the no-groupBy / single-agg
// cell falls through to `baseLabel` instead of `legend`, and the no-groupBy /
// multi-agg cell prepends the base label.
function formatForMultiplePanelQueries({
aggregationAlias,
aggregationExpression,
baseLabel,
hasGroupBy,
singleAggregation,
}: FormatContext): string {
if (hasGroupBy) {
if (singleAggregation) {
return baseLabel;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}
if (singleAggregation) {
return aggregationAlias || baseLabel || aggregationExpression;
}
return `${aggregationAlias || aggregationExpression}-${baseLabel}`;
}

View File

@@ -36,6 +36,14 @@
margin-inline-end: 0;
}
// Actions sit inside the drag-handle row but opt out of dragging
// (`panel-no-drag`); reset the grab cursor so the menu reads as clickable.
.actions {
display: flex;
align-items: center;
cursor: default;
}
.body {
flex: 1;
display: flex;
@@ -50,3 +58,41 @@
.bodyKind {
margin-bottom: 6px;
}
// Container for the rendered chart — fills the panel below the header and lets
// the chart shrink (min-* 0) so it resizes with the grid cell.
.chartBody {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
}
// Subtle background-refetch spinner in the header (chart stays mounted).
.refetchIndicator {
color: var(--l2-foreground);
flex-shrink: 0;
}
// Error state — shown only when there's no stale data to fall back to.
.error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
text-align: center;
}
.errorIcon {
color: var(--bg-cherry-500, #e5484d);
}
.errorMessage {
color: var(--l2-foreground);
font-size: 12px;
max-width: 90%;
overflow-wrap: anywhere;
}

View File

@@ -1,15 +1,15 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import PanelBody from './PanelBody';
import PanelHeader from './PanelHeader';
import styles from './Panel.module.scss';
/** Panel action context — present together only in editable sectioned mode. */
@@ -23,16 +23,18 @@ export interface PanelActionsConfig {
interface PanelProps {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
/** True once this panel's section enters the viewport — gates the fetch. */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* A single dashboard panel: chrome (header) + content (body). Thin orchestrator
* — data fetching lives in `usePanelQuery`, cross-panel interactions in
* `usePanelInteractions`, and the loading/error/chart state machine in
* `PanelBody`.
*/
function Panel({
panel,
panelId,
@@ -41,9 +43,22 @@ function Panel({
}: PanelProps): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const fullKind = panel?.spec?.plugin?.kind;
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const panelDef = getPanelDefinition(fullKind);
const { data, isLoading, isFetching, error, refetch } = usePanelQuery({
panel,
panelId,
// Lazy: only fetch once the section is on screen (undefined → treat as
// visible) and a renderer exists for the kind.
enabled: !!panelDef && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const headerTitle = useMemo(() => {
if (!description) {
return name;
@@ -60,35 +75,26 @@ function Panel({
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
<PanelHeader
title={headerTitle}
kind={kind}
panelId={panelId}
isFetching={isFetching}
panelActions={panelActions}
/>
<PanelBody
panelDef={panelDef}
panel={panel}
panelId={panelId}
kind={kind}
queryCount={queryCount}
data={data}
isLoading={isLoading}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
/>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { Spin } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Loader, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type {
DashboardPreference,
RenderablePanelDefinition,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import styles from './Panel.module.scss';
interface PanelBodyProps {
/** Resolved renderer for the panel kind; undefined when the kind is unknown. */
panelDef: RenderablePanelDefinition | undefined;
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
kind: string;
queryCount: number;
data: MetricQueryRangeSuccessResponse | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
onDragSelect: (start: number, end: number) => void;
dashboardPreference: DashboardPreference;
}
/**
* Renders the panel content as an explicit state machine so each state is
* handled deliberately (no implicit fall-through):
*
* unknown-kind → unsupported fallback
* error + no data → error message with retry
* first load (no data) → loading indicator
* otherwise → the kind's renderer (which owns its own "No Data" state, and
* keeps stale data mounted during background refetches)
*/
function PanelBody({
panelDef,
panel,
panelId,
kind,
queryCount,
data,
isLoading,
error,
refetch,
onDragSelect,
dashboardPreference,
}: PanelBodyProps): JSX.Element {
if (!panelDef) {
return (
<div className={styles.body} data-testid="panel-unknown-kind-fallback">
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet supported
in V2
</div>
</div>
</div>
);
}
// Surface a hard failure only when there's no (stale) data to show; otherwise
// keep the last-good chart and let the header indicate the refresh.
if (error && !data) {
return (
<div className={styles.error} data-testid="panel-error">
<TriangleAlert size={20} className={styles.errorIcon} />
<Typography.Text className={styles.errorMessage}>
{error.message || 'Failed to load panel data'}
</Typography.Text>
<Button variant="outlined" color="secondary" onClick={refetch}>
Retry
</Button>
</div>
);
}
// First load only — background refetches keep `data` populated so the chart
// stays mounted instead of blinking.
if (isLoading && !data) {
return (
<div className={styles.body} data-testid="panel-loading">
<Spin indicator={<Loader size={14} className="animate-spin" />} />
</div>
);
}
return (
<div className={styles.chartBody}>
<panelDef.Renderer
panelId={panelId}
panel={panel}
data={data}
isLoading={isLoading}
error={error}
onDragSelect={onDragSelect}
panelMode={PanelMode.DASHBOARD_VIEW}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
/>
</div>
);
}
export default PanelBody;

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical, Loader } from '@signozhq/icons';
import cx from 'classnames';
import type { PanelActionsConfig } from './Panel';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface PanelHeaderProps {
title: ReactNode;
kind: string;
panelId: string;
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
isFetching: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/** Panel chrome: drag handle, title, kind badge, refetch indicator, actions. */
function PanelHeader({
title,
kind,
panelId,
isFetching,
panelActions,
}: PanelHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
{isFetching && (
<Loader
size={12}
className={cx('animate-spin', styles.refetchIndicator)}
data-testid="panel-refetching"
/>
)}
</div>
{/* `panel-no-drag` opts this region out of the grid drag handle so the
actions menu is clickable instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{panelActions ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={panelActions.currentLayoutIndex}
sections={panelActions.sections}
onMovePanel={panelActions.onMovePanel}
onDeletePanel={panelActions.onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
</div>
);
}
export default PanelHeader;

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
import { useDispatch } from 'react-redux';
import { useLocation, useParams } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { UpdateTimeInterval } from 'store/actions';
export interface PanelInteractions {
/**
* Drag-select a time range on a chart → write the window to the URL + global
* time so every panel re-fetches against the same range.
*/
onDragSelect: (start: number, end: number) => void;
/**
* Dashboard-wide rendering preferences (cursor sync, tooltip filter) keyed
* off the dashboard id from the route.
*/
dashboardPreference: DashboardPreference;
}
/**
* Encapsulates the cross-panel interactions shared by every dashboard-view
* panel: drag-to-zoom time selection and the cursor-sync / tooltip-filter
* preferences. Keeping this out of the `Panel` component keeps the component a
* thin render orchestrator and lets the wiring be unit-tested in isolation.
*/
export function usePanelInteractions(): PanelInteractions {
const dispatch = useDispatch();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { dashboardId } = useParams<{ dashboardId: string }>();
const [syncMode] = useDashboardCursorSyncMode(
dashboardId,
PanelMode.DASHBOARD_VIEW,
);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
const dashboardPreference = useMemo<DashboardPreference>(
() => ({ syncMode, syncFilterMode, dashboardId }),
[syncMode, syncFilterMode, dashboardId],
);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
safeNavigate(`${pathname}?${urlQuery.toString()}`);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
},
[dispatch, pathname, safeNavigate, urlQuery],
);
return { onDragSelect, dashboardPreference };
}

View File

@@ -54,6 +54,7 @@ function SectionGrid({
useCSSTransforms
layout={rglLayout}
draggableHandle=".panel-drag-handle"
draggableCancel=".panel-no-drag"
isDraggable={isEditable}
isResizable={isEditable}
onDragStop={handleLayoutChange}

View File

@@ -0,0 +1,288 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { usePanelQuery } from '../../hooks/usePanelQuery';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
useGetQueryRange: jest.fn(),
}));
const mockUseSelector = useSelector as unknown as jest.Mock;
const mockUseGetQueryRange = useGetQueryRange as unknown as jest.Mock;
// ---- helpers ---------------------------------------------------------------
// Test fixtures are cast at the outer boundary; the perses-generated panel and
// query plugin unions are too verbose to construct field-typed inline.
function builderPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function listPanelWithLogs(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ListPanel', spec: {} },
queries: [
{
kind: 'LogQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'logs',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function emptyPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
function histogramPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/HistogramPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
function barPanel(): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/BarChartPanel', spec: {} },
queries: [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
},
},
},
},
],
},
} as unknown as DashboardtypesPanelDTO;
}
const DEFAULT_GLOBAL_TIME = {
selectedTime: 'GLOBAL_TIME',
minTime: 1000,
maxTime: 2000,
isAutoRefreshDisabled: false,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseSelector.mockImplementation((selector: unknown) => {
// usePanelQuery passes a selector `(state) => state.globalTime`.
return (
selector as (state: { globalTime: typeof DEFAULT_GLOBAL_TIME }) => unknown
)({ globalTime: DEFAULT_GLOBAL_TIME });
});
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
});
});
// ---- tests -----------------------------------------------------------------
describe('usePanelQuery', () => {
it('runs fromPerses on the panel queries and forwards the V1 Query to useGetQueryRange', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(requestArg.query.builder.queryData).toHaveLength(1);
expect(requestArg.query.builder.queryData[0].queryName).toBe('A');
});
it('derives graphType=TIME_SERIES for a TimeSeries panel', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('derives graphType=LIST for a ListPanel', () => {
renderHook(() =>
usePanelQuery({ panel: listPanelWithLogs(), panelId: 'p1' }),
);
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.LIST);
});
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (matches V1's
// `getGraphType` translation). Without this remap, V5's
// `mapPanelTypeToRequestType` would send `distribution` for histograms,
// returning a shape `prepareHistogramData` can't bin.
it('remaps graphType=HISTOGRAM to TIME_SERIES (V1 parity)', () => {
renderHook(() => usePanelQuery({ panel: histogramPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('remaps graphType=BAR to TIME_SERIES (V1 parity)', () => {
renderHook(() => usePanelQuery({ panel: barPanel(), panelId: 'p1' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.graphType).toBe(PANEL_TYPES.TIME_SERIES);
});
it('passes V5 entity version to useGetQueryRange', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [, version] = mockUseGetQueryRange.mock.calls[0];
expect(version).toBe(ENTITY_VERSION_V5);
});
it('exposes data/error from useGetQueryRange', () => {
mockUseGetQueryRange.mockReturnValue({
data: { payload: 'X' } as unknown,
isLoading: false,
isFetching: false,
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.data).toStrictEqual({ payload: 'X' });
expect(result.current.error?.message).toBe('boom');
});
it('combines isLoading and isFetching into a single isLoading flag', () => {
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: true,
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.isLoading).toBe(true);
});
it('coerces a missing/undefined error to null', () => {
mockUseGetQueryRange.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to useGetQueryRange when the caller disables it', () => {
renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
);
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.enabled).toBe(false);
});
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
);
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.enabled).toBe(false);
});
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const [, , opts] = mockUseGetQueryRange.mock.calls[0];
expect(opts.queryKey).toStrictEqual(
expect.arrayContaining([
'p1',
DEFAULT_GLOBAL_TIME.minTime,
DEFAULT_GLOBAL_TIME.maxTime,
DEFAULT_GLOBAL_TIME.selectedTime,
'signoz/TimeSeriesPanel',
panel.spec?.queries,
]),
);
});
it('forwards the inflated empty composite to useGetQueryRange when panel is undefined (no crash)', () => {
renderHook(() => usePanelQuery({ panel: undefined, panelId: 'p-none' }));
const [requestArg] = mockUseGetQueryRange.mock.calls[0];
expect(requestArg.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(requestArg.query.builder.queryData).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,130 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryAdapter/fromPerses';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { AppState } from 'store/reducers';
import type { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../Panels/types';
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Gate the underlying fetch. Defaults to true. PanelV2 sets this false when
* no plugin is registered for the panel's kind so the unknown-kind fallback
* UI doesn't trigger a wasted API call.
*
* The hook *also* auto-disables internally when the panel has no queries —
* callers don't need to compute that themselves.
*/
enabled?: boolean;
}
export interface UsePanelQueryResult {
data: MetricQueryRangeSuccessResponse | undefined;
/** Combines `isLoading` (first fetch) and `isFetching` (background refresh). */
isLoading: boolean;
/** Background refresh in flight while data is already present. */
isFetching: boolean;
error: Error | null;
/** Re-run the query (e.g. a retry button on the error state). */
refetch: () => void;
}
/**
* Fetches the query-range data for a V2 panel.
*
* Encapsulates three concerns the dashboard shell otherwise has to wire by
* hand at every call site:
*
* 1. Adapter — runs `fromPerses` on the panel's perses queries to produce
* the V1 in-memory Query shape `useGetQueryRange` still consumes
* internally. This is a fetch-time detail; the V1 Query is not surfaced
* to callers — renderers that need it derive it themselves from
* `panel.spec.queries` (single source of truth).
* 2. Time + variables — reads the global time selection from Redux
* (variables substitution is intentionally deferred until V2 has its
* own variable plumbing).
* 3. Fetch — calls `useGetQueryRange` with the v5 entity version and a
* react-query cache key composed from panel identity + time range +
* kind + queries so cache invalidation matches the inputs that affect
* the result.
*
* The hook is consumed today by PanelV2 (renderer dispatch) and will be
* consumed by PanelEditor (1.8) for "preview as you edit."
*/
export function usePanelQuery({
panel,
panelId,
enabled = true,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel?.spec?.plugin?.kind;
// HISTOGRAM and BAR panels both bin/derive from raw time-series data
// client-side, so the backend `requestType` for them is `time_series`.
// `getGraphType` encodes the V1-established mapping — using it keeps
// V2 in lockstep with how the API has always been called.
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
const graphType = getGraphType(panelType);
const query = useMemo(
() => fromPerses(panel?.spec?.queries ?? []),
[panel?.spec?.queries],
);
const {
selectedTime: globalSelectedInterval,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const hasQuery = useMemo(() => {
return (
query.builder.queryData.length > 0 ||
query.promql.length > 0 ||
query.clickhouse_sql.length > 0
);
}, [query]);
const response = useGetQueryRange(
{
query,
graphType,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
},
ENTITY_VERSION_V5,
{
queryKey: [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
panelId,
minTime,
maxTime,
globalSelectedInterval,
fullKind,
panel?.spec?.queries,
],
enabled: enabled && hasQuery,
},
);
return {
data: response.data,
isLoading: response.isLoading || response.isFetching,
isFetching: response.isFetching,
// Coerce undefined → null so the contract is `Error | null`, not
// `Error | null | undefined`. Consumers can rely on a single
// "no error" sentinel.
error: (response.error as Error | null) ?? null,
refetch: response.refetch,
};
}

View File

@@ -4,7 +4,7 @@ import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import type { GridItem } from './utils';
@@ -16,7 +16,7 @@ import type { GridItem } from './utils';
* patches in DashboardSettings/General and DashboardDescription).
*/
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
const { add, replace, remove } = DashboardtypesPatchOpDTO;
const PANEL_REF_PREFIX = '#/spec/panels/';

View File

@@ -0,0 +1,741 @@
import {
Querybuildertypesv5RequestTypeDTO,
type DashboardtypesQueryDTO,
type DashboardtypesQueryPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { fromPerses } from '../fromPerses';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
// ---- helpers ---------------------------------------------------------------
const EMPTY_INFLATED = {
id: '',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
function persesBuilderDTO(
name = 'A',
signal: 'metrics' | 'logs' | 'traces' = 'metrics',
extra: Record<string, unknown> = {},
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name,
signal,
disabled: false,
filter: { expression: '' },
...extra,
},
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
function extractPlugin(dto: DashboardtypesQueryDTO): {
kind: string;
spec: Record<string, unknown>;
} {
const plugin = dto.spec?.plugin;
if (!plugin) {
throw new Error('missing plugin on perses DTO');
}
return plugin as unknown as { kind: string; spec: Record<string, unknown> };
}
// ---- Phase 1: empty / unknown contract -------------------------------------
describe('fromPerses (Phase 1 — empty / unknown-input contract)', () => {
it('returns the fully-inflated empty composite on empty input', () => {
expect(fromPerses([])).toStrictEqual(EMPTY_INFLATED);
});
it('returns the fully-inflated empty composite when plugin is missing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
it('returns the fully-inflated empty composite on unknown plugin kind', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/NotAThing',
spec: {},
} as unknown as DashboardtypesQueryPluginDTO,
},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
});
// ---- Phase 2: bare BuilderQuery --------------------------------------------
describe('fromPerses (Phase 2 — bare signoz/BuilderQuery)', () => {
it('unwraps a bare BuilderQuery into a single inflated queryData entry', () => {
const result = fromPerses([persesBuilderDTO('A', 'metrics')]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
expect(result.promql).toStrictEqual([]);
expect(result.clickhouse_sql).toStrictEqual([]);
const q = result.builder.queryData[0];
expect(q.queryName).toBe('A');
expect(q.expression).toBe('A');
});
it('maps signal "logs" to DataSource.LOGS', () => {
const q = fromPerses([persesBuilderDTO('A', 'logs')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.LOGS);
});
it('maps signal "traces" to DataSource.TRACES', () => {
const q = fromPerses([persesBuilderDTO('A', 'traces')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.TRACES);
});
it('maps signal "metrics" to DataSource.METRICS', () => {
const q = fromPerses([persesBuilderDTO('A', 'metrics')]).builder.queryData[0];
expect(q.dataSource).toBe(DataSource.METRICS);
});
it('applies inverse groupBy renames (name->key, fieldDataType->dataType, fieldContext->type)', () => {
const dto = persesBuilderDTO('A', 'metrics', {
groupBy: [
{
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'tag',
},
],
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.groupBy[0]).toMatchObject({
key: 'service.name',
dataType: 'string',
type: 'tag',
});
});
it('applies inverse orderBy renames (key.name->columnName, direction->order)', () => {
const dto = persesBuilderDTO('A', 'metrics', {
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.orderBy).toStrictEqual([{ columnName: 'timestamp', order: 'desc' }]);
});
it('preserves filter.expression', () => {
const dto = persesBuilderDTO('A', 'metrics', {
filter: { expression: 'service.name = "api"' },
});
const q = fromPerses([dto]).builder.queryData[0];
expect(q.filter).toStrictEqual({ expression: 'service.name = "api"' });
});
it('coerces v5 stepInterval string (e.g. "30s") to null without crashing', () => {
const dto = persesBuilderDTO('A', 'metrics', { stepInterval: '30s' });
const q = fromPerses([dto]).builder.queryData[0];
expect(q.stepInterval).toBeNull();
});
it('keeps v5 stepInterval as-is when it is already a number', () => {
const dto = persesBuilderDTO('A', 'metrics', { stepInterval: 60 });
const q = fromPerses([dto]).builder.queryData[0];
expect(q.stepInterval).toBe(60);
});
});
// ---- Phase 2: round-trip ---------------------------------------------------
describe('round-trip (Phase 2 — bare BuilderQuery): perses → fromPerses → toPerses → perses', () => {
it('preserves a metrics builder with filter + groupBy + order', () => {
const original = persesBuilderDTO('A', 'metrics', {
filter: { expression: 'service.name = "api"' },
groupBy: [
{ name: 'service.name', fieldDataType: 'string', fieldContext: 'tag' },
],
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(
Querybuildertypesv5RequestTypeDTO.time_series,
);
const origPlugin = extractPlugin(original);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe(origPlugin.kind);
expect(rtPlugin.spec).toMatchObject({
name: 'A',
signal: 'metrics',
filter: { expression: 'service.name = "api"' },
groupBy: origPlugin.spec.groupBy,
order: origPlugin.spec.order,
});
});
it('preserves a logs builder routed to a LIST panel (outer kind raw)', () => {
const original = persesBuilderDTO('A', 'logs', {
aggregations: [{ expression: 'count()' }],
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.LIST,
});
expect(roundTripped[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.raw);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/BuilderQuery');
expect(rtPlugin.spec).toMatchObject({ name: 'A', signal: 'logs' });
});
});
// ---- Phase 3: CompositeQuery distribution ----------------------------------
function compositeDTO(
subqueries: Array<{ type: string; spec: Record<string, unknown> }>,
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: subqueries },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
describe('fromPerses (Phase 3 — signoz/CompositeQuery)', () => {
it('distributes multiple builder_query subqueries into builder.queryData', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_query',
spec: { name: 'B', signal: 'metrics', filter: { expression: '' } },
},
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual([
'A',
'B',
]);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
});
it('handles an empty CompositeQuery (queries: []) by returning the inflated empty shape', () => {
const dto = compositeDTO([]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toStrictEqual([]);
});
});
// ---- Phase 3: round-trip ---------------------------------------------------
describe('round-trip (Phase 3 — multi-builder CompositeQuery): perses → fromPerses → toPerses → perses', () => {
it('preserves a two-builder CompositeQuery (same outer kind, same subquery names)', () => {
const original = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_query',
spec: { name: 'B', signal: 'metrics', filter: { expression: '' } },
},
]);
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(
Querybuildertypesv5RequestTypeDTO.time_series,
);
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(subqueries.map((s) => `${s.type}:${s.spec.name}`)).toStrictEqual([
'builder_query:A',
'builder_query:B',
]);
});
});
// ---- Phase 4: bare Formula + TraceOperator + composite subqueries ----------
function bareDTO(
pluginKind: string,
spec: Record<string, unknown>,
): DashboardtypesQueryDTO {
return {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: pluginKind,
spec,
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
describe('fromPerses (Phase 4 — invalid bare Formula / TraceOperator are warned and dropped)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('warns and returns inflated empty composite on top-level signoz/Formula (invalid alone)', () => {
const dto = bareDTO('signoz/Formula', {
name: 'F1',
expression: 'A + 1',
legend: 'L',
});
const result = fromPerses([dto]);
expect(result.builder.queryFormulas).toStrictEqual([]);
expect(result.builder.queryData).toStrictEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(/top-level signoz\/Formula is invalid/),
);
});
it('warns and returns inflated empty composite on top-level signoz/TraceOperator (invalid alone)', () => {
const dto = bareDTO('signoz/TraceOperator', {
name: 'T1',
signal: 'traces',
expression: 'A && B',
filter: { expression: '' },
});
const result = fromPerses([dto]);
expect(result.builder.queryTraceOperator).toStrictEqual([]);
expect(result.builder.queryData).toStrictEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(/top-level signoz\/TraceOperator is invalid/),
);
});
});
describe('fromPerses (Phase 4 — composite subquery distribution)', () => {
it('distributes builder_query + builder_formula + builder_trace_operator subqueries into their respective buckets', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
{
type: 'builder_trace_operator',
spec: {
name: 'T1',
signal: 'traces',
expression: 'A',
filter: { expression: '' },
},
},
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.builder.queryFormulas.map((f) => f.queryName)).toStrictEqual([
'F1',
]);
expect(
result.builder.queryTraceOperator.map((t) => t.queryName),
).toStrictEqual(['T1']);
});
});
// ---- Phase 4: round-trips --------------------------------------------------
describe('round-trip (Phase 4): mixed composite', () => {
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
(console.warn as jest.Mock).mockRestore?.();
});
it('round-trips a CompositeQuery containing builder + formula + trace operator', () => {
const original = compositeDTO([
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
disabled: false,
},
},
{
type: 'builder_formula',
spec: { name: 'F1', expression: 'A + 1', disabled: false },
},
{
type: 'builder_trace_operator',
spec: {
name: 'T1',
signal: 'traces',
expression: 'A',
filter: { expression: '' },
disabled: false,
aggregations: [{ expression: 'count()' }],
},
},
]);
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(
subqueries.map((s) => `${s.type}:${s.spec.name}`).sort(),
).toStrictEqual(
[
'builder_query:A',
'builder_formula:F1',
'builder_trace_operator:T1',
].sort(),
);
});
});
// ---- Phase 5: PromQL + ClickHouseSQL + composite queryType resolution ------
describe('fromPerses (Phase 5 — bare signoz/PromQLQuery)', () => {
it('unwraps bare PromQL into promql[0] and sets queryType=PROM', () => {
const dto = bareDTO('signoz/PromQLQuery', {
name: 'P1',
query: 'up',
disabled: false,
legend: 'L',
});
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.PROM);
expect(result.promql).toStrictEqual([
{ name: 'P1', query: 'up', disabled: false, legend: 'L' },
]);
expect(result.builder.queryData).toStrictEqual([]);
expect(result.clickhouse_sql).toStrictEqual([]);
});
it('defaults disabled to false and legend to empty when absent on wire', () => {
const dto = bareDTO('signoz/PromQLQuery', { name: 'P1', query: 'up' });
const result = fromPerses([dto]);
expect(result.promql[0]).toStrictEqual({
name: 'P1',
query: 'up',
disabled: false,
legend: '',
});
});
});
describe('fromPerses (Phase 5 — bare signoz/ClickHouseSQL)', () => {
it('unwraps bare ClickHouseSQL into clickhouse_sql[0] and sets queryType=CLICKHOUSE', () => {
const dto = bareDTO('signoz/ClickHouseSQL', {
name: 'C1',
query: 'SELECT 1',
disabled: false,
legend: '',
});
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.CLICKHOUSE);
expect(result.clickhouse_sql).toStrictEqual([
{ name: 'C1', query: 'SELECT 1', disabled: false, legend: '' },
]);
expect(result.builder.queryData).toStrictEqual([]);
expect(result.promql).toStrictEqual([]);
});
});
describe('fromPerses (Phase 4 — composite without builder warns about invalid state)', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('warns when CompositeQuery has formulas/trace-ops but no builder_query subquery', () => {
const dto = compositeDTO([
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
]);
fromPerses([dto]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/CompositeQuery contains formulas\/trace-operators but no builder_query/,
),
);
});
it('does not warn when CompositeQuery has at least one builder_query alongside the formula', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'builder_formula', spec: { name: 'F1', expression: 'A + 1' } },
]);
fromPerses([dto]);
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe('fromPerses (Phase 5 — composite queryType resolution)', () => {
it('resolves all-promql composite to queryType=PROM', () => {
const dto = compositeDTO([
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
{ type: 'promql', spec: { name: 'P2', query: 'rate(x[1m])' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.PROM);
expect(result.promql.map((p) => p.name)).toStrictEqual(['P1', 'P2']);
});
it('resolves all-clickhouse composite to queryType=CLICKHOUSE', () => {
const dto = compositeDTO([
{ type: 'clickhouse_sql', spec: { name: 'C1', query: 'SELECT 1' } },
{ type: 'clickhouse_sql', spec: { name: 'C2', query: 'SELECT 2' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.CLICKHOUSE);
expect(result.clickhouse_sql.map((c) => c.name)).toStrictEqual(['C1', 'C2']);
});
it('falls back to queryType=QUERY_BUILDER for a mixed-type composite', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
]);
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
expect(result.promql).toHaveLength(1);
});
});
// ---- Phase 5: round-trips --------------------------------------------------
describe('round-trip (Phase 5): PromQL / ClickHouseSQL bare + multi', () => {
it('round-trips bare signoz/PromQLQuery', () => {
const original = bareDTO('signoz/PromQLQuery', {
name: 'P1',
query: 'up',
disabled: false,
legend: 'L',
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/PromQLQuery');
expect(rtPlugin.spec).toMatchObject({
name: 'P1',
query: 'up',
legend: 'L',
});
});
it('round-trips bare signoz/ClickHouseSQL', () => {
const original = bareDTO('signoz/ClickHouseSQL', {
name: 'C1',
query: 'SELECT 1',
disabled: false,
legend: 'L',
});
const v1 = fromPerses([original]);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/ClickHouseSQL');
expect(rtPlugin.spec).toMatchObject({
name: 'C1',
query: 'SELECT 1',
legend: 'L',
});
});
it('round-trips an all-promql CompositeQuery (preserves PROM queryType)', () => {
const original = compositeDTO([
{ type: 'promql', spec: { name: 'P1', query: 'up' } },
{ type: 'promql', spec: { name: 'P2', query: 'rate(x[1m])' } },
]);
const v1 = fromPerses([original]);
expect(v1.queryType).toBe(EQueryType.PROM);
const roundTripped = toPerses({
query: v1,
graphType: PANEL_TYPES.TIME_SERIES,
});
const rtPlugin = extractPlugin(roundTripped[0]);
expect(rtPlugin.kind).toBe('signoz/CompositeQuery');
const subqueries = rtPlugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual(['promql', 'promql']);
});
});
// ---- Phase 6: edge cases ---------------------------------------------------
describe('fromPerses (Phase 6 — edge cases)', () => {
it('returns inflated empty composite when plugin is present but spec is missing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
// spec deliberately omitted
} as unknown as DashboardtypesQueryPluginDTO,
},
};
expect(fromPerses([dto])).toStrictEqual(EMPTY_INFLATED);
});
it('handles CompositeQuery with queries: null without crashing', () => {
const dto: DashboardtypesQueryDTO = {
kind: Querybuildertypesv5RequestTypeDTO.time_series,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: null },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder).toStrictEqual({
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
});
});
it('silently ignores composite subqueries with unrecognized types', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{ type: 'future_kind' as never, spec: { foo: 'bar' } },
]);
const result = fromPerses([dto]);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
});
it('skips composite subqueries that have no spec without crashing', () => {
const dto = compositeDTO([
{
type: 'builder_query',
spec: { name: 'A', signal: 'metrics', filter: { expression: '' } },
},
{
type: 'builder_formula',
spec: undefined as unknown as Record<string, unknown>,
},
]);
expect(() => fromPerses([dto])).not.toThrow();
const result = fromPerses([dto]);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.builder.queryFormulas).toStrictEqual([]);
});
it('only consumes the first persesQueries entry (defensive against backend invariant violations)', () => {
const builderDTO = bareDTO('signoz/BuilderQuery', {
name: 'A',
signal: 'metrics',
filter: { expression: '' },
});
const promDTO = bareDTO('signoz/PromQLQuery', { name: 'P1', query: 'up' });
const result = fromPerses([builderDTO, promDTO]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData.map((q) => q.queryName)).toStrictEqual(['A']);
expect(result.promql).toStrictEqual([]);
});
it('produces a sensible default V1 builder when the v5 spec is minimal (signal only)', () => {
const dto = bareDTO('signoz/BuilderQuery', { signal: 'metrics' });
const result = fromPerses([dto]);
expect(result.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.builder.queryData).toHaveLength(1);
const q = result.builder.queryData[0];
expect(q.dataSource).toBe(DataSource.METRICS);
// name is generated from the default builder when v5 spec lacks one
expect(typeof q.queryName).toBe('string');
expect(q.queryName.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,186 @@
/**
* Fixture-driven round-trip suite. Loads the canonical perses dashboard testdata
* the Go backend uses to validate its serialization, then for each panel runs:
*
* fromPerses(panel.queries) → V1 Query → toPerses(query, graphType) → DTO
*
* and asserts the structural fields that should survive the round trip. This
* covers real-world panel/query combinations the synthetic unit tests miss.
*/
import fs from 'fs';
import path from 'path';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types';
import { fromPerses } from '../fromPerses';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
const TESTDATA_PATH = path.join(
__dirname,
'../../../../../../../pkg/types/dashboardtypes/testdata/perses.json',
);
interface PersesPanel {
spec?: {
plugin?: { kind?: string };
queries?: DashboardtypesQueryDTO[];
};
}
interface PersesDashboardSpec {
panels?: Record<string, PersesPanel | null>;
}
interface FixturePanel {
panelId: string;
panelKind: string;
innerKind: string;
query: DashboardtypesQueryDTO;
}
function loadFixturePanels(): FixturePanel[] {
const raw = fs.readFileSync(TESTDATA_PATH, 'utf8');
const data = JSON.parse(raw) as PersesDashboardSpec;
const panels = data.panels ?? {};
return Object.entries(panels).flatMap(([panelId, panel]) => {
const panelKind = panel?.spec?.plugin?.kind;
const query = panel?.spec?.queries?.[0];
const innerKind = (query?.spec?.plugin as { kind?: string } | undefined)
?.kind;
if (!panelKind || !query || !innerKind) {
return [];
}
return [{ panelId, panelKind, innerKind, query }];
});
}
describe('round-trip: perses.json testdata → fromPerses → toPerses', () => {
const fixturePanels = loadFixturePanels();
it('testdata loaded with expected coverage', () => {
expect(fixturePanels.length).toBeGreaterThan(0);
// Sanity: at least TimeSeriesPanel + BuilderQuery is present in the testdata.
expect(
fixturePanels.some(
(p) =>
p.panelKind === 'signoz/TimeSeriesPanel' &&
p.innerKind === 'signoz/BuilderQuery',
),
).toBe(true);
});
it.each(fixturePanels)(
'panel $panelId ($panelKind / $innerKind) survives round-trip',
({ panelKind, query }) => {
const graphType =
PANEL_KIND_TO_PANEL_TYPE[panelKind as PanelKind] ?? PANEL_TYPES.TIME_SERIES;
const v1 = fromPerses([query]);
const roundTripped = toPerses({ query: v1, graphType });
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].kind).toBe(query.kind);
const origPlugin = (query.spec?.plugin ?? {}) as {
kind?: string;
spec?: Record<string, unknown>;
};
const rtPlugin = (roundTripped[0].spec?.plugin ?? {}) as {
kind?: string;
spec?: Record<string, unknown>;
};
expect(rtPlugin.kind).toBe(origPlugin.kind);
const oSpec = origPlugin.spec ?? {};
const rSpec = rtPlugin.spec ?? {};
switch (origPlugin.kind) {
case 'signoz/BuilderQuery':
expectBuilderShapePreserved(oSpec, rSpec);
break;
case 'signoz/CompositeQuery':
expectCompositeShapePreserved(oSpec, rSpec);
break;
case 'signoz/PromQLQuery':
case 'signoz/ClickHouseSQL':
expectNamedQueryPreserved(oSpec, rSpec);
break;
default:
break;
}
},
);
});
// ---- assertion helpers -----------------------------------------------------
function namesOrEmpty(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return (value as Array<{ name?: string }>).map((g) => g.name ?? '');
}
function orderColumnsOrEmpty(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return (value as Array<{ key?: { name?: string } }>).map(
(o) => o.key?.name ?? '',
);
}
function expectBuilderShapePreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
expect(roundTripped.name).toBe(original.name);
expect(roundTripped.signal).toBe(original.signal);
const origFilterExpr = (original.filter as { expression?: string } | undefined)
?.expression;
const rtFilterExpr = (
roundTripped.filter as { expression?: string } | undefined
)?.expression;
expect(rtFilterExpr ?? '').toBe(origFilterExpr ?? '');
expect(namesOrEmpty(roundTripped.groupBy)).toStrictEqual(
namesOrEmpty(original.groupBy),
);
expect(orderColumnsOrEmpty(roundTripped.order)).toStrictEqual(
orderColumnsOrEmpty(original.order),
);
}
function expectCompositeShapePreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
const origSubs =
(original.queries as Array<{ type: string }> | undefined) ?? [];
const rtSubs =
(roundTripped.queries as Array<{ type: string }> | undefined) ?? [];
expect(rtSubs).toHaveLength(origSubs.length);
expect(rtSubs.map((s) => s.type).sort()).toStrictEqual(
origSubs.map((s) => s.type).sort(),
);
}
function expectNamedQueryPreserved(
original: Record<string, unknown>,
roundTripped: Record<string, unknown>,
): void {
expect(roundTripped.name).toBe(original.name);
expect(roundTripped.query).toBe(original.query);
}

View File

@@ -0,0 +1,517 @@
import {
Querybuildertypesv5RequestTypeDTO,
type DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { toPerses } from '../toPerses';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: jest.fn(() => ({ start: '100', end: '200' })),
}));
// ---- helpers ---------------------------------------------------------------
function emptyQuery(): Query {
return {
id: 'q-empty',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
}
function metricsBuilderQuery(
overrides?: Partial<IBuilderQuery>,
): IBuilderQuery {
return {
queryName: 'A',
dataSource: DataSource.METRICS,
aggregations: [
{
metricName: 'cpu_usage',
temporality: '',
timeAggregation: 'sum',
spaceAggregation: 'avg',
reduceTo: ReduceOperators.AVG,
},
],
timeAggregation: 'sum',
spaceAggregation: 'avg',
temporality: '',
functions: [],
filter: { expression: '' },
filters: { items: [], op: 'AND' },
groupBy: [],
expression: 'A',
disabled: false,
having: [],
limit: null,
stepInterval: 60,
orderBy: [],
reduceTo: ReduceOperators.AVG,
legend: '',
...overrides,
} as IBuilderQuery;
}
function builderShell(builderQueries: IBuilderQuery[]): Query {
return {
id: 'q-test',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: builderQueries,
queryFormulas: [],
queryTraceOperator: [],
},
};
}
type PluginShape = { kind: string; spec: Record<string, unknown> };
function getPlugin(result: DashboardtypesQueryDTO[]): PluginShape {
const plugin = result[0]?.spec?.plugin;
if (!plugin) {
throw new Error('toPerses returned no plugin');
}
return plugin as unknown as PluginShape;
}
// ---- Phase 1: empty contract -----------------------------------------------
describe('toPerses (Phase 1 — empty input contract)', () => {
it('returns empty array when the V1 query has no queries of any kind', () => {
const result = toPerses({
query: emptyQuery(),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toStrictEqual([]);
});
});
// ---- Phase 2: bare BuilderQuery --------------------------------------------
describe('toPerses (Phase 2 — bare signoz/BuilderQuery)', () => {
it('wraps a single metrics builder query as bare signoz/BuilderQuery on a TimeSeries panel', () => {
const result = toPerses({
query: builderShell([metricsBuilderQuery()]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/BuilderQuery');
expect(plugin.spec).toMatchObject({ name: 'A', signal: 'metrics' });
});
it('emits signal "logs" for a logs-source builder query', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.LOGS, aggregations }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec).toMatchObject({ signal: 'logs' });
});
it('emits signal "traces" for a traces-source builder query', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.TRACES, aggregations }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec).toMatchObject({ signal: 'traces' });
});
it('applies groupBy field renames (key->name, dataType->fieldDataType, type->fieldContext)', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
groupBy: [
{
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
id: 'service.name--string--tag--false',
},
],
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
const groupBy = getPlugin(result).spec.groupBy as Array<{
name: string;
fieldDataType?: string;
fieldContext?: string;
}>;
expect(groupBy[0]).toMatchObject({
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'tag',
});
});
it('applies orderBy field renames (columnName/order -> key.name/direction)', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
const order = getPlugin(result).spec.order as Array<{
key: { name: string };
direction: string;
}>;
expect(order[0]).toStrictEqual({
key: { name: 'timestamp' },
direction: 'desc',
});
});
it('preserves filter.expression', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({
filter: { expression: 'service.name = "api"' },
}),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).spec.filter).toStrictEqual({
expression: 'service.name = "api"',
});
});
it('derives outer kind "raw" for a LIST panel', () => {
const aggregations = [
{ expression: 'count()' },
] as unknown as IBuilderQuery['aggregations'];
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ dataSource: DataSource.LOGS, aggregations }),
]),
graphType: PANEL_TYPES.LIST,
});
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.raw);
expect(getPlugin(result).kind).toBe('signoz/BuilderQuery');
});
});
// ---- Phase 3: CompositeQuery wrapping --------------------------------------
describe('toPerses (Phase 3 — signoz/CompositeQuery)', () => {
it('wraps two builder queries as signoz/CompositeQuery with two builder_query subqueries', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ queryName: 'A' }),
metricsBuilderQuery({ queryName: 'B' }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{
type: string;
spec: { name?: string };
}>;
expect(subqueries).toHaveLength(2);
expect(subqueries.map((s) => s.type)).toStrictEqual([
'builder_query',
'builder_query',
]);
expect(subqueries.map((s) => s.spec.name)).toStrictEqual(['A', 'B']);
});
it('still respects backend invariant: returns exactly one envelope for any non-empty input', () => {
const result = toPerses({
query: builderShell([
metricsBuilderQuery({ queryName: 'A' }),
metricsBuilderQuery({ queryName: 'B' }),
metricsBuilderQuery({ queryName: 'C' }),
]),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
});
it('wraps builder + formula as signoz/CompositeQuery (mixed content)', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
const result = toPerses({
query: {
id: 'q-mixed',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [metricsBuilderQuery()],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(getPlugin(result).kind).toBe('signoz/CompositeQuery');
const subqueries = getPlugin(result).spec.queries as Array<{
type: string;
}>;
expect(subqueries.map((s) => s.type).sort()).toStrictEqual(
['builder_formula', 'builder_query'].sort(),
);
});
});
// ---- Phase 4: formula + trace-operator invariant guard --------------------
//
// Formulas (`A + 1`) and trace operators (`A && B`) reference builder queries
// by name. They are invalid in isolation — the wrapper must always be
// CompositeQuery alongside at least one builder query. toPerses throws on
// save-time violation; fromPerses warns on read.
describe('toPerses (Phase 4 — formula/trace-op invariant guard)', () => {
it('throws when V1 has a formula but no builder queries', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
expect(() =>
toPerses({
query: {
id: 'q-bad-formula',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).toThrow(/Formulas and trace operators reference builder queries/);
});
it('throws when V1 has a trace operator but no builder queries', () => {
const traceOperator = {
queryName: 'T1',
dataSource: DataSource.TRACES,
expression: 'A && B',
aggregations: [{ expression: 'count()' }],
functions: [],
filter: { expression: '' },
filters: { items: [], op: 'AND' },
groupBy: [],
disabled: false,
having: [],
limit: null,
stepInterval: 60,
orderBy: [],
legend: '',
} as unknown as IBuilderQuery;
expect(() =>
toPerses({
query: {
id: 'q-bad-traceop',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [traceOperator],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).toThrow(/Formulas and trace operators reference builder queries/);
});
it('does not throw when builder is present alongside formula/trace-op', () => {
const formula = {
queryName: 'F1',
expression: 'A + 1',
disabled: false,
legend: '',
having: [],
orderBy: [],
};
expect(() =>
toPerses({
query: {
id: 'q-mixed-ok',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [metricsBuilderQuery()],
queryFormulas: [formula],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
}),
).not.toThrow();
});
});
// ---- Phase 5: PromQL + ClickHouseSQL ---------------------------------------
function promQuery(name: string, query = 'up'): Query {
return {
id: `q-prom-${name}`,
queryType: EQueryType.PROM,
clickhouse_sql: [],
promql: [{ name, query, disabled: false, legend: '' }],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
};
}
function clickhouseQuery(name: string, query = 'SELECT 1'): Query {
return {
id: `q-ch-${name}`,
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [{ name, query, disabled: false, legend: '' }],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
};
}
describe('toPerses (Phase 5 — PromQL)', () => {
it('wraps a single PromQL query as bare signoz/PromQLQuery', () => {
const result = toPerses({
query: promQuery('P1'),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
expect(getPlugin(result).kind).toBe('signoz/PromQLQuery');
expect(getPlugin(result).spec).toMatchObject({ name: 'P1', query: 'up' });
});
it('wraps multiple PromQL queries as signoz/CompositeQuery', () => {
const result = toPerses({
query: {
id: 'q-prom-multi',
queryType: EQueryType.PROM,
clickhouse_sql: [],
promql: [
{ name: 'P1', query: 'up', disabled: false, legend: '' },
{ name: 'P2', query: 'rate(x[1m])', disabled: false, legend: '' },
],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
},
graphType: PANEL_TYPES.TIME_SERIES,
});
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual(['promql', 'promql']);
});
});
describe('toPerses (Phase 5 — ClickHouseSQL)', () => {
it('wraps a single ClickHouse query as bare signoz/ClickHouseSQL', () => {
const result = toPerses({
query: clickhouseQuery('C1', 'SELECT count(*)'),
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toHaveLength(1);
expect(getPlugin(result).kind).toBe('signoz/ClickHouseSQL');
expect(getPlugin(result).spec).toMatchObject({
name: 'C1',
query: 'SELECT count(*)',
});
});
it('wraps multiple ClickHouse queries as signoz/CompositeQuery', () => {
const result = toPerses({
query: {
id: 'q-ch-multi',
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [
{ name: 'C1', query: 'SELECT 1', disabled: false, legend: '' },
{ name: 'C2', query: 'SELECT 2', disabled: false, legend: '' },
],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
},
graphType: PANEL_TYPES.TIME_SERIES,
});
const plugin = getPlugin(result);
expect(plugin.kind).toBe('signoz/CompositeQuery');
const subqueries = plugin.spec.queries as Array<{ type: string }>;
expect(subqueries.map((s) => s.type)).toStrictEqual([
'clickhouse_sql',
'clickhouse_sql',
]);
});
});
// ---- Phase 6: edge cases ---------------------------------------------------
describe('toPerses (Phase 6 — edge cases)', () => {
it('returns [] when queryType is unrecognized', () => {
const result = toPerses({
query: {
id: 'q-bogus',
queryType: 'WHATEVER' as EQueryType,
promql: [{ name: 'P1', query: 'up', disabled: false, legend: '' }],
clickhouse_sql: [],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
} as unknown as Query,
graphType: PANEL_TYPES.TIME_SERIES,
});
expect(result).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,276 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type {
IBuilderFormula,
IBuilderQuery,
IBuilderTraceOperator,
IClickHouseQuery,
IPromQLQuery,
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
BuilderQuery,
ClickHouseQuery,
CompositeQuery,
GroupByKey,
OrderBy,
PromQuery,
QueryBuilderFormula,
QueryEnvelope,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { makeEmptyV1Query, signalToDataSource } from './shared';
// IPromQLQuery and IClickHouseQuery are structurally identical
// ({name, query, disabled, legend}). Both v5 PromQuery and ClickHouseQuery
// carry the same four required-on-wire fields. One helper covers both.
type NamedQueryV5 = Pick<PromQuery, 'name' | 'query' | 'disabled' | 'legend'>;
/**
* Converts the perses on-wire envelope to the V1 in-memory `Query` shape that
* QueryBuilderContext consumes.
*
* Always returns a fully-inflated `{queryData, queryFormulas, queryTraceOperator}`
* shape so the QB UI never branches on "is the builder hydrated yet."
*
* Phase 5: all six perses plugin kinds (BuilderQuery, CompositeQuery, Formula,
* TraceOperator, PromQLQuery, ClickHouseSQL) handled both at top level and
* inside CompositeQuery. Composite queryType resolution: all-promql → PROM,
* all-clickhouse → CLICKHOUSE, otherwise QUERY_BUILDER.
*/
export function fromPerses(persesQueries: DashboardtypesQueryDTO[]): Query {
// Backend invariant: panel.queries.length === 1. We trust that here and
// only consume the first entry — extras (a malformed payload) are ignored.
const plugin = persesQueries[0]?.spec?.plugin;
const result = makeEmptyV1Query();
// Defensive: skip if plugin is missing or its spec is absent. v5BuilderToV1
// and friends assume a non-undefined spec, so a missing one would otherwise
// crash inside a helper.
if (!plugin?.spec) {
return result;
}
switch (plugin.kind) {
case 'signoz/BuilderQuery':
result.builder.queryData.push(v5BuilderToV1(plugin.spec as BuilderQuery));
break;
// Top-level Formula and TraceOperator are invalid — they reference
// builder queries by name and can only appear *inside* a CompositeQuery
// that also contains the referenced builder query. Defensive read: warn
// and drop, don't crash dashboard load.
case 'signoz/Formula':
case 'signoz/TraceOperator':
// eslint-disable-next-line no-console
console.warn(
`fromPerses: top-level ${plugin.kind} is invalid (formulas and ` +
'trace operators must travel inside a CompositeQuery alongside ' +
'the builder query they reference). Dropping.',
);
break;
case 'signoz/PromQLQuery':
result.promql.push(v5NamedQueryToV1(plugin.spec as PromQuery));
result.queryType = EQueryType.PROM;
break;
case 'signoz/ClickHouseSQL':
result.clickhouse_sql.push(v5NamedQueryToV1(plugin.spec as ClickHouseQuery));
result.queryType = EQueryType.CLICKHOUSE;
break;
case 'signoz/CompositeQuery': {
const composite = plugin.spec as CompositeQuery;
const subqueries = composite.queries ?? [];
subqueries.forEach((sub) => dispatchSubquery(sub, result));
warnIfFormulaOrTraceOperatorWithoutBuilder(subqueries);
result.queryType = resolveCompositeQueryType(subqueries);
break;
}
default:
break;
}
return result;
}
// Mirrors the toPerses save-time invariant: formulas and trace operators
// inside a CompositeQuery must be accompanied by at least one builder_query
// subquery. On read we warn rather than throw — existing (legacy or manually-
// edited) dashboards keep loading; the diagnostic surfaces the bad state.
function warnIfFormulaOrTraceOperatorWithoutBuilder(
subqueries: QueryEnvelope[],
): void {
const hasBuilder = subqueries.some((s) => s.type === 'builder_query');
if (hasBuilder) {
return;
}
const hasFormulaOrOp = subqueries.some(
(s) => s.type === 'builder_formula' || s.type === 'builder_trace_operator',
);
if (hasFormulaOrOp) {
// eslint-disable-next-line no-console
console.warn(
'fromPerses: CompositeQuery contains formulas/trace-operators but no ' +
'builder_query subquery to reference. The saved panel is in an ' +
'invalid state.',
);
}
}
// Composite queryType resolution: all-promql → PROM, all-clickhouse →
// CLICKHOUSE, otherwise QUERY_BUILDER. V1's queryType is a single value
// (the QB UI picks which sub-list is "active") so mixed-type composites
// default to QUERY_BUILDER and the QB shows the builder tab.
function resolveCompositeQueryType(subqueries: QueryEnvelope[]): EQueryType {
if (subqueries.length === 0) {
return EQueryType.QUERY_BUILDER;
}
const types = new Set(subqueries.map((s) => s.type));
if (types.size === 1 && types.has('promql')) {
return EQueryType.PROM;
}
if (types.size === 1 && types.has('clickhouse_sql')) {
return EQueryType.CLICKHOUSE;
}
return EQueryType.QUERY_BUILDER;
}
// Distributes a CompositeQuery subquery into the right V1 bucket on `result`,
// based on its v5 envelope `type`. Each phase adds another branch.
function dispatchSubquery(sub: QueryEnvelope, result: Query): void {
// Defensive: a malformed subquery without a spec is skipped — converting an
// undefined spec would crash inside a helper. The well-formed siblings in
// the same composite still flow through.
if (!sub.spec) {
return;
}
switch (sub.type) {
case 'builder_query':
result.builder.queryData.push(v5BuilderToV1(sub.spec as BuilderQuery));
break;
case 'builder_formula':
result.builder.queryFormulas.push(
v5FormulaToV1(sub.spec as QueryBuilderFormula & { disabled?: boolean }),
);
break;
case 'builder_trace_operator':
result.builder.queryTraceOperator.push(
v5TraceOperatorToV1(sub.spec as BuilderQuery),
);
break;
case 'promql':
result.promql.push(v5NamedQueryToV1(sub.spec as PromQuery));
break;
case 'clickhouse_sql':
result.clickhouse_sql.push(v5NamedQueryToV1(sub.spec as ClickHouseQuery));
break;
default:
break;
}
}
// Maps a v5 BuilderQuery (the union of Log/Metric/Trace/Meter variants) back to
// the V1 in-memory IBuilderQuery shape. Fields the v5 spec doesn't carry come
// from the signal-specific default in `initialQueryBuilderFormValuesMap`.
function v5BuilderToV1(spec: BuilderQuery): IBuilderQuery {
const dataSource = signalToDataSource(spec.signal);
const base = initialQueryBuilderFormValuesMap[dataSource];
const name = spec.name ?? base.queryName;
return {
...base,
queryName: name,
expression: name,
dataSource,
aggregations: spec.aggregations ?? base.aggregations,
filter: spec.filter ?? { expression: '' },
// V1's `filters` is the legacy V4 tag-filter input. The v5 spec doesn't
// carry it; the QB UI reconstructs items from `filter.expression` when
// parsing the saved query.
filters: { items: [], op: 'AND' },
groupBy: (spec.groupBy ?? []).map(v5GroupByToV1),
orderBy: (spec.order ?? []).map(v5OrderByToV1),
// v5 Step = string | number; V1 stepInterval = number | null. Strings
// like "30s" can't be coerced losslessly here — drop to null and let
// the UI re-derive.
stepInterval:
typeof spec.stepInterval === 'number' ? spec.stepInterval : null,
limit: spec.limit ?? null,
legend: spec.legend ?? '',
disabled: spec.disabled ?? false,
having: spec.having ?? [],
functions: spec.functions ?? [],
selectColumns: spec.selectFields,
offset: spec.offset,
// Only the MeterBuilderQuery variant has `source`; the discriminator
// check keeps TS happy and produces `''` for non-meter variants.
source: 'source' in spec ? spec.source : '',
};
}
// v5 GroupByKey uses the v5 TelemetryFieldKey field names. V1 BaseAutocompleteData
// uses the legacy {key, dataType, type} names.
function v5GroupByToV1(g: GroupByKey): BaseAutocompleteData {
return {
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
};
}
// v5 OrderBy uses {key:{name}, direction}; V1 OrderByPayload uses {columnName, order}.
function v5OrderByToV1(o: OrderBy): OrderByPayload {
return {
columnName: o.key?.name ?? '',
order: o.direction,
};
}
// v5 QueryBuilderFormula: {name, expression, functions?, order?, limit?, having?, legend?}.
// The on-wire shape also carries `disabled` (emitted by prepareQueryRangePayloadV5)
// even though the local v5 interface omits it — the intersection on the
// parameter type makes this explicit at the helper signature.
// V1 IBuilderFormula uses {queryName, expression, disabled, legend, limit, having[], orderBy}.
function v5FormulaToV1(
spec: QueryBuilderFormula & { disabled?: boolean },
): IBuilderFormula {
return {
queryName: spec.name,
expression: spec.expression,
disabled: spec.disabled ?? false,
legend: spec.legend ?? '',
limit: spec.limit,
// v5 having is {expression: string}; V1 having on formula is Having[].
// They're not structurally compatible — drop to empty array.
having: [],
stepInterval: undefined,
orderBy: (spec.order ?? []).map(v5OrderByToV1),
};
}
// A v5 trace operator spec is structurally a BuilderQuery (BaseBuilderQuery
// + signal-specific aggregations) carrying the trace-operator `expression`
// field. V1 IBuilderTraceOperator is an alias for IBuilderQuery, so we delegate
// the bulk of the mapping to v5BuilderToV1 and override `expression`.
function v5TraceOperatorToV1(spec: BuilderQuery): IBuilderTraceOperator {
const base = v5BuilderToV1(spec);
return {
...base,
expression: spec.expression ?? base.expression,
};
}
// v5 PromQuery / ClickHouseQuery carry an identical {name, query, disabled?,
// legend?} core; V1 IPromQLQuery and IClickHouseQuery are also structurally
// identical {name, query, disabled, legend}. The intersection return type lets
// callers push the result into either V1 list.
function v5NamedQueryToV1(spec: NamedQueryV5): IPromQLQuery & IClickHouseQuery {
return {
name: spec.name,
query: spec.query,
disabled: spec.disabled ?? false,
legend: spec.legend ?? '',
};
}

View File

@@ -0,0 +1,111 @@
import {
DashboardtypesQueryDTO,
DashboardtypesQueryPluginDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
// Outer envelope `kind` values used by the perses dashboard format. The Go
// backend defines `Query.Kind` as a free-form string, but the only values
// observed in `pkg/types/dashboardtypes/testdata` are these two.
export type PersesOuterKind = 'TimeSeriesQuery' | 'LogQuery';
// LIST panels (rows-oriented data — logs and traces alike) use LogQuery.
// Every other panel type uses TimeSeriesQuery.
export function deriveOuterKind(
graphType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
return graphType === PANEL_TYPES.LIST
? Querybuildertypesv5RequestTypeDTO.raw
: Querybuildertypesv5RequestTypeDTO.time_series;
}
// v5 builder query carries `signal` as a literal union; V1 in-memory uses the
// DataSource enum. These two helpers bridge the two sides.
export type V5Signal = 'logs' | 'metrics' | 'traces';
export function signalToDataSource(signal: V5Signal): DataSource {
if (signal === 'logs') {
return DataSource.LOGS;
}
if (signal === 'traces') {
return DataSource.TRACES;
}
return DataSource.METRICS;
}
export function dataSourceToSignal(dataSource: DataSource): V5Signal {
if (dataSource === DataSource.LOGS) {
return 'logs';
}
if (dataSource === DataSource.TRACES) {
return 'traces';
}
return 'metrics';
}
// fromPerses always returns this fully-inflated empty composite shape so the
// QB UI never has to branch on "is the builder hydrated yet."
export function makeEmptyV1Query(): Query {
return {
id: '',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
};
}
// Inner plugin kinds that wrap a single v5 envelope spec (no further nesting).
// CompositeQuery is excluded — it has its own wrapper (`wrapComposite`).
export type BarePluginKind =
| 'signoz/BuilderQuery'
| 'signoz/Formula'
| 'signoz/TraceOperator'
| 'signoz/PromQLQuery'
| 'signoz/ClickHouseSQL';
// Packages a single v5 envelope spec into the perses outer DTO with the
// specified inner plugin kind. The one cast per call bridges the structurally-
// identical-but-nominally-distinct local v5 / generated perses type pair.
export function wrapBare(
outerKind: Querybuildertypesv5RequestTypeDTO,
pluginKind: BarePluginKind,
envelope: QueryEnvelope,
): DashboardtypesQueryDTO {
return {
kind: outerKind,
spec: {
plugin: {
kind: pluginKind,
spec: envelope.spec,
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}
// Packages a list of v5 envelopes into a perses `signoz/CompositeQuery` DTO.
// Used when V1 has multi-query, formula, or trace-operator content — anything
// the bare wrapper kinds can't represent.
export function wrapComposite(
outerKind: Querybuildertypesv5RequestTypeDTO,
envelopes: QueryEnvelope[],
): DashboardtypesQueryDTO {
return {
kind: outerKind,
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: { queries: envelopes },
} as unknown as DashboardtypesQueryPluginDTO,
},
};
}

View File

@@ -0,0 +1,135 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { prepareQueryRangePayloadV5 } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryEnvelope, QueryType } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import {
type BarePluginKind,
deriveOuterKind,
wrapBare,
wrapComposite,
} from './shared';
// QUERY_BUILDER single-envelope dispatch table: when the v5 forward conversion
// emits exactly one envelope, the envelope's `type` determines which bare
// perses plugin kind wraps it. Only builder_query bare-wraps — formulas and
// trace operators reference builder queries by name (e.g. "A + 1", "A && B")
// and can never exist alone, so they always travel inside a CompositeQuery
// alongside the builder query they reference.
const QUERY_BUILDER_BARE_KIND: Partial<Record<QueryType, BarePluginKind>> = {
builder_query: 'signoz/BuilderQuery',
};
export interface ToPersesArgs {
query: Query;
graphType: PANEL_TYPES;
}
/**
* Converts a V1 in-memory `Query` to the perses on-wire envelope used by V2
* dashboards.
*
* Returns `[]` for genuinely-empty input, otherwise an array of length exactly
* one (the perses backend invariant: `panel.queries.length === 1`).
*
* Phase 5: all six perses plugin kinds — BuilderQuery, Formula, TraceOperator,
* CompositeQuery, PromQLQuery, ClickHouseSQL. PROM and CLICKHOUSE queryTypes
* collapse to their bare envelopes on single-query input, composite otherwise.
*/
export function toPerses({
query,
graphType,
}: ToPersesArgs): DashboardtypesQueryDTO[] {
if (isEmptyQuery(query)) {
return [];
}
const { queryPayload } = prepareQueryRangePayloadV5({
query,
graphType,
selectedTime: 'GLOBAL_TIME',
});
const envelopes = queryPayload.compositeQuery.queries ?? [];
if (envelopes.length === 0) {
return [];
}
const outerKind = deriveOuterKind(graphType);
switch (query.queryType) {
case EQueryType.QUERY_BUILDER:
assertFormulaAndTraceOperatorReferenceBuilder(query);
return [buildQueryBuilderEnvelope(envelopes, outerKind)];
case EQueryType.PROM:
return [buildBareOrComposite(envelopes, outerKind, 'signoz/PromQLQuery')];
case EQueryType.CLICKHOUSE:
return [buildBareOrComposite(envelopes, outerKind, 'signoz/ClickHouseSQL')];
default:
return [];
}
}
// Semantic invariant: formulas (`A + 1`) and trace operators (`A && B`)
// reference builder queries by name. They are only meaningful when at least
// one builder query is present in the same panel. Throw on save-time
// violation so corrupt state can't be persisted; reads are handled defensively
// in fromPerses.
function assertFormulaAndTraceOperatorReferenceBuilder(query: Query): void {
const builderCount = query.builder?.queryData?.length ?? 0;
const formulaCount = query.builder?.queryFormulas?.length ?? 0;
const traceOperatorCount = query.builder?.queryTraceOperator?.length ?? 0;
if (builderCount > 0) {
return;
}
if (formulaCount === 0 && traceOperatorCount === 0) {
return;
}
throw new Error(
'toPerses: cannot serialize a query with ' +
`${formulaCount} formula(s) and ${traceOperatorCount} trace operator(s) ` +
'but no builder queries. Formulas and trace operators reference ' +
'builder queries by name and cannot exist alone.',
);
}
// QUERY_BUILDER dispatch: a single envelope of a recognized type collapses to
// its bare wrapper; anything else (multi-envelope, mixed types) becomes a
// CompositeQuery containing all envelopes.
function buildQueryBuilderEnvelope(
envelopes: QueryEnvelope[],
outerKind: Querybuildertypesv5RequestTypeDTO,
): DashboardtypesQueryDTO {
if (envelopes.length === 1) {
const bareKind = QUERY_BUILDER_BARE_KIND[envelopes[0].type];
if (bareKind) {
return wrapBare(outerKind, bareKind, envelopes[0]);
}
}
return wrapComposite(outerKind, envelopes);
}
// PROM / CLICKHOUSE dispatch: single envelope → bare; multi → composite.
function buildBareOrComposite(
envelopes: QueryEnvelope[],
outerKind: Querybuildertypesv5RequestTypeDTO,
bareKind: BarePluginKind,
): DashboardtypesQueryDTO {
return envelopes.length === 1
? wrapBare(outerKind, bareKind, envelopes[0])
: wrapComposite(outerKind, envelopes);
}
function isEmptyQuery(query: Query): boolean {
const hasBuilder =
(query.builder?.queryData?.length ?? 0) > 0 ||
(query.builder?.queryFormulas?.length ?? 0) > 0 ||
(query.builder?.queryTraceOperator?.length ?? 0) > 0;
const hasPromql = (query.promql?.length ?? 0) > 0;
const hasClickhouse = (query.clickhouse_sql?.length ?? 0) > 0;
return !hasBuilder && !hasPromql && !hasClickhouse;
}

View File

@@ -1,3 +1,13 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './DashboardsListPage';
export default DashboardsListPage;
function DashboardsListPageEntry(): JSX.Element {
const isV2 = useIsDashboardV2();
if (isV2) {
return <DashboardsListPageV2 />;
}
return <DashboardsListPage />;
}
export default DashboardsListPageEntry;

View File

@@ -27,9 +27,9 @@
color: white;
}
// Shared trigger button for the sort + configure-group icons in the right
// actions cluster. Provides a square hover/active background so users know
// which icon they're targeting.
/* Shared trigger button for the sort + configure-group icons in the right
actions cluster. Provides a square hover/active background so users know
which icon they are targeting. */
.iconTrigger {
display: inline-flex;
align-items: center;

View File

@@ -1,5 +1,5 @@
// Shared building blocks for the dashboards-list view states.
// Composed via CSS-modules `composes:` from each state's own SCSS.
/* Shared building blocks for the dashboards-list view states. */
/* Composed via CSS-modules composes: from each state's own SCSS. */
.cardWrapper {
display: flex;

View File

@@ -1,8 +1,8 @@
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { DashboardtypesGettableDashboardWithPinDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
export type DashboardListItem = DashboardtypesGettableDashboardWithPinDTO;
export type DashboardListItem = DashboardtypesGettableDashboardV2DTO;
export const tagsToStrings = (
tags: { key: string; value: string }[] | null | undefined,

View File

@@ -48,9 +48,7 @@
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts",
"src/pages/DashboardsListPageV2/**/*",
"src/pages/DashboardPageV2/**/*"
"orval.config.ts"
],
"include": [
"./src",