mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-08 18:10:27 +01:00
Compare commits
14 Commits
sso-form-p
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634a1d285a | ||
|
|
9eca33ed3d | ||
|
|
7a1d8bd6fb | ||
|
|
67ba89bcab | ||
|
|
63f467fb7c | ||
|
|
e175838678 | ||
|
|
69805d0214 | ||
|
|
c95c8d8d53 | ||
|
|
4998edde74 | ||
|
|
5d645e28f5 | ||
|
|
9cd5f3e1c9 | ||
|
|
2255334ce1 | ||
|
|
4d7f25d7ec | ||
|
|
2e659c0407 |
6
deploy/docker/docker-compose.override.yaml
Normal file
6
deploy/docker/docker-compose.override.yaml
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
41
frontend/src/lib/uPlotV2/components/Legend/UPlotLegend.tsx
Normal file
41
frontend/src/lib/uPlotV2/components/Legend/UPlotLegend.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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', () => {
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.panelContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -54,6 +54,7 @@ function SectionGrid({
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".panel-drag-handle"
|
||||
draggableCancel=".panel-no-drag"
|
||||
isDraggable={isEditable}
|
||||
isResizable={isEditable}
|
||||
onDragStop={handleLayoutChange}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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/';
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user