mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-10 19:00:34 +01:00
Compare commits
12 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8bd3f731 | ||
|
|
c02b2f67fb | ||
|
|
5538fbfc1b | ||
|
|
957fe131e9 | ||
|
|
243c8dc5c9 | ||
|
|
d2335b5e72 | ||
|
|
29ea87c35c | ||
|
|
4b818d39cb | ||
|
|
94070df143 | ||
|
|
3a73dcb9a9 | ||
|
|
be13636967 | ||
|
|
6cd71bcd62 |
@@ -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,34 @@
|
||||
import { definition as barChart } from './kinds/BarChartPanel/definition';
|
||||
import { definition as histogram } from './kinds/HistogramPanel/definition';
|
||||
import { definition as pieChart } from './kinds/PieChartPanel/definition';
|
||||
import { definition as timeSeries } from './kinds/TimeSeriesPanel/definition';
|
||||
import type {
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
} from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
// Pure assembly: each kind owns its own PanelDefinition (see
|
||||
// `kinds/<Kind>/definition.ts`). Registering a new panel = add its folder and a
|
||||
// single entry below — no other central file needs editing.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[timeSeries.kind]: timeSeries,
|
||||
[barChart.kind]: barChart,
|
||||
[histogram.kind]: histogram,
|
||||
[pieChart.kind]: pieChart,
|
||||
};
|
||||
|
||||
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,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 '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearanceMappings';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildBarChartConfig } from './buildConfig';
|
||||
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,13 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
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 '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveLegendPosition } from '../../utils/chartAppearanceMappings';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildHistogramConfig } from './buildConfig';
|
||||
import { prepareHistogramData } from './prepareData';
|
||||
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,13 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -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/sections';
|
||||
|
||||
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 '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearanceMappings';
|
||||
|
||||
import { preparePieData } from './prepareData';
|
||||
|
||||
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,13 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -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/sections';
|
||||
|
||||
// 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 '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearanceMappings';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
|
||||
import { buildTimeSeriesConfig } from './buildConfig';
|
||||
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,13 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
Renderer,
|
||||
sections,
|
||||
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SectionConfig } from '../../types/sections';
|
||||
|
||||
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,9 @@
|
||||
.panelContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
@@ -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
|
||||
* rendererProps.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,31 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export type PanelKind =
|
||||
| 'signoz/TimeSeriesPanel'
|
||||
| 'signoz/BarChartPanel'
|
||||
| 'signoz/NumberPanel'
|
||||
| 'signoz/PieChartPanel'
|
||||
| 'signoz/TablePanel'
|
||||
| 'signoz/HistogramPanel'
|
||||
| 'signoz/ListPanel';
|
||||
|
||||
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,74 @@
|
||||
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 { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
|
||||
import type { PanelInteractionMap } from './interactions';
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/**
|
||||
* 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];
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
BarChart,
|
||||
Columns3,
|
||||
Hash,
|
||||
ListEnd,
|
||||
Palette,
|
||||
Ruler,
|
||||
SlidersHorizontal,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
// Derived from an actual icon component so the type stays exact (size is a
|
||||
// constrained IconSize union, not arbitrary strings) and ForwardRef-compatible.
|
||||
export type SectionIcon = typeof Hash;
|
||||
|
||||
export interface SectionMetadata {
|
||||
title: string;
|
||||
icon: SectionIcon;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// Discriminated union derived from SectionControls — kept in lockstep automatically.
|
||||
export type SectionConfig = {
|
||||
[K in SectionKind]: { kind: K; controls: SectionControls[K] };
|
||||
}[SectionKind];
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
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;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -50,3 +59,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);
|
||||
}
|
||||
|
||||
.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,27 @@ 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}
|
||||
panelId={panelId}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data?.warning}
|
||||
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,108 @@
|
||||
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 { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
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,81 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Loader } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import type { PanelActionsConfig } from './Panel';
|
||||
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
|
||||
import PanelStatusPopover from './PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
panelStatusFromError,
|
||||
panelStatusFromWarning,
|
||||
} from './PanelStatus/utils';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
panelId: string;
|
||||
/** Background refresh in flight — shows a subtle spinner without blinking the chart. */
|
||||
isFetching: boolean;
|
||||
/** Latest query error, if any — surfaced as a header error indicator. */
|
||||
error?: Error | null;
|
||||
/** Non-fatal query warning lifted from the response payload. */
|
||||
warning?: Warning;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
|
||||
function PanelHeader({
|
||||
title,
|
||||
panelId,
|
||||
isFetching,
|
||||
error,
|
||||
warning,
|
||||
panelActions,
|
||||
}: PanelHeaderProps): JSX.Element {
|
||||
const errorDetail = useMemo(
|
||||
() => panelStatusFromError(error ?? null),
|
||||
[error],
|
||||
);
|
||||
|
||||
const warningDetail = useMemo(
|
||||
() => panelStatusFromWarning(warning),
|
||||
[warning],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>{title}</Typography.Text>
|
||||
{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)}>
|
||||
{errorDetail && <PanelStatusPopover variant="error" detail={errorDetail} />}
|
||||
{warningDetail && (
|
||||
<PanelStatusPopover variant="warning" detail={warningDetail} />
|
||||
)}
|
||||
{panelActions && (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={panelActions.currentLayoutIndex}
|
||||
sections={panelActions.sections}
|
||||
onMovePanel={panelActions.onMovePanel}
|
||||
onDeletePanel={panelActions.onDeletePanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelHeader;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BookOpenText } from '@signozhq/icons';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
import styles from './PanelStatusPopover.module.scss';
|
||||
|
||||
interface PanelStatusContentProps {
|
||||
detail: PanelStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover body for a panel status (error or warning): a code + summary header
|
||||
* with an optional docs link, followed by any per-item messages. Pure
|
||||
* presentation — the variant's icon/colour is owned by `PanelStatusPopover`.
|
||||
*/
|
||||
function PanelStatusContent({ detail }: PanelStatusContentProps): JSX.Element {
|
||||
const { code, message, docsUrl, messages } = detail;
|
||||
|
||||
return (
|
||||
<section className={styles.content} data-testid="panel-status-content">
|
||||
<header className={styles.summary}>
|
||||
<div className={styles.summaryText}>
|
||||
<h2 className={styles.code}>{code}</h2>
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="panel-status-docs"
|
||||
>
|
||||
<BookOpenText size={14} />
|
||||
Open Docs
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
{messages.length > 0 && (
|
||||
<ul className={styles.messageList}>
|
||||
{messages.map((m) => (
|
||||
<li key={m} className={styles.messageItem}>
|
||||
{m}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelStatusContent;
|
||||
@@ -0,0 +1,64 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 600px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CircleX, TriangleAlert } from '@signozhq/icons';
|
||||
import { Popover } from 'antd';
|
||||
|
||||
import PanelStatusContent from './PanelStatusContent';
|
||||
import type { PanelStatusDetail, PanelStatusVariant } from './types';
|
||||
import styles from './PanelStatusPopover.module.scss';
|
||||
|
||||
const VARIANT_CONFIG: Record<
|
||||
PanelStatusVariant,
|
||||
{ color: string; ariaLabel: string }
|
||||
> = {
|
||||
error: { color: Color.BG_CHERRY_500, ariaLabel: 'Panel error' },
|
||||
warning: { color: Color.BG_AMBER_500, ariaLabel: 'Panel warning' },
|
||||
};
|
||||
|
||||
interface PanelStatusPopoverProps {
|
||||
variant: PanelStatusVariant;
|
||||
detail: PanelStatusDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header status indicator: a variant-coloured icon (error → CircleX,
|
||||
* warning → TriangleAlert) that opens a popover with the status detail. One
|
||||
* component drives both variants so error and warning surfacing stay in lockstep.
|
||||
*/
|
||||
function PanelStatusPopover({
|
||||
variant,
|
||||
detail,
|
||||
}: PanelStatusPopoverProps): JSX.Element {
|
||||
const { color, ariaLabel } = VARIANT_CONFIG[variant];
|
||||
const Icon = variant === 'error' ? CircleX : TriangleAlert;
|
||||
|
||||
const content = useMemo(
|
||||
() => <PanelStatusContent detail={detail} />,
|
||||
[detail],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
autoAdjustOverflow
|
||||
>
|
||||
{/* Wrapping span gives antd a ref-able, hoverable trigger (icon
|
||||
components don't forward refs) and a stable testid anchor. */}
|
||||
<span
|
||||
className={styles.trigger}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={`panel-status-${variant}`}
|
||||
>
|
||||
<Icon size={16} color={color} />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelStatusPopover;
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
|
||||
|
||||
describe('panelStatusFromError', () => {
|
||||
it('returns null when there is no error', () => {
|
||||
expect(panelStatusFromError(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a structured APIError to code/message/docs/sub-messages', () => {
|
||||
const apiError = new APIError({
|
||||
httpStatusCode: StatusCodes.BAD_REQUEST,
|
||||
error: {
|
||||
code: 'invalid_query',
|
||||
message: 'Query is invalid',
|
||||
url: 'https://docs/err',
|
||||
errors: [{ message: 'missing aggregation' }, { message: 'bad filter' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(panelStatusFromError(apiError)).toStrictEqual({
|
||||
code: 'invalid_query',
|
||||
message: 'Query is invalid',
|
||||
docsUrl: 'https://docs/err',
|
||||
messages: ['missing aggregation', 'bad filter'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a generic 500 for a plain Error', () => {
|
||||
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
|
||||
code: '500',
|
||||
message: 'boom',
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits docsUrl when the API error has no url', () => {
|
||||
const apiError = new APIError({
|
||||
httpStatusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
error: { code: 'x', message: 'y', url: '', errors: [] },
|
||||
});
|
||||
|
||||
expect(panelStatusFromError(apiError)?.docsUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panelStatusFromWarning', () => {
|
||||
it('returns null when there is no warning', () => {
|
||||
expect(panelStatusFromWarning(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a warning to the normalized status shape', () => {
|
||||
const warning: Warning = {
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
url: 'https://docs/warn',
|
||||
warnings: [{ message: 'series A truncated' }],
|
||||
};
|
||||
|
||||
expect(panelStatusFromWarning(warning)).toStrictEqual({
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
docsUrl: 'https://docs/warn',
|
||||
messages: ['series A truncated'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Which kind of non-fatal panel status is being surfaced in the header. */
|
||||
export type PanelStatusVariant = 'error' | 'warning';
|
||||
|
||||
/**
|
||||
* Normalized status shape that both an API error and a query warning adapt into,
|
||||
* so a single popover can render either. Mirrors the fields the backend supplies
|
||||
* on its `ErrorV2` / `Warning` envelopes (code + summary + optional docs link +
|
||||
* per-item messages).
|
||||
*/
|
||||
export interface PanelStatusDetail {
|
||||
/** Short status code (e.g. an error/warning code) shown as the heading. */
|
||||
code: string;
|
||||
/** Human-readable summary line. */
|
||||
message: string;
|
||||
/** Optional docs link; renders an "Open Docs" action when present. */
|
||||
docsUrl?: string;
|
||||
/** Additional per-item messages listed under the summary. */
|
||||
messages: string[];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Warning } from 'types/api';
|
||||
import type APIError from 'types/api/error';
|
||||
|
||||
import type { PanelStatusDetail } from './types';
|
||||
|
||||
const FALLBACK_CODE = '500';
|
||||
const FALLBACK_MESSAGE = 'Something went wrong';
|
||||
|
||||
/**
|
||||
* Narrows a thrown `Error` to our `APIError` (which carries the structured
|
||||
* `error.error` envelope). react-query types failures as `Error`, so a runtime
|
||||
* guard is the typed way to recover the richer shape.
|
||||
*/
|
||||
function isAPIError(error: Error): error is APIError {
|
||||
return (
|
||||
'error' in error &&
|
||||
typeof (error as APIError).error === 'object' &&
|
||||
(error as APIError).error !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a query failure into the normalized status shape. Structured
|
||||
* `APIError`s yield their backend code/message/docs/sub-messages; any other
|
||||
* `Error` falls back to a generic 500 with its message.
|
||||
*/
|
||||
export function panelStatusFromError(
|
||||
error: Error | null,
|
||||
): PanelStatusDetail | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAPIError(error)) {
|
||||
const detail = error.error.error;
|
||||
return {
|
||||
code: detail?.code || FALLBACK_CODE,
|
||||
message: detail?.message || FALLBACK_MESSAGE,
|
||||
docsUrl: detail?.url || undefined,
|
||||
messages: (detail?.errors ?? []).map((e) => e.message),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: FALLBACK_CODE,
|
||||
message: error.message || FALLBACK_MESSAGE,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapts a query warning into the normalized status shape. */
|
||||
export function panelStatusFromWarning(
|
||||
warning: Warning | undefined,
|
||||
): PanelStatusDetail | null {
|
||||
if (!warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: warning.code,
|
||||
message: warning.message,
|
||||
docsUrl: warning.url || undefined,
|
||||
messages: (warning.warnings ?? []).map((w) => w.message),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
import PanelHeader from '../PanelHeader';
|
||||
|
||||
const baseProps = {
|
||||
title: 'My panel',
|
||||
kind: 'TimeSeries',
|
||||
panelId: 'panel-1',
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
const warning: Warning = {
|
||||
code: 'partial_data',
|
||||
message: 'Some series were dropped',
|
||||
url: '',
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe('PanelHeader status indicators', () => {
|
||||
it('shows the error indicator whenever an error is present', () => {
|
||||
render(<PanelHeader {...baseProps} error={new Error('boom')} />);
|
||||
expect(screen.getByTestId('panel-status-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the warning indicator whenever a warning is present', () => {
|
||||
render(<PanelHeader {...baseProps} warning={warning} />);
|
||||
expect(screen.getByTestId('panel-status-warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no status indicators when there is no error or warning', () => {
|
||||
render(<PanelHeader {...baseProps} />);
|
||||
expect(screen.queryByTestId('panel-status-error')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-status-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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/rendererProps';
|
||||
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 };
|
||||
}
|
||||
@@ -9,4 +9,11 @@
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:global(.react-resizable-handle) {
|
||||
&::after {
|
||||
border-right-color: var(--l2-border) !important;
|
||||
border-bottom-color: var(--l2-border) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,133 @@
|
||||
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/panelKind';
|
||||
|
||||
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,190 @@
|
||||
/**
|
||||
* 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/panelKind';
|
||||
|
||||
import { fromPerses } from '../fromPerses';
|
||||
import { deriveOuterKind } from '../shared';
|
||||
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);
|
||||
// The outer kind is derived from the panel's graphType (LIST→raw,
|
||||
// VALUE/TABLE/PIE→scalar, else→time_series), not carried over from the
|
||||
// input envelope — assert against that contract, not the testdata's kind.
|
||||
expect(roundTripped[0].kind).toBe(deriveOuterKind(graphType));
|
||||
|
||||
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,529 @@
|
||||
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');
|
||||
});
|
||||
|
||||
it.each([PANEL_TYPES.VALUE, PANEL_TYPES.TABLE, PANEL_TYPES.PIE])(
|
||||
'derives outer kind "scalar" for a %s panel',
|
||||
(graphType) => {
|
||||
const result = toPerses({
|
||||
query: builderShell([metricsBuilderQuery()]),
|
||||
graphType,
|
||||
});
|
||||
expect(result[0].kind).toBe(Querybuildertypesv5RequestTypeDTO.scalar);
|
||||
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,277 @@
|
||||
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 } from './shared';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// 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 = spec.signal as DataSource;
|
||||
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,91 @@
|
||||
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';
|
||||
|
||||
// Maps a panel type to the v5 request kind its query envelope expects:
|
||||
// - LIST panels (rows-oriented data — logs and traces alike) request `raw`.
|
||||
// - VALUE / TABLE / PIE panels render a single aggregated figure, so they
|
||||
// request `scalar`.
|
||||
// - Every other panel type requests `time_series`.
|
||||
export function deriveOuterKind(
|
||||
graphType: PANEL_TYPES,
|
||||
): Querybuildertypesv5RequestTypeDTO {
|
||||
switch (graphType) {
|
||||
case PANEL_TYPES.LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
case PANEL_TYPES.VALUE:
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.PIE:
|
||||
return Querybuildertypesv5RequestTypeDTO.scalar;
|
||||
default:
|
||||
return Querybuildertypesv5RequestTypeDTO.time_series;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -41,6 +41,10 @@ import SearchBar from '../SearchBar/SearchBar';
|
||||
import DashboardsListContent from './DashboardsListContent';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -82,8 +86,8 @@ function DashboardsList(): JSX.Element {
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
query: searchString.trim() || undefined,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
sort: sortColumn as DashboardtypesListSortDTO,
|
||||
order: sortOrder as DashboardtypesListOrderDTO,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
|
||||
@@ -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