Compare commits

..

2 Commits

Author SHA1 Message Date
Vinícius Lourenço
f050419cff perf(tsconfig): enable incremental & fix large type resolution 2026-07-01 10:28:20 -03:00
Vinícius Lourenço
5ea514b94f chore(tsconfig): explicit path mappings and cleaned includes 2026-07-01 10:28:20 -03:00
87 changed files with 852 additions and 3500 deletions

View File

@@ -3071,10 +3071,6 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3086,7 +3082,6 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardV2:
properties:
@@ -3094,10 +3089,6 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
reservedKeywords:
items:
type: string
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
@@ -3109,7 +3100,6 @@ components:
- dashboards
- total
- tags
- reservedKeywords
type: object
DashboardtypesListableDashboardView:
properties:
@@ -3581,7 +3571,7 @@ components:
- user
- system
- integration
type: string
type: object
DashboardtypesSpanGaps:
properties:
fillLessThan:

View File

@@ -328,11 +328,6 @@
{
"name": "immer",
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
},
{
"name": "api/generated/services/dashboard",
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
}
]
}

View File

@@ -5031,10 +5031,6 @@ export interface DashboardtypesListableDashboardForUserV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
@@ -5102,10 +5098,6 @@ export interface DashboardtypesListableDashboardV2DTO {
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/

View File

@@ -1,5 +1,14 @@
import { MutableRefObject } from 'react';
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
import {
ActiveElement,
Chart,
ChartConfiguration,
ChartData,
ChartEvent,
ChartType,
Color,
TooltipItem,
} from 'chart.js';
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -60,184 +69,189 @@ export const getGraphOptions = (
minTime?: number,
maxTime?: number,
// eslint-disable-next-line sonarjs/cognitive-complexity
): CustomChartOptions => ({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
): CustomChartOptions =>
({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
},
],
],
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context: TooltipItem<'line'>[]): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context: TooltipItem<'line'>): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData: TooltipItem<'line'>): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
position: 'custom',
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
return item2.parsed.y - item1.parsed.y;
},
},
position: 'custom',
itemSort(item1, item2): number {
return item2.parsed.y - item1.parsed.y;
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
grid: {
display: true,
color: getGridColor(),
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
grid: {
display: true,
color: getGridColor(),
},
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value: number | string): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
},
},
},
},
},
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
hoverRadius: 5,
},
},
onClick: (event, element, chart): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
},
onHover: (event, _, chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
hoverRadius: 5,
},
},
onClick: (
event: ChartEvent,
element: ActiveElement[],
chart: Chart,
): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
}
},
});
},
onHover: (event: ChartEvent, _: ActiveElement[], chart: Chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
}
}
},
}) as CustomChartOptions;
declare module 'chart.js' {
interface TooltipPositionerMap {

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { SolidAlertTriangle } from '@signozhq/icons';
import { Select, Tooltip } from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import classNames from 'classnames';
import { UniversalYAxisUnitMappings } from './constants';
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
@@ -72,7 +72,9 @@ function YAxisUnitSelector({
}, [categoriesOverride, source]);
return (
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
<div
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<Select
showSearch
value={universalUnit}
@@ -82,17 +84,12 @@ function YAxisUnitSelector({
loading={loading}
suffixIcon={
incompatibleUnitMessage ? (
<Tooltip
title={incompatibleUnitMessage}
overlayClassName="y-axis-unit-warning-tooltip"
>
<span className="y-axis-unit-warning" role="img" aria-label="warning">
<SolidAlertTriangle size="md" />
</span>
<Tooltip title={incompatibleUnitMessage}>
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
</Tooltip>
) : undefined
}
className={cx({
className={classNames({
'warning-state': incompatibleUnitMessage,
})}
data-testid={dataTestId}

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisSource } from '../types';
@@ -7,13 +6,9 @@ import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => {
const mockOnChange = jest.fn();
// antd injects its `pointer-events` styles via cssinjs in jsdom, but the SCSS
// overrides aren't loaded — skip the pointer-events check so hovers/clicks register.
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
mockOnChange.mockClear();
user = userEvent.setup({ pointerEventsCheck: 0 });
});
it('renders with default placeholder', () => {
@@ -39,7 +34,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
});
it('calls onChange when a value is selected', async () => {
it('calls onChange when a value is selected', () => {
render(
<YAxisUnitSelector
value=""
@@ -49,8 +44,9 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
await user.click(select);
await user.click(screen.getByText('Bytes (B)'));
fireEvent.mouseDown(select);
const option = screen.getByText('Bytes (B)');
fireEvent.click(option);
expect(mockOnChange).toHaveBeenCalledWith('By', {
children: 'Bytes (B)',
@@ -59,7 +55,7 @@ describe('YAxisUnitSelector', () => {
});
});
it('filters options based on search input', async () => {
it('filters options based on search input', () => {
render(
<YAxisUnitSelector
value=""
@@ -69,13 +65,14 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
await user.click(select);
await user.type(select, 'bytes/sec');
fireEvent.mouseDown(select);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'bytes/sec' } });
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
});
it('shows all categories and their units', async () => {
it('shows all categories and their units', () => {
render(
<YAxisUnitSelector
value=""
@@ -83,8 +80,9 @@ describe('YAxisUnitSelector', () => {
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
await user.click(screen.getByRole('combobox'));
fireEvent.mouseDown(select);
// Check for category headers
expect(screen.getByText('Data')).toBeInTheDocument();
@@ -95,7 +93,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
});
it('shows warning message when incompatible unit is selected', async () => {
it('shows warning message when incompatible unit is selected', () => {
render(
<YAxisUnitSelector
source={YAxisSource.ALERTS}
@@ -106,12 +104,12 @@ describe('YAxisUnitSelector', () => {
);
const warningIcon = screen.getByLabelText('warning');
expect(warningIcon).toBeInTheDocument();
await user.hover(warningIcon);
await expect(
screen.findByText(
fireEvent.mouseOver(warningIcon);
return screen
.findByText(
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
),
).resolves.toBeInTheDocument();
)
.then((el) => expect(el).toBeInTheDocument());
});
it('does not show warning message when compatible unit is selected', () => {
@@ -127,7 +125,7 @@ describe('YAxisUnitSelector', () => {
expect(warningIcon).not.toBeInTheDocument();
});
it('uses categories override to render custom units', async () => {
it('uses categories override to render custom units', () => {
const customCategories = [
{
name: YAxisCategoryNames.Data,
@@ -149,7 +147,9 @@ describe('YAxisUnitSelector', () => {
/>,
);
await user.click(screen.getByRole('combobox'));
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();

View File

@@ -4,13 +4,6 @@
}
}
// Re-enable hover on the warning icon: its `.ant-select-arrow` parent sets
// `pointer-events: none`, which would otherwise suppress the tooltip.
.y-axis-unit-warning {
display: inline-flex;
pointer-events: auto;
}
.warning-state {
.ant-select-selector {
border-color: var(--bg-amber-400) !important;
@@ -24,7 +17,3 @@
right: 28px;
}
}
.y-axis-unit-warning-tooltip {
max-width: 240px;
}

View File

@@ -1,8 +0,0 @@
.emptyMeterSearch {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

View File

@@ -1,26 +0,0 @@
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './EmptyMeterSearch.module.scss';
interface EmptyMeterSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMeterSearch({
hasQueryResult,
}: EmptyMeterSearchProps): JSX.Element {
return (
<div className={styles.emptyMeterSearch}>
<Empty
description={
<Typography.Title level={5}>
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
</Typography.Title>
}
/>
</div>
);
}

View File

@@ -73,6 +73,34 @@
margin-top: 10px;
margin-bottom: 20px;
}
.empty-meter-search {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.time-series-view-panel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 8px !important;
margin: 8px;
}
.time-series-container {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(100%, calc(50% - 8px)), 1fr)
);
gap: 16px;
width: 100%;
height: fit-content;
}
}
}
@@ -85,6 +113,22 @@
padding-bottom: 80px;
}
.meter-time-series-container {
display: flex;
flex-direction: column;
gap: 10px;
.builder-units-filter {
padding: 0 8px;
margin-bottom: 0px !important;
.builder-units-filter-label {
margin-bottom: 0px !important;
font-size: 12px;
}
}
}
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;

View File

@@ -1,18 +0,0 @@
.loadingMeter {
display: flex;
justify-content: center;
align-items: flex-start;
height: 240px;
padding: var(--spacing-12) 0;
}
.loadingMeterContent {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.loadingGif {
height: 72px;
margin-left: calc(-1 * var(--spacing-12));
}

View File

@@ -1,17 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import styles from './MeterLoading.module.scss';
export default function MeterLoading(): JSX.Element {
return (
<div className={styles.loadingMeter}>
<div className={styles.loadingMeterContent}>
<img className={styles.loadingGif} src={loadingPlaneUrl} alt="wait-icon" />
<Typography>Retrieving your {DataSource.METRICS}</Typography>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
.meterTimeSeriesContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-5);
width: 100%;
:global(.builder-units-filter) {
padding: 0 var(--spacing-4);
margin-bottom: 0 !important;
}
:global(.builder-units-filter-label) {
margin-bottom: 0 !important;
font-size: 12px;
}
}
.timeSeriesContainer {
gap: var(--spacing-8);
width: 100%;
height: 50vh;
max-height: 50vh;
padding-right: 16px;
padding-left: 8px;
}
.timeSeriesViewPanel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
}

View File

@@ -1,28 +1,27 @@
import { useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useQueries } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { isAxiosError } from 'axios';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { buildMeterChartConfig } from './configBuilder';
import EmptyMeterSearch from './EmptyMeterSearch';
import MeterLoading from './MeterLoading';
import styles from './TimeSeries.module.scss';
import { useTimeSeriesQueries } from './useTimeSeriesQueries';
import { useTimeSeriesTimeManagement } from './useTimeSeriesTimeManagement';
const WIDGET_ID = 'meter-explorer-bar-chart';
interface TimeSeriesProps {
onFetchingStateChange?: (isFetching: boolean) => void;
@@ -33,124 +32,144 @@ function TimeSeries({
onFetchingStateChange,
isCancelled = false,
}: TimeSeriesProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const { stagedQuery, currentQuery } = useQueryBuilder();
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const containerDimensions = useResizeObserver(graphRef);
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { minTimeScale, maxTimeScale, onDragSelect } =
useTimeSeriesTimeManagement({
globalSelectedTime,
maxTime,
minTime,
});
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
const { responseData, isLoading, isError } = useTimeSeriesQueries({
stagedQuery,
currentQuery,
globalSelectedTime,
maxTime,
minTime,
onFetchingStateChange,
});
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||
aggregateAttribute?.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const queryPayloads = useMemo(
() => [stagedQuery || initialQueryMeterWithType],
[stagedQuery],
);
const { showErrorModal } = useErrorModal();
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE,
payload,
ENTITY_VERSION_V5,
globalSelectedTime,
maxTime,
minTime,
index,
],
queryFn: ({
signal,
}: {
signal?: AbortSignal;
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(
{
query: payload,
graphType: PANEL_TYPES.BAR,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: DataSource.METRICS,
},
},
ENTITY_VERSION_V5,
undefined,
signal,
),
enabled: !!payload,
retry: (failureCount: number, error: unknown): boolean => {
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
return false;
}
let status: number | undefined;
if (error instanceof APIError) {
status = error.getHttpStatusCode();
} else if (isAxiosError(error)) {
status = error.response?.status;
}
if (status && status >= 400 && status < 500) {
return false;
}
return failureCount < MAX_QUERY_RETRIES;
},
onError: (error: APIError): void => {
showErrorModal(error);
},
})),
);
const isFetching = queries.some((q) => q.isFetching);
useEffect(() => {
onFetchingStateChange?.(isFetching);
}, [isFetching, onFetchingStateChange]);
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
const responseData = useMemo(
() =>
data.map((datapoint) =>
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
),
[data, isValidToConvertToMs],
);
const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery],
);
const chartsData = useMemo(() => {
return responseData.map((response, index) => {
const apiResponse = response?.payload;
const config = buildMeterChartConfig({
id: `${WIDGET_ID}-${index}`,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit: yAxisUnit || 'short',
minTimeScale,
maxTimeScale,
});
const chartData = apiResponse ? prepareChartData(apiResponse) : [];
return {
config,
chartData,
hasData: chartData.length > 0 && chartData[0]?.length > 0,
};
});
}, [
responseData,
currentQuery,
yAxisUnit,
isDarkMode,
onDragSelect,
timezone,
minTimeScale,
maxTimeScale,
]);
const hasAnyData = chartsData.some((chart) => chart.hasData);
return (
<div className={styles.meterTimeSeriesContainer}>
<div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
<div className={styles.timeSeriesContainer} ref={graphRef}>
{!hasMetricSelected && <EmptyMeterSearch />}
<div className="time-series-container">
{!hasMetricSelected && <EmptyMetricsSearch />}
{isCancelled && hasMetricSelected && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
)}
{isLoading && hasMetricSelected && !isCancelled && <MeterLoading />}
{!isCancelled &&
hasMetricSelected &&
!isLoading &&
!isError &&
!hasAnyData && (
<EmptyMeterSearch hasQueryResult={responseData[0] !== undefined} />
)}
{!isCancelled &&
hasMetricSelected &&
!isLoading &&
!isError &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 &&
chartsData.map(
(chart, index) =>
chart.hasData && (
<div
className={styles.timeSeriesViewPanel}
// oxlint-disable-next-line react/no-array-index-key -- query responses have no stable ID
key={`${WIDGET_ID}-${index}`}
>
<BarChart
config={chart.config}
legendConfig={{
position: LegendPosition.BOTTOM,
}}
data={chart.chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
isStackedBarChart
yAxisUnit={yAxisUnit || 'short'}
timezone={timezone}
/>
</div>
),
)}
responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
</div>
</div>
);

View File

@@ -1,117 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
DrawStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
export interface MeterChartConfigProps {
id: string;
isDarkMode: boolean;
currentQuery: Query;
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse?: MetricRangePayloadProps;
timezone: Timezone;
yAxisUnit: string;
minTimeScale?: number;
maxTimeScale?: number;
}
export function buildMeterChartConfig({
id,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit,
minTimeScale,
maxTimeScale,
}: MeterChartConfigProps): UPlotConfigBuilder {
const stepIntervals = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
) as Record<string, number>;
const minStepInterval = Object.keys(stepIntervals).length
? Math.min(...Object.values(stepIntervals))
: undefined;
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
tzDate,
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
stepInterval: minStepInterval,
});
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
});
builder.addScale({
scaleKey: 'y',
time: false,
});
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.BAR,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
if (!apiResponse?.data?.result) {
return builder;
}
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
builder.setBands(getInitialStackedBands(seriesCount));
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = getLegend(series, currentQuery, baseLabelName);
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping: {},
isDarkMode,
stepInterval: currentStepInterval,
metric: series.metric,
});
});
return builder;
}

View File

@@ -1,146 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useQueries } from 'react-query';
import { isAxiosError } from 'axios';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { SuccessResponse } from 'types/api';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesQueriesProps {
stagedQuery: Query | null;
currentQuery: Query;
globalSelectedTime: Time | CustomTimeType;
maxTime: number;
minTime: number;
onFetchingStateChange?: (isFetching: boolean) => void;
}
interface UseTimeSeriesQueriesResult {
responseData: (SuccessResponse<MetricRangePayloadProps> | undefined)[];
isLoading: boolean;
isError: boolean;
}
export function useTimeSeriesQueries({
stagedQuery,
currentQuery,
globalSelectedTime,
maxTime,
minTime,
onFetchingStateChange,
}: UseTimeSeriesQueriesProps): UseTimeSeriesQueriesResult {
const { showErrorModal } = useErrorModal();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||
aggregateAttribute?.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const queryPayloads = useMemo(
() => [stagedQuery || initialQueryMeterWithType],
[stagedQuery],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE,
payload,
ENTITY_VERSION_V5,
globalSelectedTime,
maxTime,
minTime,
index,
],
queryFn: ({
signal,
}: {
signal?: AbortSignal;
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(
{
query: payload,
graphType: PANEL_TYPES.BAR,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: DataSource.METRICS,
},
},
ENTITY_VERSION_V5,
undefined,
signal,
),
enabled: !!payload,
retry: (failureCount: number, error: unknown): boolean => {
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
return false;
}
let status: number | undefined;
if (error instanceof APIError) {
status = error.getHttpStatusCode();
} else if (isAxiosError(error)) {
status = error.response?.status;
}
if (status && status >= 400 && status < 500) {
return false;
}
return failureCount < MAX_QUERY_RETRIES;
},
onError: (error: APIError): void => {
showErrorModal(error);
},
})),
);
const isFetching = queries.some((q) => q.isFetching);
useEffect(() => {
onFetchingStateChange?.(isFetching);
}, [isFetching, onFetchingStateChange]);
const responseData = useMemo(() => {
const data = queries.map(({ data }) => data) ?? [];
return data.map((datapoint) =>
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
);
}, [queries, isValidToConvertToMs]);
const isLoading = queries.some((q) => q.isLoading);
const isError = queries.some((q) => q.isError);
return {
responseData,
isLoading,
isError,
};
}

View File

@@ -1,102 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { UpdateTimeInterval } from 'store/actions';
import { getTimeRange } from 'utils/getTimeRange';
interface UseTimeSeriesTimeManagementProps {
globalSelectedTime: Time | CustomTimeType;
maxTime: number;
minTime: number;
}
interface UseTimeSeriesTimeManagementResult {
minTimeScale: number | undefined;
maxTimeScale: number | undefined;
onDragSelect: (start: number, end: number) => void;
}
export function useTimeSeriesTimeManagement({
globalSelectedTime,
maxTime,
minTime,
}: UseTimeSeriesTimeManagementProps): UseTimeSeriesTimeManagementResult {
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const location = useLocation();
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
useEffect((): void => {
const { startTime, endTime } = getTimeRange();
setMinTimeScale(startTime);
setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedTime]);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
const { maxTime, minTime } = GetMinMax('custom', [
startTimestamp,
endTimestamp,
]);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
urlQuery.delete(QueryParams.relativeTime);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
},
[dispatch, location.pathname, urlQuery],
);
const handleBackNavigation = useCallback((): void => {
const searchParams = new URLSearchParams(window.location.search);
const startTime = searchParams.get(QueryParams.startTime);
const endTime = searchParams.get(QueryParams.endTime);
const relativeTime = searchParams.get(
QueryParams.relativeTime,
) as CustomTimeType;
if (relativeTime) {
dispatch(UpdateTimeInterval(relativeTime));
} else if (startTime && endTime && startTime !== endTime) {
dispatch(
UpdateTimeInterval('custom', [
parseInt(getTimeString(startTime), 10),
parseInt(getTimeString(endTime), 10),
]),
);
}
}, [dispatch]);
useEffect(() => {
window.addEventListener('popstate', handleBackNavigation);
return (): void => {
window.removeEventListener('popstate', handleBackNavigation);
};
}, [handleBackNavigation]);
return {
minTimeScale,
maxTimeScale,
onDragSelect,
};
}

View File

@@ -79,11 +79,13 @@ function Panel({
},
ENTITY_VERSION_V5,
{
// Public data is fetched by index and the payload redacts each widget's
// filters, so query bodies are identical across panels. Key on panel
// identity + time — the only inputs that determine the response — so
// panels don't collapse onto one cache entry.
queryKey: [widget?.id, index, startTime, endTime],
queryKey: [
widget?.query,
widget?.panelTypes,
requestData,
startTime,
endTime,
],
retry(failureCount, error): boolean {
if (
String(error).includes('status: error') &&

View File

@@ -1,79 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render } from 'tests/test-utils';
import { Widgets } from 'types/api/dashboard/getAll';
import Panel from '../Panel';
const useGetQueryRangeMock = jest.fn();
jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
useGetQueryRange: (...args: unknown[]): unknown => {
useGetQueryRangeMock(...args);
return {
data: undefined,
isFetching: false,
isLoading: false,
isSuccess: true,
isError: false,
};
},
}));
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="widget-graph" />,
}));
const buildWidget = (id: string): Widgets =>
({
id,
panelTypes: PANEL_TYPES.LIST,
query: {
builder: {
queryData: [{ dataSource: 'logs', limit: 100, orderBy: [] }],
},
},
timePreferance: 'GLOBAL_TIME',
}) as unknown as Widgets;
describe('Public dashboard Panel', () => {
beforeEach(() => {
useGetQueryRangeMock.mockClear();
});
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {
render(
<>
<Panel
widget={buildWidget('widget-a')}
index={2}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>
<Panel
widget={buildWidget('widget-b')}
index={62}
dashboardId="dash-1"
startTime={100}
endTime={200}
/>
</>,
);
const [callA, callB] = useGetQueryRangeMock.mock.calls;
const queryKeyA = callA[2].queryKey;
const metaA = callA[4];
const queryKeyB = callB[2].queryKey;
const metaB = callB[4];
// Key is panel identity + time only — the redacted query body is not part
// of it, so identical query bodies can't collapse two panels onto one key.
expect(queryKeyA).toStrictEqual(['widget-a', 2, 100, 200]);
expect(queryKeyB).toStrictEqual(['widget-b', 62, 100, 200]);
expect(queryKeyA).not.toStrictEqual(queryKeyB);
expect(metaA.widgetIndex).toBe(2);
expect(metaB.widgetIndex).toBe(62);
});
});

View File

@@ -30,7 +30,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { stackSeries } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { isEmpty } from 'lodash-es';
@@ -58,7 +57,6 @@ function TimeSeriesView({
dataSource,
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -67,23 +65,11 @@ function TimeSeriesView({
const location = useLocation();
const { currentQuery } = useQueryBuilder();
const rawChartData = useMemo(
const chartData = useMemo(
() => getUPlotChartData(data?.payload),
[data?.payload],
);
const { chartData, stackedBands } = useMemo(() => {
if (!stackBarChart || !rawChartData || rawChartData.length < 2) {
return { chartData: rawChartData, stackedBands: null };
}
const noSeriesHidden = (): boolean => false;
const { data: stacked, bands } = stackSeries(
rawChartData as uPlot.AlignedData,
noSeriesHidden,
);
return { chartData: stacked, stackedBands: bands };
}, [rawChartData, stackBarChart]);
useEffect(() => {
if (data?.payload) {
setWarning?.(data?.warning);
@@ -203,7 +189,7 @@ function TimeSeriesView({
const { timezone } = useTimezone();
const baseChartOptions = getUPlotChartOptions({
const chartOptions = getUPlotChartOptions({
id: 'time-series-explorer',
onDragSelect,
yAxisUnit: yAxisUnit || '',
@@ -236,14 +222,6 @@ function TimeSeriesView({
},
});
const chartOptions = useMemo(
() =>
stackedBands
? { ...baseChartOptions, bands: stackedBands }
: baseChartOptions,
[baseChartOptions, stackedBands],
);
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
@@ -304,7 +282,6 @@ interface TimeSeriesViewProps {
dataSource: DataSource;
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
}
TimeSeriesView.defaultProps = {
@@ -313,7 +290,6 @@ TimeSeriesView.defaultProps = {
error: undefined,
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
};
export default TimeSeriesView;

View File

@@ -4,6 +4,7 @@ import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
@@ -17,7 +18,6 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useCreatePanel } from '../hooks/useCreatePanel';
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
@@ -51,7 +51,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
@@ -89,13 +88,14 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
value: next,
},
];
await patchAsync(patch);
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[id, patchAsync, showErrorModal],
[id, refetch, showErrorModal],
);
const { isEditing, draft, setDraft, startEdit, cancel, commit } =

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
@@ -8,7 +9,7 @@ import { isEqual } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
@@ -22,7 +23,7 @@ interface OverviewProps {
function Overview({ dashboard }: OverviewProps): JSX.Element {
const id = dashboard.id;
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
@@ -95,14 +96,15 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [buildPatch, patchAsync, showErrorModal]);
}, [id, buildPatch, refetch, showErrorModal]);
useEffect(() => {
let numberOfUnsavedChanges = 0;

View File

@@ -1,9 +1,9 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
@@ -14,9 +14,14 @@ interface UseSaveVariables {
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
@@ -28,8 +33,9 @@ export function useSaveVariables(): UseSaveVariables {
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchAsync(buildVariablesPatch(dtos));
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -38,7 +44,7 @@ export function useSaveVariables(): UseSaveVariables {
setIsSaving(false);
}
},
[dashboardId, patchAsync, showErrorModal],
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };

View File

@@ -40,8 +40,6 @@ interface ConfigPaneProps {
*/
panel: DashboardtypesPanelDTO;
panelId: string;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}
/**
@@ -60,7 +58,6 @@ function ConfigPane({
stepInterval,
panel,
panelId,
metricUnit,
}: ConfigPaneProps): JSX.Element {
const panelKind = spec.plugin.kind;
const definition = getPanelDefinition(panelKind);
@@ -121,7 +118,6 @@ function ConfigPane({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
))}
</div>

View File

@@ -34,7 +34,6 @@ function SectionSlot({
onChangePanelKind,
queryType,
stepInterval,
metricUnit,
}: SectionSlotProps): JSX.Element | null {
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
@@ -75,7 +74,6 @@ function SectionSlot({
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</SettingsSection>
);

View File

@@ -19,6 +19,4 @@ export interface SectionEditorContext {
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
}

View File

@@ -46,10 +46,11 @@ function DisconnectValuesField({
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
// `fillOnlyBelow` is authoritative; fall back to a stored duration for legacy panels.
const isThreshold = value?.fillOnlyBelow ?? !!duration;
// Remember the last committed threshold so Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState<string | undefined>(duration);
const isThreshold = !!duration;
// Remember the last threshold so toggling Never → Threshold restores it.
const [lastDuration, setLastDuration] = useState(
duration ?? defaultDuration(stepInterval),
);
useEffect(() => {
if (duration) {
@@ -58,17 +59,11 @@ function DisconnectValuesField({
}, [duration]);
const handleMode = (mode: DisconnectValuesMode): void => {
if (mode === DisconnectValuesMode.THRESHOLD) {
onChange({
...value,
fillOnlyBelow: true,
// Seed from the live stepInterval (async — undefined until results load), not mount.
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
});
return;
}
// Never spans every gap; drop the duration so the renderer reads a clean "span all".
onChange({ ...value, fillOnlyBelow: false, fillLessThan: undefined });
onChange(
mode === DisconnectValuesMode.THRESHOLD
? { ...value, fillLessThan: lastDuration }
: undefined,
);
};
return (
@@ -84,16 +79,14 @@ function DisconnectValuesField({
onChange={handleMode}
/>
</div>
{isThreshold && duration && (
{isThreshold && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={duration}
value={lastDuration}
minValue={stepInterval}
onChange={(next): void =>
onChange({ ...value, fillOnlyBelow: true, fillLessThan: next })
}
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
/>
</div>
)}

View File

@@ -14,28 +14,6 @@ interface DisconnectValuesThresholdInputProps {
onChange: (duration: string) => void;
}
/**
* Inline error for a raw duration, or `null` when valid and in range. The parse is
* guarded: `isValidTimeSpan` passes some strings `intervalToSeconds` throws on (e.g. "5x").
*/
function validationError(raw: string, minValue?: number): string | null {
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
return 'Enter a valid duration (e.g. 30s, 1m, 1h)';
}
if (minValue !== undefined && seconds < minValue) {
return `Threshold should be > ${rangeUtil.secondsToHms(minValue)}`;
}
return null;
}
/**
* Duration input for the span-gaps threshold: shows/accepts and reports a human
* duration ("30s", "1m", "1h"), which is the value stored verbatim in
@@ -58,21 +36,24 @@ function DisconnectValuesThresholdInput({
setError(null);
}, [value]);
// Validate live so an invalid entry surfaces immediately, not only on blur.
const handleText = (raw: string): void => {
setText(raw);
setError(raw ? validationError(raw, minValue) : null);
};
const commit = (raw: string): void => {
// Skip no-op commits: blur fires when clicking the Never toggle, and re-emitting
// the unchanged value there would race the toggle and snap back to Threshold.
if (!raw || raw === value) {
if (!raw) {
return;
}
const message = validationError(raw, minValue);
if (message) {
setError(message);
let seconds: number;
try {
seconds = rangeUtil.isValidTimeSpan(raw)
? rangeUtil.intervalToSeconds(raw)
: NaN;
} catch {
seconds = NaN;
}
if (!Number.isFinite(seconds) || seconds <= 0) {
setError('Enter a valid duration (e.g. 30s, 1m, 1h)');
return;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
@@ -88,9 +69,12 @@ function DisconnectValuesThresholdInput({
status={error ? 'error' : undefined}
prefix={<span className={styles.thresholdPrefix}>&gt;</span>}
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
handleText(e.target.value)
}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setText(e.target.value);
if (error) {
setError(null);
}
}}
onBlur={(e): void => commit(e.currentTarget.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {

View File

@@ -1,34 +1,9 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
DashboardtypesLineStyleDTO,
type DashboardtypesTimeSeriesChartAppearanceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
import ChartAppearanceSection from '../ChartAppearanceSection';
/** Stateful wrapper that feeds onChange back as the spec, mirroring the real editor. */
function StatefulSpanGaps({
initial,
stepInterval,
}: {
initial?: DashboardtypesTimeSeriesChartAppearanceDTO;
stepInterval?: number;
}): JSX.Element {
const [value, setValue] = useState<
DashboardtypesTimeSeriesChartAppearanceDTO | undefined
>(initial);
return (
<ChartAppearanceSection
value={value}
controls={{ spanGaps: true }}
stepInterval={stepInterval}
onChange={setValue}
/>
);
}
// Open the antd Select by clicking its selector, then pick the option by label. The
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
// only used for the line-interpolation ConfigSelect.
@@ -164,7 +139,7 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
spanGaps: { fillLessThan: '1m' },
});
});
@@ -187,7 +162,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
spanGaps: { fillLessThan: '5m' },
});
});
@@ -208,7 +183,7 @@ describe('ChartAppearanceSection', () => {
await user.tab();
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '300' },
spanGaps: { fillLessThan: '300' },
});
});
@@ -225,24 +200,7 @@ describe('ChartAppearanceSection', () => {
await user.click(screen.getByText('Never'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
});
});
it('selects Never when fillOnlyBelow is false even if a duration lingers', () => {
render(
<ChartAppearanceSection
value={{ spanGaps: { fillOnlyBelow: false, fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
// The flag is authoritative: a stale fillLessThan must not show Threshold.
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
});
it('shows an error and does not commit an invalid duration', async () => {
@@ -286,117 +244,4 @@ describe('ChartAppearanceSection', () => {
expect(screen.getByText(/Threshold should be >/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('seeds the threshold from the step interval when switching to Threshold', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('seeds from the step interval even when it arrives after mount', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
// The step interval is undefined until the query response carries step metadata,
// so the panel first renders without it and receives it on a later render.
const { rerender } = render(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
rerender(
<ChartAppearanceSection
value={undefined}
controls={{ spanGaps: true }}
stepInterval={300}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Threshold'));
// Regression: a value seeded at mount would still be the 1m fallback.
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
});
});
it('shows a validation error while typing, before blur', async () => {
const user = userEvent.setup();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={jest.fn()}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.clear(input);
await user.type(input, 'abc');
// No blur / Enter — the error must already be visible.
expect(screen.getByText(/valid duration/i)).toBeInTheDocument();
});
it('does not re-commit the threshold when blurred without a change', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ spanGaps: { fillLessThan: '1m' } }}
controls={{ spanGaps: true }}
onChange={onChange}
/>,
);
const input = screen.getByTestId('panel-editor-v2-span-gaps-value');
await user.click(input);
await user.tab();
expect(onChange).not.toHaveBeenCalled();
});
it('fully switches from Threshold to Never (the input disappears)', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '1m' } }} />);
expect(
screen.getByTestId('panel-editor-v2-span-gaps-value'),
).toBeInTheDocument();
// Focus the input first so clicking Never also fires its blur (the toggle race).
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
await user.click(screen.getByText('Never'));
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
});
it('remembers the last threshold when toggling Never → Threshold', async () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
await user.click(screen.getByText('Never'));
await user.click(screen.getByText('Threshold'));
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
'5m',
);
});
});

View File

@@ -14,7 +14,7 @@ import ColumnUnits from './ColumnUnits';
import styles from './FormattingSection.module.scss';
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
Pick<SectionEditorContext, 'tableColumns'>;
// `full` means "show the raw value, no rounding"; the digits round to that many places.
const DECIMAL_OPTIONS: {
@@ -39,7 +39,6 @@ function FormattingSection({
controls,
onChange,
tableColumns = [],
metricUnit,
}: FormattingSectionProps): JSX.Element {
return (
<>
@@ -51,7 +50,6 @@ function FormattingSection({
data-testid="panel-editor-v2-unit"
source={YAxisSource.DASHBOARDS}
value={value?.unit}
initialValue={metricUnit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>

View File

@@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event';
import FormattingSection from '../FormattingSection';
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
// Open the Decimals select (clicking its antd selector) and pick the option with the
// given visible label.
async function pickDecimal(label: string): Promise<void> {
@@ -73,31 +71,4 @@ describe('FormattingSection', () => {
decimalPrecision: '2',
});
});
it('warns when the selected unit mismatches the metric unit', () => {
// metric sent in seconds, but bytes is selected.
render(
<FormattingSection
value={{ unit: 'By' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.getByLabelText('warning')).toBeInTheDocument();
});
it('shows no warning when the selected unit matches the metric unit', () => {
render(
<FormattingSection
value={{ unit: 's' }}
controls={{ unit: true }}
metricUnit="s"
onChange={jest.fn()}
/>,
);
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
@@ -82,15 +82,6 @@ function ThresholdsSection({
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
// The saved threshold captured on edit entry, restored if the edit is discarded
// (edits stream into the spec live, so Discard can't just drop a local draft).
const editSnapshot = useRef<AnyThreshold | null>(null);
const updateAt =
(index: number) =>
(next: AnyThreshold): void => {
onChange(thresholds.map((t, i) => (i === index ? next : t)));
};
const addThreshold = (): void => {
const nextIndex = thresholds.length;
@@ -99,11 +90,6 @@ function ThresholdsSection({
setUnsavedIndex(nextIndex);
};
const beginEdit = (index: number): void => {
editSnapshot.current = thresholds[index] ?? null;
setEditingIndex(index);
};
const saveAt =
(index: number) =>
(next: AnyThreshold): void => {
@@ -119,15 +105,11 @@ function ThresholdsSection({
};
const discardAt = (index: number) => (): void => {
// A never-saved row is removed; otherwise revert the live edits to the snapshot.
// Discarding a row that was never saved removes it; otherwise just exit edit.
if (index === unsavedIndex) {
removeAt(index);
return;
}
const original = editSnapshot.current;
if (original) {
onChange(thresholds.map((t, i) => (i === index ? original : t)));
}
setEditingIndex(null);
};
@@ -138,9 +120,8 @@ function ThresholdsSection({
index,
yAxisUnit,
isEditing: editingIndex === index,
onEdit: (): void => beginEdit(index),
onEdit: (): void => setEditingIndex(index),
onSave: saveAt(index),
onLiveChange: updateAt(index),
onDiscard: discardAt(index),
onRemove: (): void => removeAt(index),
};

View File

@@ -5,7 +5,7 @@ import {
DashboardtypesThresholdFormatDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent } from 'tests/test-utils';
import UnifiedThresholdsSection from '../ThresholdsSection';
@@ -36,16 +36,9 @@ const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
},
];
// Stateful harness for flows that depend on the value updating (add/discard/live).
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: DashboardtypesComparisonThresholdDTO[];
}): JSX.Element {
const [value, setValue] =
useState<DashboardtypesComparisonThresholdDTO[]>(initial);
// Stateful harness for flows that depend on the value updating (add/discard).
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
return (
<ComparisonThresholdsSection
value={value}
@@ -149,46 +142,24 @@ describe('ComparisonThresholdsSection', () => {
expect(valueInput).toHaveValue(5);
});
it('reflects edits live (before Save) so the preview can react', async () => {
it('does not commit edits when Discard is clicked', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
// No Save click — the latest edit is pushed up (debounced) for the preview.
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith([
{
value: 90,
color: '#F5B225',
operator: DashboardtypesComparisonOperatorDTO.above,
unit: 'percent',
format: DashboardtypesThresholdFormatDTO.background,
},
]),
);
});
it('reverts the live edits to the saved value on Discard', async () => {
const user = userEvent.setup();
render(<Harness initial={THRESHOLDS} />);
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
expect(onChange).not.toHaveBeenCalled();
// Back to view mode.
expect(
screen.queryByTestId('comparison-threshold-value-0'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', async () => {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
@@ -10,16 +10,10 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
];
// Stateful harness for flows that depend on the value updating (add/discard/live);
// Stateful harness for flows that depend on the value updating (add/discard);
// omits `controls` to exercise the default `label` variant.
function Harness({
yAxisUnit,
initial = [],
}: {
yAxisUnit?: string;
initial?: AnyThreshold[];
}): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>(initial);
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
);
@@ -43,20 +37,19 @@ describe('ThresholdsSection', () => {
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
});
it('edits a threshold value and commits it on Save', async () => {
const user = userEvent.setup();
it('edits a threshold value and commits it on Save', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
await user.click(screen.getByTestId('threshold-edit-0'));
const valueInput = screen.getByTestId('threshold-value-0');
expect(valueInput).toHaveValue(80);
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
await user.clear(valueInput);
await user.type(valueInput, '90');
await user.click(screen.getByTestId('threshold-save-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-save-0'));
expect(onChange).toHaveBeenLastCalledWith([
expect(onChange).toHaveBeenCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]);
});
@@ -77,63 +70,43 @@ describe('ThresholdsSection', () => {
]);
});
it('reflects edits live (before Save) so the preview can react', async () => {
const user = userEvent.setup();
it('does not commit edits when Discard is clicked', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
await user.click(screen.getByTestId('threshold-edit-0'));
await user.clear(screen.getByTestId('threshold-value-0'));
await user.type(screen.getByTestId('threshold-value-0'), '90');
fireEvent.click(screen.getByTestId('threshold-edit-0'));
fireEvent.change(screen.getByTestId('threshold-value-0'), {
target: { value: '90' },
});
fireEvent.click(screen.getByTestId('threshold-discard-0'));
// No Save click — the edit is pushed up (debounced) for the preview to render.
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith([
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
]),
);
});
it('reverts the live edits to the saved value on Discard', async () => {
const user = userEvent.setup();
render(<Harness initial={THRESHOLDS} />);
await user.click(screen.getByTestId('threshold-edit-0'));
await user.clear(screen.getByTestId('threshold-value-0'));
await user.type(screen.getByTestId('threshold-value-0'), '90');
await user.click(screen.getByTestId('threshold-discard-0'));
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
await user.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
});
it('removes a threshold from view mode', async () => {
const user = userEvent.setup();
it('removes a threshold from view mode', () => {
const onChange = jest.fn();
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
await user.click(screen.getByTestId('threshold-remove-0'));
fireEvent.click(screen.getByTestId('threshold-remove-0'));
expect(onChange).toHaveBeenCalledWith([]);
});
it('adds a threshold that opens in edit mode, and discards it away', async () => {
const user = userEvent.setup();
it('adds a threshold that opens in edit mode, and discards it away', () => {
render(<Harness />);
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
// Discarding a never-saved row removes it entirely.
await user.click(screen.getByTestId('threshold-discard-0'));
fireEvent.click(screen.getByTestId('threshold-discard-0'));
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
});
it('flags a threshold unit in a different category than the y-axis unit', async () => {
const user = userEvent.setup();
it('flags a threshold unit in a different category than the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
@@ -142,12 +115,11 @@ describe('ThresholdsSection', () => {
/>,
);
await user.click(screen.getByTestId('threshold-edit-0'));
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(screen.getByTestId('threshold-unit-invalid-0')).toBeInTheDocument();
});
it('does not flag a threshold unit in the same category as the y-axis unit', async () => {
const user = userEvent.setup();
it('does not flag a threshold unit in the same category as the y-axis unit', () => {
render(
<ThresholdsSection
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
@@ -156,7 +128,7 @@ describe('ThresholdsSection', () => {
/>,
);
await user.click(screen.getByTestId('threshold-edit-0'));
fireEvent.click(screen.getByTestId('threshold-edit-0'));
expect(
screen.queryByTestId('threshold-unit-invalid-0'),
).not.toBeInTheDocument();

View File

@@ -27,7 +27,6 @@ interface ComparisonThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
onLiveChange: (next: DashboardtypesComparisonThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -43,15 +42,10 @@ function ComparisonThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: ComparisonThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
const summary = (

View File

@@ -20,7 +20,6 @@ interface LabelThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onLiveChange: (next: DashboardtypesThresholdWithLabelDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -33,15 +32,10 @@ function LabelThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: LabelThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Persist an empty-string label when none was entered — the spec requires a string.
const handleSave = useCallback((): void => {

View File

@@ -28,7 +28,6 @@ interface TableThresholdRowProps {
isEditing: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesTableThresholdDTO) => void;
onLiveChange: (next: DashboardtypesTableThresholdDTO) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -46,15 +45,10 @@ function TableThresholdRow({
isEditing,
onEdit,
onSave,
onLiveChange,
onDiscard,
onRemove,
}: TableThresholdRowProps): JSX.Element {
const { draft, setDraft, setValue } = useThresholdDraft(
threshold,
isEditing,
onLiveChange,
);
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
// Stored columnName is the query key; resolve its label + configured unit.
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;

View File

@@ -1,5 +1,4 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import useDebouncedFn from 'hooks/useDebouncedFunction';
interface ThresholdDraft<T> {
draft: T;
@@ -8,25 +7,17 @@ interface ThresholdDraft<T> {
setValue: (raw: string) => void;
}
const LIVE_PREVIEW_DEBOUNCE_MS = 150;
/**
* Local draft for a threshold row, shared by every variant. Snapshots the saved
* threshold on each entry into edit mode and exposes the numeric `value` setter all
* variants use. `onLiveChange` mirrors the draft into the spec as the user edits, so the
* panel preview updates live (without Save); the section reverts it on Discard.
* threshold on each entry into edit mode (so Discard simply drops the draft and the
* next edit starts clean) and exposes the numeric `value` setter all variants use.
*/
export function useThresholdDraft<T extends { value: number }>(
threshold: T,
isEditing: boolean,
onLiveChange?: (draft: T) => void,
): ThresholdDraft<T> {
const [draft, setDraft] = useState<T>(threshold);
const emitLiveChange = useDebouncedFn((next) => {
onLiveChange?.(next as T);
}, LIVE_PREVIEW_DEBOUNCE_MS);
useEffect(() => {
if (isEditing) {
setDraft(threshold);
@@ -34,20 +25,6 @@ export function useThresholdDraft<T extends { value: number }>(
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
}, [isEditing]);
useEffect(() => {
if (isEditing) {
emitLiveChange(draft);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- propagate on draft change only
}, [draft]);
useEffect(() => {
if (!isEditing) {
emitLiveChange.cancel();
}
return (): void => emitLiveChange.cancel();
}, [isEditing, emitLiveChange]);
const setValue = (raw: string): void => {
const next = Number(raw);
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));

View File

@@ -1,8 +1,6 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
TelemetrytypesSignalDTO,
type DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -97,141 +95,4 @@ describe('getSwitchedPluginSpec', () => {
expect(result.legend?.position).toBe('bottom');
});
describe('thresholds', () => {
it('does not carry thresholds when the new kind has no thresholds section', () => {
mockGetPanelDefinition.mockReturnValue({ sections: [{ kind: 'columns' }] });
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/ListPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toBeUndefined();
});
it('carries thresholds verbatim within the label variant (color/value/unit/label)', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/BarChartPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', unit: 'ms', label: 'warn' },
]);
});
it('remaps label thresholds into the comparison variant, defaulting operator + format', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'comparison' } }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/NumberPanel',
TelemetrytypesSignalDTO.logs,
);
// The label is dropped; operator/format are seeded so the threshold can match.
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.text,
},
]);
});
it('remaps comparison thresholds into the table variant, keeping operator/format and seeding a column', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'table' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TablePanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.below,
format: DashboardtypesThresholdFormatDTO.text,
columnName: '',
},
]);
});
it('drops the table-only columnName when remapping into the label variant', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: { variant: 'label' } }],
});
const old = specWith({
thresholds: [
{
value: 80,
color: '#F1575F',
operator: DashboardtypesComparisonOperatorDTO.above,
format: DashboardtypesThresholdFormatDTO.background,
columnName: 'p99',
},
],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([{ value: 80, color: '#F1575F' }]);
});
it('defaults the variant to label when the thresholds section omits controls', () => {
mockGetPanelDefinition.mockReturnValue({
sections: [{ kind: 'thresholds', controls: {} }],
});
const old = specWith({
thresholds: [{ value: 80, color: '#F1575F', label: 'warn' }],
});
const result = getSwitchedPluginSpec(
old,
'signoz/TimeSeriesPanel',
TelemetrytypesSignalDTO.logs,
);
expect(result.thresholds).toStrictEqual([
{ value: 80, color: '#F1575F', label: 'warn' },
]);
});
});
});

View File

@@ -1,18 +1,13 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
type TelemetrytypesSignalDTO,
type TelemetrytypesTelemetryFieldKeyDTO,
import type {
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
type AnyThreshold,
type PanelFormattingSlice,
type SectionConfig,
SectionKind,
type ThresholdVariant,
type PanelFormattingSlice,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import {
buildDefaultPluginSpec,
@@ -29,73 +24,13 @@ import { defaultColumnsForSignal } from './ListColumnsEditor/selectFields';
export interface SwitchedPluginSpec extends DefaultPluginSpec {
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
thresholds?: AnyThreshold[];
}
/** Every field any threshold variant can hold; switching reads across shapes to remap. */
interface AnyThresholdFields {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
format?: DashboardtypesThresholdFormatDTO;
columnName?: string;
label?: string;
}
/** The threshold variant a kind edits, or `undefined` when it has no Thresholds section. */
function getThresholdVariant(
sections: SectionConfig[],
): ThresholdVariant | undefined {
const section = sections.find(
(s): s is Extract<SectionConfig, { kind: SectionKind.Thresholds }> =>
s.kind === SectionKind.Thresholds,
);
return section ? (section.controls.variant ?? 'label') : undefined;
}
/**
* Remaps a threshold to the target kind's variant: keeps the shared core (color, value,
* unit) plus any cross-variant fields, and seeds the rest with the variant's defaults so
* the carried threshold stays functional (a comparison/table threshold needs an operator
* to match, a table threshold a column).
*/
function toThresholdVariant(
source: AnyThresholdFields,
variant: ThresholdVariant,
): AnyThreshold {
const core = {
color: source.color,
value: source.value,
...(source.unit !== undefined && { unit: source.unit }),
};
if (variant === 'comparison') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.text,
};
}
if (variant === 'table') {
return {
...core,
operator: source.operator ?? DashboardtypesComparisonOperatorDTO.above,
format: source.format ?? DashboardtypesThresholdFormatDTO.background,
columnName: source.columnName ?? '',
};
}
return {
...core,
...(source.label !== undefined && { label: source.label }),
};
}
/**
* Builds the plugin spec for a first-visit switch to `newKind`: the kind's declared
* section defaults (so the config pane opens populated, matching new-panel seeding) plus
* the cross-kind config worth keeping — unit + decimal precision, and thresholds when the
* new kind supports them (remapped to its variant). Switching into a List seeds the
* current signal's default columns so the columns control isn't empty.
* the only cross-kind config worth keeping — unit + decimal precision. Switching into a
* List seeds the current signal's default columns so the columns control isn't empty.
*
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
*/
@@ -131,19 +66,5 @@ export function getSwitchedPluginSpec(
}
}
const thresholdVariant = getThresholdVariant(sections);
if (thresholdVariant) {
const oldThresholds = (
oldSpec.plugin.spec as {
thresholds?: AnyThreshold[] | null;
}
).thresholds;
if (oldThresholds && oldThresholds.length > 0) {
result.thresholds = oldThresholds.map((threshold) =>
toThresholdVariant(threshold as AnyThresholdFields, thresholdVariant),
);
}
}
return result;
}

View File

@@ -1,103 +0,0 @@
import { renderHook } from '@testing-library/react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
jest.mock('hooks/useGetYAxisUnit', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
function mockMetricUnit(
yAxisUnit: string | undefined,
isLoading = false,
): void {
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
}
describe('useMetricYAxisUnit', () => {
beforeEach(() => jest.clearAllMocks());
it('seeds the unit from the metric on a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
});
it('does not seed when not a new panel', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the metric has no unit', () => {
mockMetricUnit(undefined);
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('does not seed when the unit already matches the metric', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
renderHook(() =>
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
);
expect(onSelectUnit).not.toHaveBeenCalled();
});
it('re-seeds when the resolved metric unit changes', () => {
mockMetricUnit('bytes');
const onSelectUnit = jest.fn();
const { rerender } = renderHook(
(props: { unit: string | undefined }) =>
useMetricYAxisUnit({
isNewPanel: true,
unit: props.unit,
onSelectUnit,
}),
{ initialProps: { unit: undefined as string | undefined } },
);
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
// The metric changes; the panel now holds the previously-seeded unit.
mockMetricUnit('ms');
rerender({ unit: 'bytes' });
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
});
it('returns the resolved metric unit and loading state', () => {
mockMetricUnit('bytes', true);
const { result } = renderHook(() =>
useMetricYAxisUnit({
isNewPanel: false,
unit: undefined,
onSelectUnit: jest.fn(),
}),
);
expect(result.current.metricUnit).toBe('bytes');
expect(result.current.isLoading).toBe(true);
});
});

View File

@@ -1,36 +1,40 @@
import { renderHook } from '@testing-library/react';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelEditorSave } from '../usePanelEditorSave';
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
let mockIsPatching = false;
jest.mock('../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): {
patchAsync: jest.Mock;
isPatching: boolean;
error: Error | null;
} => ({ patchAsync: mockPatchAsync, isPatching: mockIsPatching, error: null }),
}));
// The hook reads getQueryData only for the isNew branch; a stub client is enough here.
const mockInvalidateQueries = jest.fn();
jest.mock('react-query', () => ({
useQueryClient: (): { getQueryData: jest.Mock } => ({
getQueryData: jest.fn(),
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
invalidateQueries: mockInvalidateQueries,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
usePatchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
describe('usePanelEditorSave', () => {
const mutateAsync = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
jest.clearAllMocks();
mockIsPatching = false;
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: false,
error: null,
});
});
it('optimistically patches an add replacing the whole panel spec', async () => {
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
);
@@ -46,17 +50,28 @@ describe('usePanelEditorSave', () => {
await result.current.save(spec);
expect(mockPatchAsync).toHaveBeenCalledWith([
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: spec,
},
expect(mutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'dash-1' },
data: [
{
op: 'add',
path: '/spec/panels/panel-9/spec',
value: spec,
},
],
});
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
expect(mockInvalidateQueries).toHaveBeenCalledWith([
'/api/v2/dashboards/dash-1',
]);
});
it('surfaces the patch in-flight state as isSaving', () => {
mockIsPatching = true;
it('surfaces the mutation loading state as isSaving', () => {
mockUsePatch.mockReturnValue({
mutateAsync,
isLoading: true,
error: null,
});
const { result } = renderHook(() =>
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),

View File

@@ -1,36 +0,0 @@
import { useEffect } from 'react';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
interface UseMetricYAxisUnitArgs {
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
isNewPanel: boolean;
unit: string | undefined;
onSelectUnit: (unit: string) => void;
}
interface UseMetricYAxisUnitResult {
metricUnit: string | undefined;
isLoading: boolean;
}
/**
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
* from it (V1 parity); returns the unit for the selector's mismatch warning.
*/
export function useMetricYAxisUnit({
isNewPanel,
unit,
onSelectUnit,
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
useEffect(() => {
if (isNewPanel && metricUnit && metricUnit !== unit) {
onSelectUnit(metricUnit);
}
// Re-seed only when the resolved metric unit changes, not on every unit edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewPanel, metricUnit]);
return { metricUnit, isLoading };
}

View File

@@ -1,7 +1,10 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { v4 as uuid } from 'uuid';
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
import {
getGetDashboardV2QueryKey,
usePatchDashboardV2,
} from 'api/generated/services/dashboard';
import {
type DashboardtypesJSONPatchOperationDTO,
type DashboardtypesPanelSpecDTO,
@@ -10,7 +13,6 @@ import {
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { createPanelOps } from '../../patchOps';
interface UsePanelEditorSaveArgs {
@@ -41,14 +43,15 @@ export function usePanelEditorSave({
layoutIndex,
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
const queryClient = useQueryClient();
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
const save = useCallback(
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
let ops: DashboardtypesJSONPatchOperationDTO[];
if (isNew) {
// Resolve the target section against the freshest dashboard we have.
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const cached =
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
ops = createPanelOps({
@@ -67,11 +70,11 @@ export function usePanelEditorSave({
];
}
// Optimistic cache write + settle refetch (replaces the manual invalidate).
await patchAsync(ops);
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
await queryClient.invalidateQueries(dashboardQueryKey);
},
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
);
return { save, isSaving: isPatching, error };
return { save, isSaving: isLoading, error: (error as Error) ?? null };
}

View File

@@ -8,8 +8,6 @@ import {
import { toast } from '@signozhq/ui/sonner';
import {
type DashboardtypesPanelDTO,
type DashboardtypesPanelFormattingDTO,
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -32,7 +30,6 @@ import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
@@ -126,33 +123,6 @@ function PanelEditorContainer({
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
}
).formatting?.unit;
const seedFormattingUnit = useCallback(
(unit: string): void => {
const pluginSpec = spec.plugin.spec as {
formatting?: DashboardtypesPanelFormattingDTO;
};
setSpec({
...spec,
plugin: {
...spec.plugin,
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
},
} as DashboardtypesPanelSpecDTO);
},
[spec, setSpec],
);
const { metricUnit } = useMetricYAxisUnit({
isNewPanel: isNew,
unit: formattingUnit,
onSelectUnit: seedFormattingUnit,
});
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
@@ -281,7 +251,6 @@ function PanelEditorContainer({
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -1,130 +0,0 @@
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { preparePieData } from '../prepareData';
function tableWith(
columns: PanelTable['columns'],
rows: PanelTable['rows'],
overrides: Partial<PanelTable> = {},
): PanelTable {
return { queryName: 'A', legend: '', columns, rows, ...overrides };
}
const args = (tables: PanelTable[]): Parameters<typeof preparePieData>[0] => ({
tables,
isDarkMode: true,
});
describe('preparePieData', () => {
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 23399927, col2: 588691297 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['col1', 23399927],
['col2', 588691297],
]);
});
it('keeps one slice per group row for a single value column', () => {
const table = tableWith(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['adservice', 100],
['cartservice', 200],
]);
});
it('prefixes the group when multiple value columns are grouped', () => {
const table = tableWith(
[
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'prod · col1',
'prod · col2',
]);
});
it('falls back to legend/query name when a single value column has no group', () => {
const table = tableWith(
[{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' }],
[{ data: { A: 42 } }],
{ legend: 'requests' },
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['requests', 42],
]);
});
it('honours customColors over the generated palette', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 10, col2: 20 } }],
);
const slices = preparePieData({
tables: [table],
isDarkMode: true,
customColors: { col1: '#ff0000' },
});
expect(slices[0].color).toBe('#ff0000');
expect(slices[1].color).not.toBe('#ff0000');
});
it('drops non-positive and non-numeric values', () => {
const table = tableWith(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
],
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
});
it('returns no slices for empty tables', () => {
expect(preparePieData(args([]))).toStrictEqual([]);
});
});

View File

@@ -11,7 +11,11 @@ export interface PreparePieDataArgs {
isDarkMode: boolean;
}
/** One pie slice per (row × value column); column name labels slices when a query has several value columns. */
/**
* Turns the scalar tables of a V5 response into pie slices (one per group row):
* value column → value, group column(s) → label. Colours honour `customColors`
* then fall back to the deterministic palette; non-positive/non-numeric dropped.
*/
export function preparePieData({
tables,
customColors,
@@ -23,35 +27,26 @@ export function preparePieData({
const slices: PieSlice[] = [];
tables.forEach((table) => {
const valueColumns = table.columns.filter((column) => column.isValueColumn);
if (valueColumns.length === 0) {
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);
const hasMultipleValueColumns = valueColumns.length > 1;
table.rows.forEach((row) => {
const groupLabel = labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ');
valueColumns.forEach((column) => {
let label: string;
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
label = groupLabel || table.legend || table.queryName || '';
}
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({
label,
value: Number(row.data[column.id || column.name]),
color,
});
});
const value = Number(row.data[valueKey]);
const label =
labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ') ||
table.legend ||
table.queryName ||
'';
const color = customColors?.[label] ?? generateColor(label, colorMap);
slices.push({ label, value, color });
});
});

View File

@@ -108,9 +108,7 @@ function addSeries({
// `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 = chartAppearance?.spanGaps
? resolveSpanGaps(chartAppearance?.spanGaps)
: true;
const spanGaps = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
const lineStyle = chartAppearance?.lineStyle
? LINE_STYLE_MAP[chartAppearance.lineStyle]

View File

@@ -1,35 +1,22 @@
import { resolveSpanGaps } from '../resolvers';
describe('resolveSpanGaps', () => {
it('parses a duration string into seconds when thresholding', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '5s' })).toBe(5);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '10m' })).toBe(
600,
);
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '1h' })).toBe(
3600,
);
it('spans all gaps (true) when unset', () => {
expect(resolveSpanGaps(undefined)).toBe(true);
expect(resolveSpanGaps('')).toBe(true);
});
it('parses a duration string into seconds', () => {
expect(resolveSpanGaps('5s')).toBe(5);
expect(resolveSpanGaps('10m')).toBe(600);
expect(resolveSpanGaps('1h')).toBe(3600);
});
it('tolerates a bare seconds number (back-compat)', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '600' })).toBe(
600,
);
expect(resolveSpanGaps('600')).toBe(600);
});
it('falls back to true for unparseable input', () => {
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: 'abc' })).toBe(
true,
);
});
it('spans all gaps when fillOnlyBelow is explicitly false, ignoring any duration', () => {
expect(resolveSpanGaps({ fillOnlyBelow: false, fillLessThan: '5m' })).toBe(
true,
);
});
it('treats a duration with no fillOnlyBelow flag as a threshold (legacy panels)', () => {
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
expect(resolveSpanGaps('abc')).toBe(true);
});
});

View File

@@ -2,7 +2,6 @@ import { rangeUtil } from '@grafana/data';
import {
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
type DashboardtypesSpanGapsDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
@@ -40,14 +39,15 @@ export function resolveDecimalPrecision(
}
/**
* Resolves `spanGaps` to uPlot's value. `fillOnlyBelow: false` spans every gap regardless
* of `fillLessThan`; a duration with no flag still thresholds (panels predating the flag).
* `spec.chartAppearance.spanGaps.fillLessThan` is a duration string on the wire
* ("10m", "5s"). Empty/missing → span all gaps (default); otherwise forward the
* threshold in seconds so uPlot only bridges short runs of nulls. Tolerates a
* bare seconds number for back-compat.
*/
export function resolveSpanGaps(
spanGaps: DashboardtypesSpanGapsDTO,
fillLessThan: string | undefined,
): boolean | number {
const fillLessThan = spanGaps.fillLessThan;
if (spanGaps.fillOnlyBelow === false || !fillLessThan) {
if (!fillLessThan) {
return true;
}
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)

View File

@@ -1,15 +1,12 @@
import { renderHook } from '@testing-library/react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import type { DashboardSection } from '../../../../utils';
import { useClonePanel } from '../useClonePanel';
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
jest.mock('../../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
patchAsync: mockPatchAsync,
isPatching: false,
}),
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
}));
const mockToastPromise = jest.fn();
@@ -19,6 +16,8 @@ jest.mock('@signozhq/ui/sonner', () => ({
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
const sourcePanel = {
kind: 'Panel',
spec: {
@@ -46,7 +45,7 @@ function sections(): DashboardSection[] {
describe('useClonePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
});
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
@@ -54,7 +53,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
expect(mockPatchAsync).toHaveBeenCalledWith([
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
{
op: 'add',
path: '/spec/panels/cloned-id',
@@ -93,7 +92,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatchAsync.mock.calls[0][0];
const ops = mockPatch.mock.calls[0][1];
// Room in the last row (4 + 4 = 8 ≤ 12 cols) → sits to the right at y:0.
expect(ops[1].value).toMatchObject({ x: 4, y: 0, width: 4, height: 5 });
});
@@ -103,7 +102,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'p1', layoutIndex: 0 });
const ops = mockPatchAsync.mock.calls[0][0];
const ops = mockPatch.mock.calls[0][1];
expect(ops[0].value).toStrictEqual(sourcePanel);
expect(ops[0].value).not.toBe(sourcePanel);
});
@@ -113,7 +112,7 @@ describe('useClonePanel', () => {
await result.current({ panelId: 'missing', layoutIndex: 0 });
expect(mockPatchAsync).not.toHaveBeenCalled();
expect(mockPatch).not.toHaveBeenCalled();
expect(mockToastPromise).not.toHaveBeenCalled();
});
@@ -133,7 +132,7 @@ describe('useClonePanel', () => {
});
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
mockPatch.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
await expect(

View File

@@ -1,7 +1,6 @@
import { renderHook } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5VariableTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
@@ -19,55 +18,12 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
const mockToastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
toast: { error: (...args: unknown[]): void => mockToastError(...args) },
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: { minTime: 1_000_000, maxTime: 2_000_000 } }),
}));
const mockSubstituteVars = jest.fn();
jest.mock('api/generated/services/querier', () => ({
useReplaceVariables: (): { mutate: jest.Mock } => ({
mutate: mockSubstituteVars,
}),
}));
// Stub the builders so this asserts only the hook's orchestration.
// The V5→V1 query→URL translation is covered by buildCreateAlertUrl's own tests;
// stub it so this asserts only the hook's side effects (analytics + navigation).
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=sync',
buildAlertUrl: (): string => '/alerts/new?composite=substituted',
readPanelUnit: (): string | undefined => undefined,
buildCreateAlertUrl: (): string => '/alerts/new?composite=1',
}));
// Keep the real exports (getPanelQueryType reads them); stub only the builder.
const mockBuildQueryRangeRequest = jest.fn((_args?: unknown) => ({
request: 'payload',
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest',
() => ({
...jest.requireActual(
'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest',
),
buildQueryRangeRequest: (args: unknown): unknown =>
mockBuildQueryRangeRequest(args),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
...jest.requireActual(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
),
envelopesToQuery: (): unknown => ({ resolved: 'query' }),
}),
);
const mockLogEvent = logEvent as jest.Mock;
const panel = {
@@ -82,7 +38,17 @@ const panel = {
describe('useCreateAlertFromPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1', resolvedVariables: {} });
useDashboardStore.setState({ dashboardId: 'dash-1' });
});
it('opens the seeded alert builder in a new tab', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=1', {
newTab: true,
});
});
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
@@ -100,80 +66,4 @@ describe('useCreateAlertFromPanel', () => {
}),
);
});
describe('with no variable selections', () => {
it('seeds the alert synchronously without a substitute round-trip', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockSubstituteVars).not.toHaveBeenCalled();
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=sync', {
newTab: true,
});
});
});
describe('with variable selections', () => {
beforeEach(() => {
useDashboardStore.setState({
dashboardId: 'dash-1',
resolvedVariables: {
'dash-1': {
service: {
type: Querybuildertypesv5VariableTypeDTO.query,
value: 'checkout',
},
},
},
});
});
it('substitutes variables before seeding, then opens the resolved alert', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
// Round-trips the panel's queries + resolved variables.
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
expect.objectContaining({
queries: panel.spec.queries,
panelType: PANEL_TYPES.TIME_SERIES,
variables: { service: { type: 'query', value: 'checkout' } },
}),
);
expect(mockSubstituteVars).toHaveBeenCalledWith(
{ data: { request: 'payload' } },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
// Nothing opens until the round-trip resolves.
expect(mockSafeNavigate).not.toHaveBeenCalled();
const { onSuccess } = mockSubstituteVars.mock.calls[0][1];
onSuccess({ data: { compositeQuery: { queries: [{ type: 'builder' }] } } });
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/alerts/new?composite=substituted',
{ newTab: true },
);
});
it('notifies and does not navigate when substitution fails', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
const { onError } = mockSubstituteVars.mock.calls[0][1];
onError();
expect(mockToastError).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ description: expect.any(String) }),
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,7 +3,8 @@ import { toast } from '@signozhq/ui/sonner';
import { cloneDeep } from 'lodash-es';
import { v4 as uuid } from 'uuid';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import {
addPanelToSectionOps,
findFreeSlot,
@@ -31,7 +32,7 @@ export function useClonePanel({
sections,
}: Params): (args: ClonePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
return useCallback(
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
@@ -44,7 +45,8 @@ export function useClonePanel({
const newPanelId = uuid();
const { x, y } = findFreeSlot(section.items, source.width);
const clone = patchAsync(
const clone = patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId: newPanelId,
panel: cloneDeep(source.panel),
@@ -66,14 +68,15 @@ export function useClonePanel({
position: 'top-center',
});
// toast.promise owns the error UX; swallow here to avoid an unhandled
// rejection (the optimistic cache write + settle refetch handle state).
// Refetch only on success; toast.promise owns the error UX, so swallow
// the rejection to avoid an unhandled rejection.
try {
await clone;
refetch();
} catch {
// no-op
}
},
[sections, dashboardId, patchAsync],
[sections, dashboardId, refetch],
);
}

View File

@@ -1,32 +1,18 @@
import { useCallback } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
buildAlertUrl,
buildCreateAlertUrl,
readPanelUnit,
} from '../utils/buildCreateAlertUrl';
import { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
/**
* Callback that seeds the alert builder from a panel's query in a new tab (V1 parity
* with `useCreateAlerts`; panel supplied at call time so the callback stays stable).
* With variable selections, resolves them via `/substitute_vars` first; otherwise
* seeds synchronously (the round-trip would be a no-op).
* Returns a callback that opens the alert builder in a new tab, seeded from a
* panel's query, and logs the action — mirroring V1's `useCreateAlerts`
* ('dashboardView' caller). The panel is supplied at call time so the callback
* stays stable across panels (and the dashboard's react-query refetches).
*/
export function useCreateAlertFromPanel(): (
panel: DashboardtypesPanelDTO,
@@ -34,61 +20,18 @@ export function useCreateAlertFromPanel(): (
) => void {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { mutate: substituteVars } = useReplaceVariables();
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
void logEvent('Dashboard Detail: Panel action', {
action: 'createAlerts',
panelType,
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
});
if (Object.keys(variables).length === 0) {
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
return;
}
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
panelType,
startMs: minTime / 1e6,
endMs: maxTime / 1e6,
variables,
});
substituteVars(
{ data: request },
{
onSuccess: (response) => {
const query = envelopesToQuery(
response.data.compositeQuery?.queries ?? [],
panelType,
);
const url = buildAlertUrl(
query,
panelType,
readPanelUnit(panel.spec.plugin),
);
safeNavigate(url, { newTab: true });
},
onError: () => {
toast.error(SOMETHING_WENT_WRONG, {
description: 'Failed to create alert from panel',
});
},
},
);
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
},
[dashboardId, variables, minTime, maxTime, substituteVars, safeNavigate],
[dashboardId, safeNavigate],
);
}

View File

@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -25,7 +25,7 @@ export function useDeletePanel({
sections,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -40,14 +40,15 @@ export function useDeletePanel({
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchAsync([
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -27,7 +27,7 @@ export function useMovePanelToSection({
sections,
}: Params): (args: MovePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
@@ -60,7 +60,8 @@ export function useMovePanelToSection({
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchAsync(
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
@@ -68,10 +69,11 @@ export function useMovePanelToSection({
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -5,14 +5,11 @@ import type {
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
/** The panel's configured y-axis unit, for the kinds that carry one. */
export function readPanelUnit(
function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
): string | undefined {
switch (plugin.kind) {
@@ -27,17 +24,20 @@ export function readPanelUnit(
}
/**
* Assembles the `/alerts/new` URL from a ready V1 `Query`: the alert page reads it
* from `compositeQuery`, tagged with the panel type, entity version, and a
* `dashboards` source.
* Builds the `/alerts/new` URL that seeds the alert builder from a panel's query,
* mirroring V1's `useCreateAlerts`: the panel's V5 queries are translated to the
* V1 `Query` the alert page reads from `compositeQuery`, tagged with the panel
* type, entity version, and a `dashboards` source.
*
* Unlike V1 there is no `/substitute_vars` round-trip — V2 has no query-variable
* plumbing yet, so any dashboard-variable references travel through verbatim.
*/
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
): string {
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
if (unit) {
// eslint-disable-next-line no-param-reassign
query.unit = unit;
}
@@ -52,15 +52,3 @@ export function buildAlertUrl(
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
}
/**
* Seeds the alert builder from a panel's query — the no-variable path, so any
* dashboard-variable references travel through verbatim. When the dashboard has
* selections, `useCreateAlertFromPanel` runs a `/substitute_vars` round-trip first
* and assembles the URL from the resolved queries via {@link buildAlertUrl}.
*/
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
return buildAlertUrl(query, panelType, readPanelUnit(panel.spec.plugin));
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import {
addSectionOp,
newGridLayout,
@@ -15,9 +15,9 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the appended section to render, then scrolls it into view.
* Polls because the optimistic cache write commits to the DOM a frame or two after
* the patch call; bails after ~40 frames.
* Waits (via rAF) for the refetch to render the appended section, then scrolls
* it into view. Polls because `refetch` resolves before React commits the new
* section to the DOM; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
@@ -49,7 +49,7 @@ interface Result {
*/
export function useAddSection({ layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -66,7 +66,8 @@ export function useAddSection({ layouts }: Params): Result {
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchAsync([op]);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
@@ -74,7 +75,7 @@ export function useAddSection({ layouts }: Params): Result {
setIsSaving(false);
}
},
[layouts, dashboardId, patchAsync, showErrorModal],
[layouts, dashboardId, refetch, showErrorModal],
);
return { addSection, isSaving };

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { removePanelOp, removeSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -24,7 +24,7 @@ interface Result {
*/
export function useDeleteSection({ section }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -38,13 +38,14 @@ export function useDeleteSection({ section }: Params): Result {
ops.push(removeSectionOp(section.layoutIndex));
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [section, dashboardId, patchAsync, showErrorModal]);
}, [section, dashboardId, refetch, showErrorModal]);
return { deleteSection, isSaving };
}

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -26,7 +26,7 @@ interface Result {
*/
export function useFirstSectionMigration({ sections }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -49,14 +49,15 @@ export function useFirstSectionMigration({ sections }: Params): Result {
try {
setIsSaving(true);
await patchAsync(ops);
await patchDashboardV2({ id: dashboardId }, ops);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[sections, dashboardId, patchAsync, showErrorModal],
[sections, dashboardId, refetch, showErrorModal],
);
return { migrate, isSaving };

View File

@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import type { Layout } from 'react-grid-layout';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { GridItem } from '../../../utils';
@@ -65,7 +65,7 @@ function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean {
*/
export function usePersistLayout({ layoutIndex, items }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -80,14 +80,17 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
}
try {
setIsSaving(true);
await patchAsync([replaceSectionItemsOp(layoutIndex, nextItems)]);
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
},
[dashboardId, items, layoutIndex, patchAsync, showErrorModal],
[dashboardId, items, layoutIndex, refetch, showErrorModal],
);
return { handleLayoutChange, isSaving };

View File

@@ -1,9 +1,9 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { renameSectionOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
@@ -19,7 +19,7 @@ interface Result {
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
export function useRenameSection({ layoutIndex }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [isSaving, setIsSaving] = useState(false);
const { showErrorModal } = useErrorModal();
@@ -31,7 +31,10 @@ export function useRenameSection({ layoutIndex }: Params): Result {
}
try {
setIsSaving(true);
await patchAsync([renameSectionOp(layoutIndex, trimmed)]);
await patchDashboardV2({ id: dashboardId }, [
renameSectionOp(layoutIndex, trimmed),
]);
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
@@ -40,7 +43,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
setIsSaving(false);
}
},
[dashboardId, layoutIndex, patchAsync, showErrorModal],
[dashboardId, layoutIndex, refetch, showErrorModal],
);
return { rename, isSaving };

View File

@@ -9,11 +9,11 @@ import {
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { reorderLayoutsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
@@ -43,7 +43,7 @@ interface Result {
*/
export function useSectionDragReorder({ sections, layouts }: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { patchAsync } = useOptimisticPatch();
const refetch = useDashboardStore((s) => s.refetch);
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
const { showErrorModal } = useErrorModal();
@@ -99,13 +99,14 @@ export function useSectionDragReorder({ sections, layouts }: Params): Result {
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
try {
await patchAsync([reorderLayoutsOp(newLayouts)]);
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
refetch();
} catch (error) {
setLocalOrderIds(null); // revert optimistic order on failure
showErrorModal(error as APIError);
}
},
[orderedSections, layouts, dashboardId, patchAsync, showErrorModal],
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
);
const activeSection = useMemo(

View File

@@ -1,107 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useMutation, useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports -- the hook's own test mocks and asserts the underlying patchDashboardV2 call.
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { useOptimisticPatch } from '../useOptimisticPatch';
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
jest.mock('react-query', () => ({
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
jest.mock('api/generated/services/dashboard', () => ({
patchDashboardV2: jest.fn(),
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: jest.fn(
(selector: (s: { dashboardId: string }) => unknown) =>
selector({ dashboardId: 'dash-1' }),
),
}));
const queryClient = {
cancelQueries: jest.fn().mockResolvedValue(undefined),
getQueryData: jest.fn(),
setQueryData: jest.fn(),
invalidateQueries: jest.fn().mockResolvedValue(undefined),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let captured: { fn: (ops: any) => unknown; options: any };
function dashboardEnvelope(name: string): GetDashboardV2200 {
return {
data: { spec: { display: { name } } },
} as unknown as GetDashboardV2200;
}
const replaceNameOp = {
op: DashboardtypesPatchOpDTO.replace,
path: '/spec/display/name',
value: 'B',
};
beforeEach(() => {
jest.clearAllMocks();
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
(useMutation as jest.Mock).mockImplementation((fn, options) => {
captured = { fn, options };
return { mutateAsync: jest.fn(), isLoading: false };
});
renderHook(() => useOptimisticPatch());
});
describe('useOptimisticPatch', () => {
it('mutationFn sends the ops to patchDashboardV2 for the current dashboard', () => {
captured.fn([replaceNameOp]);
expect(patchDashboardV2).toHaveBeenCalledWith({ id: 'dash-1' }, [
replaceNameOp,
]);
});
it('onMutate cancels fetches, snapshots, and writes the patched dashboard to the cache', async () => {
const previous = dashboardEnvelope('A');
queryClient.getQueryData.mockReturnValue(previous);
const context = await captured.options.onMutate([replaceNameOp]);
expect(queryClient.cancelQueries).toHaveBeenCalledWith(QUERY_KEY);
// Optimistic write reflects the op immediately.
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, {
data: { spec: { display: { name: 'B' } } },
});
// Snapshot returned for rollback; original left untouched.
expect(context).toStrictEqual({ previous });
expect(previous.data).toStrictEqual({ spec: { display: { name: 'A' } } });
});
it('onMutate is a no-op write when there is no cached dashboard', async () => {
queryClient.getQueryData.mockReturnValue(undefined);
const context = await captured.options.onMutate([replaceNameOp]);
expect(queryClient.setQueryData).not.toHaveBeenCalled();
expect(context).toStrictEqual({ previous: undefined });
});
it('onError rolls the cache back to the snapshot', () => {
const previous = dashboardEnvelope('A');
captured.options.onError(new Error('boom'), [replaceNameOp], { previous });
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, previous);
});
it('onError without a snapshot does not touch the cache', () => {
captured.options.onError(new Error('boom'), [replaceNameOp], {});
expect(queryClient.setQueryData).not.toHaveBeenCalled();
});
it('onSettled invalidates the dashboard query to reconcile', () => {
captured.options.onSettled();
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(QUERY_KEY);
});
});

View File

@@ -1,73 +0,0 @@
import { useMutation, useQueryClient } from 'react-query';
import {
getGetDashboardV2QueryKey,
// eslint-disable-next-line no-restricted-imports -- this hook is the one sanctioned caller of patchDashboardV2; everything else goes through patchAsync.
patchDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import APIError from 'types/api/error';
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
import { useDashboardStore } from '../store/useDashboardStore';
/** Cached dashboard snapshot, kept for rollback on error. */
interface OptimisticPatchContext {
previous?: GetDashboardV2200;
}
export interface UseOptimisticPatch {
patchAsync: (ops: DashboardtypesJSONPatchOperationDTO[]) => Promise<unknown>;
isPatching: boolean;
error: Error | null;
}
/**
* Central optimistic mutation for V2 dashboard spec edits: writes the ops to the
* cached dashboard immediately, rolls back on error, reconciles on settle.
* `dashboardId` defaults to the edit-context store; the panel editor passes its own.
*/
export function useOptimisticPatch(
dashboardIdOverride?: string,
): UseOptimisticPatch {
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
const mutation = useMutation<
Awaited<ReturnType<typeof patchDashboardV2>>,
APIError,
DashboardtypesJSONPatchOperationDTO[],
OptimisticPatchContext
>((ops) => patchDashboardV2({ id: dashboardId }, ops), {
onMutate: async (ops) => {
await queryClient.cancelQueries(queryKey);
const previous = queryClient.getQueryData<GetDashboardV2200>(queryKey);
if (previous?.data) {
// Ops are rooted at the DTO's `/spec`, so patch `.data`, keep the envelope.
queryClient.setQueryData<GetDashboardV2200>(queryKey, {
...previous,
data: applyJsonPatch(previous.data, ops),
});
}
return { previous };
},
onError: (_error, _ops, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSettled: () => {
void queryClient.invalidateQueries(queryKey);
},
});
return {
patchAsync: mutation.mutateAsync,
isPatching: mutation.isLoading,
error: mutation.error ?? null,
};
}

View File

@@ -1,138 +0,0 @@
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { applyJsonPatch } from '../applyJsonPatch';
const { add, replace, remove, move, test: testOp } = DashboardtypesPatchOpDTO;
function op(
o: DashboardtypesPatchOpDTO,
path: string,
value?: unknown,
): DashboardtypesJSONPatchOperationDTO {
return { op: o, path, value };
}
// A trimmed dashboard-spec shape; the applier is structural, so this stands in
// for the full DTO.
function spec(): Record<string, unknown> {
return {
spec: {
display: { name: 'dash' },
panels: { p1: { spec: { display: { name: 'A' } } } },
layouts: [
{ spec: { display: { title: 'S1' }, items: [{ x: 0 }] } },
{ spec: { items: [] } },
],
variables: [{ name: 'env' }],
},
};
}
describe('applyJsonPatch', () => {
it('does not mutate the input document', () => {
const doc = spec();
const snapshot = JSON.stringify(doc);
applyJsonPatch(doc, [op(replace, '/spec/display/name', 'renamed')]);
expect(JSON.stringify(doc)).toBe(snapshot);
});
it('replaces a leaf string', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/layouts/0/spec/display/title', 'S1-renamed'),
]);
const layouts = (next.spec as any).layouts;
expect(layouts[0].spec.display.title).toBe('S1-renamed');
});
it('adds a new object member (panel by id)', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/panels/p2', { spec: { display: { name: 'B' } } }),
]);
expect((next.spec as any).panels.p2.spec.display.name).toBe('B');
// existing member untouched
expect((next.spec as any).panels.p1.spec.display.name).toBe('A');
});
it('appends to an array with the "-" token', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/-', { spec: { items: [] } }),
]);
expect((next.spec as any).layouts).toHaveLength(3);
});
it('appends an item into a nested section array', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/1/spec/items/-', { x: 5 }),
]);
expect((next.spec as any).layouts[1].spec.items).toStrictEqual([{ x: 5 }]);
});
it('replaces a whole array', () => {
const next = applyJsonPatch(spec(), [
op(replace, '/spec/variables', [{ name: 'region' }, { name: 'pod' }]),
]);
expect((next.spec as any).variables).toStrictEqual([
{ name: 'region' },
{ name: 'pod' },
]);
});
it('removes an array element by index (section)', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/layouts/0')]);
const layouts = (next.spec as any).layouts;
expect(layouts).toHaveLength(1);
expect(layouts[0].spec.items).toStrictEqual([]);
});
it('removes an object member (panel by id)', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/p1')]);
expect((next.spec as any).panels).toStrictEqual({});
});
it('adds a missing object parent for an add op (title untitled section)', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/layouts/1/spec/display', { title: 'S2' }),
]);
expect((next.spec as any).layouts[1].spec.display).toStrictEqual({
title: 'S2',
});
});
it('is lenient: remove on a missing path is a no-op', () => {
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/ghost')]);
expect((next.spec as any).panels.p1).toBeDefined();
});
it('is lenient: a path through a missing node is skipped', () => {
const next = applyJsonPatch(spec(), [op(replace, '/spec/nope/deep/leaf', 1)]);
expect(next).toStrictEqual(spec());
});
it('unescapes ~1 and ~0 in reference tokens', () => {
const doc = { spec: { m: { 'a/b': 1, 'c~d': 2 } } };
const next = applyJsonPatch(doc, [
op(replace, '/spec/m/a~1b', 9),
op(replace, '/spec/m/c~0d', 8),
]);
expect(next.spec.m).toStrictEqual({ 'a/b': 9, 'c~d': 8 });
});
it('applies multiple ops in order', () => {
const next = applyJsonPatch(spec(), [
op(add, '/spec/panels/p2', { spec: {} }),
op(remove, '/spec/panels/p1'),
op(replace, '/spec/display/name', 'z'),
]);
expect(Object.keys((next.spec as any).panels)).toStrictEqual(['p2']);
expect((next.spec as any).display.name).toBe('z');
});
it('treats move/copy/test as no-ops', () => {
const next = applyJsonPatch(spec(), [
op(move, '/spec/display/name'),
op(testOp, '/spec/display/name', 'dash'),
]);
expect(next).toStrictEqual(spec());
});
});

View File

@@ -1,146 +0,0 @@
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
/**
* Applies the RFC-6902 ops our `patchOps` builders emit to a document, so a
* dashboard edit can be reflected in the react-query cache optimistically before
* the server responds. Pure: deep-clones and returns a new document, never
* mutating the input.
*
* Deliberately lenient — mirrors the backend's apply (a `remove`/`replace` on a
* missing path is a no-op, `add` creates missing object parents) rather than
* throwing as strict RFC-6902 would. This is safe because the mutation always
* refetches on settle, so any mis-applied edge op self-corrects; the applier only
* needs to be right for the common case to kill the perceived lag.
*
* Scope: `add` / `replace` / `remove` (the only ops the builders produce).
* `move` / `copy` / `test` are never emitted, so they are treated as no-ops.
*/
export function applyJsonPatch<T>(
doc: T,
ops: DashboardtypesJSONPatchOperationDTO[],
): T {
const next = cloneDeep(doc);
ops.forEach((op) => applyOperation(next as unknown, op));
return next;
}
type JsonRecord = Record<string, unknown>;
function isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Unescape one JSON-Pointer reference token (RFC-6901): `~1`→`/`, `~0`→`~`. */
function unescapeToken(token: string): string {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}
/** Parse a JSON Pointer into its reference tokens (`""`/`"/"` → root, `[]`). */
function parsePointer(path: string): string[] {
if (!path || path === '/') {
return [];
}
return path.slice(1).split('/').map(unescapeToken);
}
/**
* Walks to the container that holds the pointer's last token. Returns `undefined`
* when the path can't be resolved (lenient skip). For `add`, missing intermediate
* object nodes are created (backend parity); array steps are never auto-created.
*/
function navigateToParent(
root: unknown,
tokens: string[],
createMissing: boolean,
): unknown {
let current: unknown = root;
for (let i = 0; i < tokens.length - 1; i += 1) {
const token = tokens[i];
if (isArray(current)) {
const index = token === '-' ? current.length : Number(token);
current = current[index];
} else if (isRecord(current)) {
if (current[token] === undefined && createMissing) {
current[token] = {};
}
current = current[token];
} else {
return undefined;
}
if (current === undefined || current === null) {
return undefined;
}
}
return current;
}
/** `add`: array-insert (`-` = append) or object-set. */
function addAt(parent: unknown, key: string, value: unknown): void {
if (isArray(parent)) {
const index = key === '-' ? parent.length : Number(key);
parent.splice(index, 0, value);
} else if (isRecord(parent)) {
parent[key] = value;
}
}
/** `replace`: overwrite an in-range array index or an object key. */
function replaceAt(parent: unknown, key: string, value: unknown): void {
if (isArray(parent)) {
const index = Number(key);
if (index >= 0 && index < parent.length) {
parent[index] = value;
}
} else if (isRecord(parent)) {
parent[key] = value;
}
}
/** `remove`: splice an in-range array index or delete an object key (lenient). */
function removeAt(parent: unknown, key: string): void {
if (isArray(parent)) {
const index = Number(key);
if (index >= 0 && index < parent.length) {
parent.splice(index, 1);
}
} else if (isRecord(parent)) {
delete parent[key];
}
}
function applyOperation(
root: unknown,
op: DashboardtypesJSONPatchOperationDTO,
): void {
const tokens = parsePointer(op.path);
// Whole-document ops would need to reassign the root reference — our builders
// never target root, so skip rather than complicate the contract.
if (tokens.length === 0) {
return;
}
const parent = navigateToParent(
root,
tokens,
op.op === DashboardtypesPatchOpDTO.add,
);
if (parent === undefined || parent === null) {
return;
}
const key = tokens[tokens.length - 1];
// move / copy / test are never emitted by our builders → no-op (reconciled by refetch).
if (op.op === DashboardtypesPatchOpDTO.add) {
addAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.replace) {
replaceAt(parent, key, op.value);
} else if (op.op === DashboardtypesPatchOpDTO.remove) {
removeAt(parent, key);
}
}

View File

@@ -1,13 +1,10 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
import { fromPerses, toPerses } from '../persesQueryAdapters';
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
function bareQuery(
@@ -61,26 +58,6 @@ describe('persesQueryAdapters', () => {
});
});
describe('envelopesToQuery', () => {
it('returns the metrics default for an empty envelope list', () => {
expect(envelopesToQuery([], PANEL_TYPES.TIME_SERIES)).toStrictEqual(
initialQueriesMap[DataSource.METRICS],
);
});
it('maps a promql envelope to a PromQL query', () => {
const envelopes: Querybuildertypesv5QueryEnvelopeDTO[] = [
{
type: 'promql',
spec: { name: 'A', query: 'up', disabled: false },
} as unknown as Querybuildertypesv5QueryEnvelopeDTO,
];
expect(envelopesToQuery(envelopes, PANEL_TYPES.TIME_SERIES).queryType).toBe(
EQueryType.PROM,
);
});
});
describe('toPerses', () => {
it('wraps the query in a single signoz/CompositeQuery keyed to the panel request type', () => {
const result = toPerses(

View File

@@ -74,14 +74,14 @@ export function deriveQueryType(
}
/**
* V5 query-envelope list → V1 `Query`, via `mapQueryDataFromApi`. An empty list opens
* on a fresh metrics builder query. Used by `fromPerses` and by the envelopes a
* `/substitute_vars` round-trip returns with dashboard variables resolved.
* Perses panel queries → V1 `Query` (to seed the query builder), via the V5 envelope
* list + `mapQueryDataFromApi`. An empty panel opens on a fresh metrics builder query.
*/
export function envelopesToQuery(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
export function fromPerses(
queries: DashboardtypesQueryDTO[],
panelType: PANEL_TYPES,
): Query {
const envelopes = toQueryEnvelopes(queries);
if (envelopes.length === 0) {
return initialQueriesMap[DataSource.METRICS];
}
@@ -99,17 +99,6 @@ export function envelopesToQuery(
return mapQueryDataFromApi(composite);
}
/**
* Perses panel queries → V1 `Query` (to seed the query builder), via the V5 envelope
* list + `mapQueryDataFromApi`. An empty panel opens on a fresh metrics builder query.
*/
export function fromPerses(
queries: DashboardtypesQueryDTO[],
panelType: PANEL_TYPES,
): Query {
return envelopesToQuery(toQueryEnvelopes(queries), panelType);
}
/**
* V1 `Query` → perses panel queries (to write the builder result back to the editor
* draft). Wrapped in a single `signoz/CompositeQuery` to satisfy the

View File

@@ -23,10 +23,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/ts/tsconfig.tsbuildinfo",
"paths": {
"*": [
"./src/*"
],
"@constants/*": [
"./src/container/OnboardingContainer/constants/*"
],
@@ -35,7 +34,32 @@
],
"test-mocks/*": [
"./__mocks__/*"
]
],
"api": ["./src/api"],
"AppRoutes": ["./src/AppRoutes"],
"ReactI18": ["./src/ReactI18"],
"store": ["./src/store"],
"styles.scss": ["./src/styles.scss"],
"api/*": ["./src/api/*"],
"AppRoutes/*": ["./src/AppRoutes/*"],
"assets/*": ["./src/assets/*"],
"components/*": ["./src/components/*"],
"constants/*": ["./src/constants/*"],
"container/*": ["./src/container/*"],
"hooks/*": ["./src/hooks/*"],
"lib/*": ["./src/lib/*"],
"mocks-server/*": ["./src/mocks-server/*"],
"modules/*": ["./src/modules/*"],
"pages/*": ["./src/pages/*"],
"parser/*": ["./src/parser/*"],
"periscope/*": ["./src/periscope/*"],
"providers/*": ["./src/providers/*"],
"schemas/*": ["./src/schemas/*"],
"store/*": ["./src/store/*"],
"__tests__/*": ["./src/__tests__/*"],
"tests/*": ["./src/tests/*"],
"types/*": ["./src/types/*"],
"utils/*": ["./src/utils/*"]
},
"plugins": [
{
@@ -52,18 +76,11 @@
],
"include": [
"./src",
"./src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"babel.config.cjs",
"./jest.config.ts",
"./__mocks__",
"./conf/default.conf",
"./public",
"./commitlint.config.ts",
"./vite.config.ts",
"./babel.config.cjs",
"./jest.config.ts",
"./jest.setup.ts",
"./tests/**.ts",
"./**/*.d.ts"
"./vite.config.ts",
"./commitlint.config.ts"
]
}

View File

@@ -1100,25 +1100,24 @@ func (m *module) fetchMetricsStatsWithSamples(
reducedSumSB.Where(reducedSumSB.Between("unix_milli", req.Start, req.End))
reducedSumSB.Where("NOT startsWith(metric_name, 'signoz')")
// separate query for reduced series counts
reducedTsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
reducedTsSB.Where(reducedTsSB.Between("unix_milli", start, end))
reducedTsSB.Where("NOT startsWith(metric_name, 'signoz')")
reducedTsSB.GroupBy("metric_name")
if filterWhereClause != nil {
reducedTsSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
// samples uses a separate cte with local table
if filterWhereClause == nil {
reducedTsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
reducedTsSB.Where(reducedTsSB.Between("unix_milli", start, end))
reducedTsSB.Where("NOT startsWith(metric_name, 'signoz')")
reducedTsSB.GroupBy("metric_name")
} else {
reducedFpSB := sqlbuilder.NewSelectBuilder()
reducedFpSB.Select("fingerprint")
reducedFpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
reducedFpSB.Select("metric_name", "fingerprint")
reducedFpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
reducedFpSB.Where(reducedFpSB.Between("unix_milli", start, end))
reducedFpSB.Where("NOT startsWith(metric_name, 'signoz')")
reducedFpSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
reducedFpSB.GroupBy("fingerprint")
reducedFpSB.GroupBy("metric_name", "fingerprint")
ctes = append(ctes, sqlbuilder.CTEQuery("__reduced_filtered_fingerprints").As(reducedFpSB))
reducedTsSB.From("__reduced_filtered_fingerprints")
reducedTsSB.GroupBy("metric_name")
reducedLastSB.Where("reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints)")
reducedSumSB.Where("reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints)")
}
@@ -1423,7 +1422,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
if reductionEnabled {
reducedFingerprintSB := sqlbuilder.NewSelectBuilder()
reducedFingerprintSB.Select("fingerprint")
reducedFingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
reducedFingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
reducedFingerprintSB.Where(reducedFingerprintSB.Between("unix_milli", start, end))
reducedFingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
reducedFingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))

View File

@@ -32,11 +32,11 @@ const (
testEndMillis int64 = 1700003600000 // +1h
statsNoFilterSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND metric_name IN (SELECT DISTINCT metric_name FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz')) GROUP BY metric_name), __reduced_time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name), __reduced_sample_counts AS (SELECT metric_name, sum(cnt) AS samples FROM ((SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name) UNION ALL (SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name)) AS reduced_samples GROUP BY metric_name) SELECT COALESCE(ts.metric_name, rts.metric_name, s.metric_name, rs.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __reduced_time_series_counts rts ON ts.metric_name = rts.metric_name FULL OUTER JOIN __sample_counts s ON COALESCE(ts.metric_name, rts.metric_name) = s.metric_name FULL OUTER JOIN __reduced_sample_counts rs ON COALESCE(ts.metric_name, rts.metric_name, s.metric_name) = rs.metric_name WHERE (COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) > 0) ORDER BY samples DESC, metric_name ASC LIMIT ? OFFSET ? SETTINGS join_use_nulls = 1"
statsOrderTimeseriesSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND metric_name IN (SELECT DISTINCT metric_name FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz')) GROUP BY metric_name), __reduced_time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name), __reduced_sample_counts AS (SELECT metric_name, sum(cnt) AS samples FROM ((SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name) UNION ALL (SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name)) AS reduced_samples GROUP BY metric_name) SELECT COALESCE(ts.metric_name, rts.metric_name, s.metric_name, rs.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __reduced_time_series_counts rts ON ts.metric_name = rts.metric_name FULL OUTER JOIN __sample_counts s ON COALESCE(ts.metric_name, rts.metric_name) = s.metric_name FULL OUTER JOIN __reduced_sample_counts rs ON COALESCE(ts.metric_name, rts.metric_name, s.metric_name) = rs.metric_name WHERE (COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) > 0) ORDER BY timeseries ASC, metric_name ASC LIMIT ? OFFSET ? SETTINGS join_use_nulls = 1"
statsWithFilterSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name), __reduced_filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY fingerprint), __reduced_time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name), __reduced_sample_counts AS (SELECT metric_name, sum(cnt) AS samples FROM ((SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name) UNION ALL (SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name)) AS reduced_samples GROUP BY metric_name) SELECT COALESCE(ts.metric_name, rts.metric_name, s.metric_name, rs.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __reduced_time_series_counts rts ON ts.metric_name = rts.metric_name FULL OUTER JOIN __sample_counts s ON COALESCE(ts.metric_name, rts.metric_name) = s.metric_name FULL OUTER JOIN __reduced_sample_counts rs ON COALESCE(ts.metric_name, rts.metric_name, s.metric_name) = rs.metric_name WHERE (COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) > 0) ORDER BY samples DESC, metric_name ASC LIMIT ? OFFSET ? SETTINGS join_use_nulls = 1"
statsWithFilterSQL = "WITH __time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name), __reduced_filtered_fingerprints AS (SELECT metric_name, fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name, fingerprint), __reduced_time_series_counts AS (SELECT metric_name, uniq(fingerprint) AS timeseries FROM __reduced_filtered_fingerprints GROUP BY metric_name), __reduced_sample_counts AS (SELECT metric_name, sum(cnt) AS samples FROM ((SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name) UNION ALL (SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name)) AS reduced_samples GROUP BY metric_name) SELECT COALESCE(ts.metric_name, rts.metric_name, s.metric_name, rs.metric_name) AS metric_name, COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) AS timeseries, COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) AS samples, COUNT(*) OVER() AS total FROM __time_series_counts ts FULL OUTER JOIN __reduced_time_series_counts rts ON ts.metric_name = rts.metric_name FULL OUTER JOIN __sample_counts s ON COALESCE(ts.metric_name, rts.metric_name) = s.metric_name FULL OUTER JOIN __reduced_sample_counts rs ON COALESCE(ts.metric_name, rts.metric_name, s.metric_name) = rs.metric_name WHERE (COALESCE(ts.timeseries, 0) + COALESCE(rts.timeseries, 0) > 0 OR COALESCE(s.samples, 0) + COALESCE(rs.samples, 0) > 0) ORDER BY samples DESC, metric_name ASC LIMIT ? OFFSET ? SETTINGS join_use_nulls = 1"
treemapTimeseriesNoFilterSQL = "WITH __total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ?), __metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name), __reduced_total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ?), __reduced_metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') GROUP BY metric_name) SELECT COALESCE(mt.metric_name, rmt.metric_name) AS metric_name, COALESCE(mt.total_value, 0) + COALESCE(rmt.total_value, 0) AS total_value, CASE WHEN (tts.total_time_series + rtts.total_time_series) = 0 THEN 0 ELSE ((COALESCE(mt.total_value, 0) + COALESCE(rmt.total_value, 0)) * 100.0 / (tts.total_time_series + rtts.total_time_series)) END AS percentage FROM __metric_totals mt FULL OUTER JOIN __reduced_metric_totals rmt ON mt.metric_name = rmt.metric_name JOIN __total_time_series tts ON 1=1 JOIN __reduced_total_time_series rtts ON 1=1 ORDER BY percentage DESC LIMIT ? SETTINGS join_use_nulls = 1"
treemapTimeseriesWithFilterSQL = "WITH __total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ?), __metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name), __reduced_total_time_series AS (SELECT uniq(fingerprint) AS total_time_series FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ?), __reduced_metric_totals AS (SELECT metric_name, uniq(fingerprint) AS total_value FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name) SELECT COALESCE(mt.metric_name, rmt.metric_name) AS metric_name, COALESCE(mt.total_value, 0) + COALESCE(rmt.total_value, 0) AS total_value, CASE WHEN (tts.total_time_series + rtts.total_time_series) = 0 THEN 0 ELSE ((COALESCE(mt.total_value, 0) + COALESCE(rmt.total_value, 0)) * 100.0 / (tts.total_time_series + rtts.total_time_series)) END AS percentage FROM __metric_totals mt FULL OUTER JOIN __reduced_metric_totals rmt ON mt.metric_name = rmt.metric_name JOIN __total_time_series tts ON 1=1 JOIN __reduced_total_time_series rtts ON 1=1 ORDER BY percentage DESC LIMIT ? SETTINGS join_use_nulls = 1"
treemapSamplesNoFilterSQL = "WITH __metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4 WHERE NOT startsWith(metric_name, 'signoz') AND unix_milli BETWEEN ? AND ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __reduced_metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4_reduced WHERE NOT startsWith(metric_name, 'signoz') AND unix_milli BETWEEN ? AND ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) GROUP BY metric_name), __reduced_sample_counts AS (SELECT metric_name, sum(cnt) AS samples FROM ((SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) GROUP BY metric_name) UNION ALL (SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) GROUP BY metric_name)) AS reduced_samples GROUP BY metric_name), __total_samples AS (SELECT count(*) AS total_samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ?), __reduced_total_samples AS (SELECT sum(cnt) AS total_samples FROM ((SELECT uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ?) UNION ALL (SELECT uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ?)) AS reduced_total), __all_candidates AS (SELECT DISTINCT metric_name FROM ((SELECT metric_name FROM __metric_candidates) UNION ALL (SELECT metric_name FROM __reduced_metric_candidates)) AS candidates) SELECT ac.metric_name, COALESCE(sc.samples, 0) + COALESCE(rsc.samples, 0) AS samples, CASE WHEN (ts.total_samples + rts.total_samples) = 0 THEN 0 ELSE ((COALESCE(sc.samples, 0) + COALESCE(rsc.samples, 0)) * 100.0 / (ts.total_samples + rts.total_samples)) END AS percentage FROM __all_candidates ac LEFT JOIN __sample_counts sc ON ac.metric_name = sc.metric_name LEFT JOIN __reduced_sample_counts rsc ON ac.metric_name = rsc.metric_name JOIN __total_samples ts ON 1=1 JOIN __reduced_total_samples rts ON 1=1 ORDER BY percentage DESC LIMIT ? SETTINGS join_use_nulls = 1"
treemapSamplesWithFilterSQL = "WITH __metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4 WHERE NOT startsWith(metric_name, 'signoz') AND unix_milli BETWEEN ? AND ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __reduced_metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4_reduced WHERE NOT startsWith(metric_name, 'signoz') AND unix_milli BETWEEN ? AND ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) GROUP BY fingerprint), __reduced_filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name), __reduced_sample_counts AS (SELECT metric_name, sum(cnt) AS samples FROM ((SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name) UNION ALL (SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name)) AS reduced_samples GROUP BY metric_name), __total_samples AS (SELECT count(*) AS total_samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ?), __reduced_total_samples AS (SELECT sum(cnt) AS total_samples FROM ((SELECT uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ?) UNION ALL (SELECT uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ?)) AS reduced_total), __all_candidates AS (SELECT DISTINCT metric_name FROM ((SELECT metric_name FROM __metric_candidates) UNION ALL (SELECT metric_name FROM __reduced_metric_candidates)) AS candidates) SELECT ac.metric_name, COALESCE(sc.samples, 0) + COALESCE(rsc.samples, 0) AS samples, CASE WHEN (ts.total_samples + rts.total_samples) = 0 THEN 0 ELSE ((COALESCE(sc.samples, 0) + COALESCE(rsc.samples, 0)) * 100.0 / (ts.total_samples + rts.total_samples)) END AS percentage FROM __all_candidates ac LEFT JOIN __sample_counts sc ON ac.metric_name = sc.metric_name LEFT JOIN __reduced_sample_counts rsc ON ac.metric_name = rsc.metric_name JOIN __total_samples ts ON 1=1 JOIN __reduced_total_samples rts ON 1=1 ORDER BY percentage DESC LIMIT ? SETTINGS join_use_nulls = 1"
treemapSamplesWithFilterSQL = "WITH __metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4 WHERE NOT startsWith(metric_name, 'signoz') AND unix_milli BETWEEN ? AND ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __reduced_metric_candidates AS (SELECT metric_name FROM signoz_metrics.distributed_time_series_v4_reduced WHERE NOT startsWith(metric_name, 'signoz') AND unix_milli BETWEEN ? AND ? AND JSONExtractString(labels, 'host.name') = ? GROUP BY metric_name ORDER BY uniq(fingerprint) DESC LIMIT ?), __filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) GROUP BY fingerprint), __reduced_filtered_fingerprints AS (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND JSONExtractString(labels, 'host.name') = ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) GROUP BY fingerprint), __sample_counts AS (SELECT metric_name, count(*) AS samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates) AND fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints) GROUP BY metric_name), __reduced_sample_counts AS (SELECT metric_name, sum(cnt) AS samples FROM ((SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name) UNION ALL (SELECT metric_name, uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ? AND metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates) AND reduced_fingerprint IN (SELECT fingerprint FROM __reduced_filtered_fingerprints) GROUP BY metric_name)) AS reduced_samples GROUP BY metric_name), __total_samples AS (SELECT count(*) AS total_samples FROM signoz_metrics.distributed_samples_v4 WHERE unix_milli BETWEEN ? AND ?), __reduced_total_samples AS (SELECT sum(cnt) AS total_samples FROM ((SELECT uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE unix_milli BETWEEN ? AND ?) UNION ALL (SELECT uniq(reduced_fingerprint, unix_milli) AS cnt FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE unix_milli BETWEEN ? AND ?)) AS reduced_total), __all_candidates AS (SELECT DISTINCT metric_name FROM ((SELECT metric_name FROM __metric_candidates) UNION ALL (SELECT metric_name FROM __reduced_metric_candidates)) AS candidates) SELECT ac.metric_name, COALESCE(sc.samples, 0) + COALESCE(rsc.samples, 0) AS samples, CASE WHEN (ts.total_samples + rts.total_samples) = 0 THEN 0 ELSE ((COALESCE(sc.samples, 0) + COALESCE(rsc.samples, 0)) * 100.0 / (ts.total_samples + rts.total_samples)) END AS percentage FROM __all_candidates ac LEFT JOIN __sample_counts sc ON ac.metric_name = sc.metric_name LEFT JOIN __reduced_sample_counts rsc ON ac.metric_name = rsc.metric_name JOIN __total_samples ts ON 1=1 JOIN __reduced_total_samples rts ON 1=1 ORDER BY percentage DESC LIMIT ? SETTINGS join_use_nulls = 1"
// Raw-only SQL produced when the metrics-reduction feature flag is OFF. These
// must stay byte-for-byte identical to the pre-reduction queries.
@@ -193,7 +193,7 @@ func TestGetStats(t *testing.T) {
}{
{name: "NoFilter_FastPathSQL", expectSQL: statsNoFilterSQL, argCount: 14, reductionEnabled: true},
{name: "WhitespaceFilter_FastPathSQL", opts: []statsOpt{withStatsFilter(" ")}, expectSQL: statsNoFilterSQL, argCount: 14, reductionEnabled: true},
{name: "WithFilter_FingerprintSQL", opts: []statsOpt{withStatsFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: statsWithFilterSQL, argCount: 20, reductionEnabled: true},
{name: "WithFilter_FingerprintSQL", opts: []statsOpt{withStatsFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: statsWithFilterSQL, argCount: 17, reductionEnabled: true},
{name: "OrderByTimeseriesAsc", opts: []statsOpt{withStatsOrderBy("timeseries", qbtypes.OrderDirectionAsc)}, expectSQL: statsOrderTimeseriesSQL, argCount: 14, reductionEnabled: true},
{name: "OrderByInvalid", opts: []statsOpt{withStatsOrderBy("nonsense", qbtypes.OrderDirectionAsc)}, noQuery: true, wantCode: errors.CodeInvalidInput},
{name: "QueryError", queryErr: assert.AnError, expectSQL: statsNoFilterSQL, argCount: 14, reductionEnabled: true, wantCode: errors.CodeInternal},

View File

@@ -26,7 +26,6 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind
Model(&tags).
Where("org_id = ?", orgID).
Where("kind = ?", kind).
OrderExpr("lower(key) ASC, lower(value) ASC").
Scan(ctx)
if err != nil {
return nil, err

View File

@@ -39,7 +39,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "gauge_sum_latest",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationLatest, metrictypes.SpaceAggregationSum),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, anyLast(last) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, argMax(value, unix_milli) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
@@ -47,7 +47,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "gauge_avg_avg",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
@@ -55,7 +55,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "gauge_min_min",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMin, metrictypes.SpaceAggregationMin),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`min`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(min) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, min(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`min`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
@@ -63,7 +63,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "gauge_max_max",
query: reducedQuery("test.metric", metrictypes.GaugeType, metrictypes.Unspecified, metrictypes.TimeAggregationMax, metrictypes.SpaceAggregationMax),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`max`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(value) AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`max`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},
@@ -71,7 +71,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "counter_sum_rate",
query: reducedQuery("test.metric.sum", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationRate, metrictypes.SpaceAggregationSum),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746999600000), uint64(1747172760000), "test.metric.sum", uint64(1746999600000), uint64(1747172760000), false},
},
},
@@ -79,7 +79,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "counter_avg_increase",
query: reducedQuery("test.metric", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationIncrease, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746999600000), uint64(1747172760000), "test.metric", uint64(1746999600000), uint64(1747172760000), false},
},
},
@@ -103,7 +103,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "histogram_p99",
query: reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(value) / 300 AS per_series_value FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum`, computed_at) AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts, `le`), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", false, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), false},
},
},
@@ -111,7 +111,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "summary_avg",
query: reducedQuery("test.metric", metrictypes.SummaryType, metrictypes.Unspecified, metrictypes.TimeAggregationAvg, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(sum) / sum(count) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, avg(value) AS per_series_value, avg(weight) AS per_series_weight FROM (SELECT reduced_fingerprint AS fingerprint, unix_milli, argMax(`sum_last`, computed_at) AS value, argMax(`count_series`, computed_at) AS weight FROM signoz_metrics.distributed_samples_v4_reduced_last_60s WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY reduced_fingerprint, unix_milli) AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.distributed_time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND __normalized = ? GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "unspecified", false, "test.metric", uint64(1746999900000), uint64(1747172760000), 0, "test.metric", uint64(1746999900000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000), false},
},
},

View File

@@ -296,7 +296,7 @@ func (b *MetricQueryStatementBuilder) buildReducedTimeSeriesCTE(
}
}
sb.From(fmt.Sprintf("%s.%s", DBName, TimeseriesV4ReducedLocalTableName))
sb.From(fmt.Sprintf("%s.%s", DBName, TimeseriesV4ReducedTableName))
sb.Select("fingerprint")
for _, g := range query.GroupBy {
col, err := b.fm.ColumnExpressionFor(ctx, start, end, &g.TelemetryFieldKey, keys)

View File

@@ -15,7 +15,7 @@ import (
const (
DashboardViewSchemaVersion = "v1"
MaxDashboardViewNameLen = 64
MaxDashboardViewNameLen = 32
)
var (

View File

@@ -1,29 +1,12 @@
package dashboardtypes
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
var ErrCodeDashboardListFilterInvalid = errors.MustNewCode("dashboard_list_filter_invalid")
// ReservedFilterKeys returns the reserved (column-level) DSL keys the list
// filter accepts, sorted alphabetically. The list API surfaces these so clients
// can distinguish reserved keywords from tag keys when building filters.
func ReservedFilterKeys() []DSLKey {
keys := make([]DSLKey, 0, len(ReservedOps))
for key := range ReservedOps {
keys = append(keys, key)
}
slices.SortFunc(keys, func(a, b DSLKey) int {
return strings.Compare(string(a), string(b))
})
return keys
}
// ReservedOps lists the operators each reserved (column-level) DSL key accepts.
// Any non-reserved key is treated as a tag key and uses TagKeyOps.
var ReservedOps = map[DSLKey]map[qbtypesv5.FilterOperator]struct{}{

View File

@@ -145,10 +145,9 @@ func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
}
type ListableDashboardV2 struct {
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
}
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
@@ -161,10 +160,9 @@ func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsBy
items[i] = newListedDashboardV2(v2)
}
return &ListableDashboardV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
}, nil
}
@@ -176,10 +174,9 @@ type listedDashboardForUserV2 struct {
}
type ListableDashboardForUserV2 struct {
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
}
// StorableDashboardWithPinInfo is the per-row shape Store.ListForUser returns: the dashboard
@@ -203,9 +200,8 @@ func NewListableDashboardForUserV2(rows []*StorableDashboardWithPinInfo, total i
}
}
return &ListableDashboardForUserV2{
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
ReservedKeywords: ReservedFilterKeys(),
Dashboards: items,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
}, nil
}

View File

@@ -4,12 +4,8 @@ import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
@@ -138,42 +134,14 @@ func (d *DashboardV2) ErrIfNotClonable() error {
}
func (d DashboardV2) ToPostableForCloning() PostableDashboardV2 {
spec := d.Spec
spec.Display.Name = nextCloneDisplayName(spec.Display.Name)
return PostableDashboardV2{
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
GenerateName: true,
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
Spec: spec,
Spec: d.Spec,
}
}
// nextCloneDisplayName appends " - Copy" to a clone's display name, bumping an
// existing " - Copy (n)" counter, then truncates the base to fit MaxDisplayNameLen.
func nextCloneDisplayName(name string) string {
cloneCopySuffix := regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
base, count := name, 0
if m := cloneCopySuffix.FindStringSubmatch(name); m != nil {
base = m[1]
count = 1 // bare " - Copy"
if m[2] != "" {
count, _ = strconv.Atoi(m[2])
}
}
suffix := " - Copy"
if count++; count > 1 {
suffix = fmt.Sprintf(" - Copy (%d)", count)
}
limit := max(MaxDisplayNameLen-utf8.RuneCountInString(suffix), 0)
if runes := []rune(base); len(runes) > limit {
base = strings.TrimRight(string(runes[:limit]), " ")
}
return base + suffix
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image,omitempty"`

View File

@@ -5,7 +5,6 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -22,7 +21,6 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
spec := DashboardSpec{
Display: Display{Name: "Test Dashboard"},
Panels: map[string]*Panel{
"p1": {
Kind: "Panel",
@@ -213,13 +211,7 @@ func TestDashboardV2ToPostableForCloning(t *testing.T) {
assert.True(t, postable.GenerateName, "internal name must be regenerated, not copied")
assert.Empty(t, postable.Name, "name must be empty so generateName can derive it")
assert.Equal(t, dashboard.DashboardV2MetadataBase, postable.DashboardV2MetadataBase, "schema version and image are carried over")
assert.Equal(t, "Test Dashboard - Copy", postable.Spec.Display.Name, "clone appends a Copy suffix to the display name")
// The rest of the spec is carried over unchanged.
expectedSpec := dashboard.Spec
expectedSpec.Display.Name = "Test Dashboard - Copy"
assert.Equal(t, expectedSpec, postable.Spec)
assert.Equal(t, "Test Dashboard", dashboard.Spec.Display.Name, "the source dashboard's display name is not mutated")
assert.Equal(t, dashboard.Spec, postable.Spec, "spec (incl. display name) is preserved verbatim")
require.Len(t, postable.Tags, len(dashboard.Tags))
for i, sourceTag := range dashboard.Tags {
@@ -228,83 +220,6 @@ func TestDashboardV2ToPostableForCloning(t *testing.T) {
}
}
// nextCloneDisplayName appends " - Copy", bumps an existing " - Copy (n)"
// counter, and truncates an over-long base back to MaxDisplayNameLen while
// keeping the suffix whole. The long cases are real-ish titles already at the
// limit so the truncated output is legible; their literal expectations assume
// the 128-character limit.
func TestNextCloneDisplayName(t *testing.T) {
require.Equal(t, 128, MaxDisplayNameLen, "the literal expectations below are sized for a 128-character limit")
testCases := []struct {
scenario string
input string
expected string
}{
{
scenario: "plain name gets a Copy suffix",
input: "My Dashboard",
expected: "My Dashboard - Copy",
},
{
scenario: "Copy suffix bumps to (2)",
input: "My Dashboard - Copy",
expected: "My Dashboard - Copy (2)",
},
{
scenario: "numbered suffix increments",
input: "My Dashboard - Copy (2)",
expected: "My Dashboard - Copy (3)",
},
{
scenario: "multi-digit suffix increments",
input: "svc - Copy (41)",
expected: "svc - Copy (42)",
},
{
scenario: "a name that merely contains Copy is not a suffix",
input: "Copy of things",
expected: "Copy of things - Copy",
},
{
scenario: "only the trailing Copy marker is stripped",
input: "Prod - Copy - Copy",
expected: "Prod - Copy - Copy (2)",
},
{
scenario: "empty name",
input: "",
expected: " - Copy",
},
{
scenario: "first copy at the limit truncates the base, keeps the suffix",
input: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availability Zone",
expected: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availabili - Copy",
},
{
scenario: "numbered copy at the limit increments then truncates",
input: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Service - Copy (9)",
expected: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Servic - Copy (10)",
},
{
scenario: "truncation counts runes, not bytes (é and — are one rune each)",
input: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Fleet",
expected: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices F - Copy",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
require.LessOrEqual(t, utf8.RuneCountInString(testCase.input), MaxDisplayNameLen, "a saved source name never exceeds the limit")
result := nextCloneDisplayName(testCase.input)
assert.Equal(t, testCase.expected, result)
assert.LessOrEqual(t, utf8.RuneCountInString(result), MaxDisplayNameLen, "a clone name must fit the limit")
})
}
}
func TestDashboardV2StorableRoundTrip(t *testing.T) {
orgID := valuer.GenerateUUID()
original := newTestDashboardV2(t, orgID, SourceIntegration)

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"slices"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -49,9 +48,6 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
// ══════════════════════════════════════════════
func (d *DashboardSpec) Validate() error {
if err := d.Display.Validate("dashboard", "spec.display.name"); err != nil {
return err
}
if err := d.validateVariables(); err != nil {
return err
}
@@ -66,23 +62,15 @@ func (d *DashboardSpec) validateVariables() error {
seen := make(map[string]struct{}, len(d.Variables))
for i, v := range d.Variables {
var name string
var err error
// Validated here, not by decodeSpec on decode, so variable errors surface from
// Validate() with clean messages (not buried under the decoder's "invalid
// dashboard spec" wrap) and also run for programmatically built specs (cloning).
path := fmt.Sprintf("spec.variables[%d]", i)
switch s := v.Spec.(type) {
case *ListVariableSpec:
name, err = s.Name, s.validate(path)
name = s.Name
case *TextVariableSpec:
name, err = s.Name, s.validate(path)
name = s.Name
default:
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.variables[%d].spec: unexpected variable spec type %T", i, v.Spec)
}
if err != nil {
return err
}
if _, dup := seen[name]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables[%d]: duplicate variable name %q", i, name)
}
@@ -100,9 +88,6 @@ func (d *DashboardSpec) validatePanels() error {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
if err := panel.Spec.Display.Validate("panel", path+".spec.display.name"); err != nil {
return err
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
@@ -153,38 +138,14 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
return nil
}
const maxLayoutsPerDashboard = 500
// validateLayouts validates the dashboard's layouts: bounded section count,
// per-item geometry, resolvable panel references, and no panel placed twice.
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
// runs here so its errors can name the layout by index.
// validateLayouts rejects grid items referencing a panel that doesn't exist.
func (d *DashboardSpec) validateLayouts() error {
if len(d.Layouts) > maxLayoutsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
}
// Could enforce this but skipping for now: panels in no grid item (orphans)
// are allowed.
// The frontend keys each grid item by its panel id, so placing one panel in
// two grid items collides; reject duplicate references dashboard-wide. Maps
// each referenced panel key to the path of the item that first placed it.
referencedPanels := make(map[string]string, len(d.Panels))
for li, layout := range d.Layouts {
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
if !ok {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if grid.Display != nil {
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
}
}
if err := validateGridLayoutGeometry(grid, li); err != nil {
return err
}
for ii, item := range grid.Items {
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
if item.Content == nil {
@@ -197,10 +158,6 @@ func (d *DashboardSpec) validateLayouts() error {
if _, ok := d.Panels[key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
}
if firstPath, dup := referencedPanels[key]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: panel %q is already placed by %s", path, key, firstPath)
}
referencedPanels[key] = path
}
}
return nil

View File

@@ -299,22 +299,19 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout y coordinate", func(t *testing.T) {
// p2 fills the right half of row 0, so p1 can only move to a fresh row
// without tripping overlap validation.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/y", "value": 6}]`).Apply(base)
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at y=0, now lives at y=6.
assert.Contains(t, raw, `"x":0,"y":6,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
// p2 sits at x=6, so p1 (at x=0) can only shrink; widening it would overlap.
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 3}]`).Apply(base)
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":3`)
assert.Contains(t, raw, `"width":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
@@ -324,12 +321,11 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
})
t.Run("append layout item", func(t *testing.T) {
// Appending needs a not-yet-placed panel, so add one in the same patch;
// re-placing p1 or p2 would be a duplicate reference.
out, err := decode(t, `[
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
out, err := decode(t, `[{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)

View File

@@ -2,14 +2,12 @@ package dashboardtypes
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
@@ -27,19 +25,19 @@ func TestValidateBigExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
assert.NoError(t, err, "expected valid dashboard")
require.NoError(t, err, "expected valid dashboard")
}
func TestValidateDashboardWithSections(t *testing.T) {
data, err := os.ReadFile("testdata/perses_with_sections.json")
require.NoError(t, err, "reading example file")
_, err = unmarshalDashboard(data)
assert.NoError(t, err, "expected valid dashboard")
require.NoError(t, err, "expected valid dashboard")
}
func TestInvalidateNotAJSON(t *testing.T) {
_, err := unmarshalDashboard([]byte("not json"))
assert.Error(t, err, "expected error for invalid JSON")
require.Error(t, err, "expected error for invalid JSON")
}
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
@@ -62,11 +60,11 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown panel plugin kind",
require.Contains(t, err.Error(), "unknown panel plugin kind",
"outer wrap should not smother the inner UnmarshalJSON message")
assert.Contains(t, err.Error(), `"NonExistentPanel"`,
require.Contains(t, err.Error(), `"NonExistentPanel"`,
"the offending value should still appear in the error")
assert.Contains(t, err.Error(), "allowed values:",
require.Contains(t, err.Error(), "allowed values:",
"the allowed-values hint should still appear in the error")
assert.True(t, errors.Ast(err, errors.TypeInvalidInput),
@@ -79,7 +77,7 @@ func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
require.NoError(t, err, "expected valid")
}
func TestValidateOnlyVariables(t *testing.T) {
@@ -111,7 +109,7 @@ func TestValidateOnlyVariables(t *testing.T) {
"layouts": []
}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
require.NoError(t, err, "expected valid")
}
func TestInvalidateDuplicateVariableNames(t *testing.T) {
@@ -138,7 +136,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for duplicate variable name")
assert.Contains(t, err.Error(), `duplicate variable name "env"`)
require.Contains(t, err.Error(), `duplicate variable name "env"`)
}
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
@@ -165,19 +163,19 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.Error(t, err, "expected error for invalid variable name %q", name)
assert.Contains(t, err.Error(), "is not a correct name")
require.Contains(t, err.Error(), "is not a correct name")
})
}
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
assert.NoError(t, err, "expected valid variable name %q", name)
require.NoError(t, err, "expected valid variable name %q", name)
})
}
t.Run("digits only", func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName("123"))
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot contain only digits")
require.Contains(t, err.Error(), "cannot contain only digits")
})
}
@@ -201,7 +199,7 @@ func TestInvalidatePanelKey(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel key")
assert.Contains(t, err.Error(), "is not a correct name")
require.Contains(t, err.Error(), "is not a correct name")
}
func TestInvalidateListVariableCrossFields(t *testing.T) {
@@ -227,30 +225,30 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
require.Error(t, err)
assert.Contains(t, err.Error(), "customAllValue cannot be set")
require.Contains(t, err.Error(), "customAllValue cannot be set")
})
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowMultiple")
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowMultiple")
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("valid sort is accepted", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
assert.NoError(t, err)
require.NoError(t, err)
})
t.Run("unknown sort is rejected", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown sort")
require.Contains(t, err.Error(), "unknown sort")
})
}
@@ -277,7 +275,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for empty variable name")
assert.Contains(t, err.Error(), "name cannot be empty")
require.Contains(t, err.Error(), "name cannot be empty")
})
}
}
@@ -416,7 +414,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -437,7 +435,7 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel plugin kind")
assert.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
func TestInvalidateLayoutPanelReferences(t *testing.T) {
@@ -490,11 +488,11 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard(tt.data)
if tt.wantContain == "" {
assert.NoError(t, err)
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain)
})
}
}
@@ -572,7 +570,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error for unknown field")
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -651,7 +649,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected validation error")
if tt.wantContain != "" {
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
}
})
}
@@ -876,7 +874,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -909,7 +907,7 @@ func TestThresholdLabelOptional(t *testing.T) {
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Len(t, spec.Thresholds, 1)
assert.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
})
}
}
@@ -926,7 +924,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel-without-queries to be rejected")
assert.Contains(t, err.Error(), "panel must have one query")
require.Contains(t, err.Error(), "panel must have one query")
}
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
@@ -944,7 +942,7 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel with explicit empty queries array to be rejected")
assert.Contains(t, err.Error(), "panel must have one query")
require.Contains(t, err.Error(), "panel must have one query")
}
// Rendering multiple data sources in a single panel is supported via
@@ -967,7 +965,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected panel with two top-level queries to be rejected")
assert.Contains(t, err.Error(), "panel must have one query")
require.Contains(t, err.Error(), "panel must have one query")
}
func TestValidateRequiredFields(t *testing.T) {
@@ -1055,7 +1053,7 @@ func TestValidateRequiredFields(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
assert.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
@@ -1083,14 +1081,14 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
require.IsType(t, &TimeSeriesPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
assert.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
assert.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
assert.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
assert.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
// and verify the output contains the default values.
@@ -1133,8 +1131,8 @@ func TestNumberPanelDefaults(t *testing.T) {
spec := d.Panels["p1"].Spec.Plugin.Spec.(*NumberPanelSpec)
require.Len(t, spec.Thresholds, 1, "expected 1 threshold")
assert.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
assert.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
require.Equal(t, "above", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default above")
require.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
// Marshal back and verify defaults in JSON output.
output, err := json.Marshal(d)
@@ -1165,7 +1163,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
require.NoError(t, err, "map → JSON (read-back shape)")
var roundtripped DashboardSpec
assert.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
}
// TestStorageRoundTrip simulates the future DB store/load cycle:
@@ -1331,9 +1329,9 @@ func TestGenerateDashboardName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.scenario, func(t *testing.T) {
got := generateDashboardName(tt.input)
assert.NotEmpty(t, got)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
require.NotEmpty(t, got)
require.LessOrEqual(t, len(got), 63)
require.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
if tt.wantPrefix == "" {
assert.Len(t, got, dashboardNameSuffixLen, "expected the bare random suffix")
@@ -1348,8 +1346,8 @@ func TestGenerateDashboardName(t *testing.T) {
t.Run("prefix is truncated to leave room for the suffix", func(t *testing.T) {
input := strings.Repeat("a", 100)
got := generateDashboardName(input)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got))
require.LessOrEqual(t, len(got), 63)
require.Empty(t, validation.IsDNS1123Label(got))
assert.Equal(t, len(got), 63, "expected the result to be padded to the max DNS-1123 length")
})
@@ -1437,194 +1435,10 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
_, err := unmarshalDashboard(tc.data)
if tc.wantErr {
assert.Error(t, err)
require.Error(t, err)
} else {
assert.NoError(t, err)
require.NoError(t, err)
}
})
}
}
func TestValidateGridGeometry(t *testing.T) {
tests := []struct {
scenario string
items []dashboard.GridItem
expectErrContain string
}{
{
scenario: "valid side-by-side items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 6, Y: 0, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "valid full-width item",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 12, Height: 6}},
expectErrContain: "",
},
{
scenario: "stacked items do not overlap",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 0, Y: 6, Width: 6, Height: 6}},
expectErrContain: "",
},
{
scenario: "zero width",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 0, Height: 6}},
expectErrContain: "width must be at least 1",
},
{
scenario: "zero height",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 0}},
expectErrContain: "height must be at least 1",
},
{
scenario: "negative x",
items: []dashboard.GridItem{{X: -1, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x must not be negative",
},
{
scenario: "negative y",
items: []dashboard.GridItem{{X: 0, Y: -1, Width: 6, Height: 6}},
expectErrContain: "y must not be negative",
},
{
scenario: "width wider than grid",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 13, Height: 6}},
expectErrContain: "width (13) exceeds grid width 12",
},
{
scenario: "x at grid width",
items: []dashboard.GridItem{{X: 12, Y: 0, Width: 1, Height: 6}},
expectErrContain: "x (12) must be less than grid width 12",
},
{
scenario: "x plus width overflows grid",
items: []dashboard.GridItem{{X: 8, Y: 0, Width: 6, Height: 6}},
expectErrContain: "x (8) + width (6) exceeds grid width 12",
},
{
scenario: "overlapping items",
items: []dashboard.GridItem{{X: 0, Y: 0, Width: 6, Height: 6}, {X: 3, Y: 3, Width: 6, Height: 6}},
expectErrContain: "items[0] and items[1] overlap",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: test.items}, 0)
if test.expectErrContain == "" {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectErrContain)
})
}
}
func TestValidateGridItemLimit(t *testing.T) {
err := validateGridLayoutGeometry(&dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, maxItemsPerGridLayout+1)}, 0)
require.Error(t, err)
assert.Contains(t, err.Error(), "maximum is")
}
// Both panel refs are valid, so this errors only if geometry validation runs on
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "overlap")
}
// The frontend keys each grid item by its panel id, so the same panel placed by
// two grid items crashes the section; the backend rejects it dashboard-wide. The
// two items are side by side so they clear the overlap check first.
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
]}}]
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err)
assert.Contains(t, err.Error(), "already placed")
// Both offending grid items are named.
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[0].content")
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}
// Every display name — dashboard, panel, variable — and the grid layout title is
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
// the message reads "<json path>: <field> name must be at most ...", pairing the
// locatable path (like the other spec errors) with a human field label.
func TestInvalidateDisplayNameTooLong(t *testing.T) {
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
testCases := []struct {
scenario string
dashboardJSON string
expectedPath string
expectedLabel string
}{
{
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
require.Error(t, err)
// Message is "<path>: <label> name must be at most N characters, got M".
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg
assert.Equal(t, want, errors.AsJSON(err).Message)
})
}
}
// A display name at exactly the limit is accepted.
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
atLimit := strings.Repeat("x", MaxDisplayNameLen)
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "layouts": []}`))
assert.NoError(t, err)
}

View File

@@ -5,7 +5,6 @@ import (
"maps"
"slices"
"strconv"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -16,22 +15,11 @@ import (
"github.com/swaggest/jsonschema-go"
)
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
// and variable display names, plus the grid layout title.
const MaxDisplayNameLen = 128
type Display struct {
Name string `json:"name" required:"true"`
Description string `json:"description,omitempty"`
}
func (d Display) Validate(label, path string) error {
if n := utf8.RuneCountInString(d.Name); n > MaxDisplayNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: %s name must be at most %d characters, got %d", path, label, MaxDisplayNameLen, n)
}
return nil
}
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
@@ -200,25 +188,19 @@ func (VariableDefaultValue) PrepareJSONSchema(s *jsonschema.Schema) error {
}
// validate mirrors perses ListVariableSpec validation (plus the digits-only name
// check perses only applies to text variables). path is the JSON path to this
// variable (e.g. "spec.variables[0]") and prefixes each message. Taking a param
// keeps it out of decodeSpec's validate() hook, so errors surface from Validate()
// with clean messages and also run for programmatically built specs (cloning).
func (s *ListVariableSpec) validate(path string) error {
if err := s.Display.Validate("variable", path+".spec.display.name"); err != nil {
// check perses only applies to text variables); run by decodeSpec on unmarshal.
func (s *ListVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
return err
}
if err := common.ValidateID(s.Name); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: variable name cannot contain only digits", path)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
}
if s.CustomAllValue != "" && !s.AllowAllValue {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: customAllValue cannot be set if allowAllValue is not set to true", path)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "customAllValue cannot be set if allowAllValue is not set to true")
}
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: defaultValue cannot be a list if allowMultiple is not set to true", path)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "defaultValue cannot be a list if allowMultiple is not set to true")
}
return nil
}
@@ -282,21 +264,16 @@ type TextVariableSpec struct {
Name string `json:"name" required:"true" minLength:"1"`
}
// validate mirrors perses TextVariableSpec validation. path is the JSON path to
// this variable (e.g. "spec.variables[0]") and prefixes each message. See
// ListVariableSpec.validate for why it takes a param.
func (s *TextVariableSpec) validate(path string) error {
if err := s.Display.Validate("variable", path+".spec.display.name"); err != nil {
// validate mirrors perses TextVariableSpec validation; run by decodeSpec on unmarshal.
func (s *TextVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
return err
}
if err := common.ValidateID(s.Name); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: variable name cannot contain only digits", path)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
}
if s.Value == "" && s.Constant {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: value for a constant text variable cannot be empty", path)
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "value for a constant text variable cannot be empty")
}
return nil
}
@@ -345,55 +322,6 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
return nil
}
const (
gridColumnCount = 12
maxItemsPerGridLayout = 100
)
// validateGridLayoutGeometry checks a single grid layout's item geometry (size,
// position, and intra-section overlap), which Perses does not. It reads only the
// layout's own items; layoutIndex is supplied by the caller (validateLayouts)
// solely to name the layout in error paths.
func validateGridLayoutGeometry(spec *dashboard.GridLayoutSpec, layoutIndex int) error {
if len(spec.Items) > maxItemsPerGridLayout {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items: has %d items; maximum is %d", layoutIndex, len(spec.Items), maxItemsPerGridLayout)
}
for i, item := range spec.Items {
// The width/x bounds keep x+width small enough not to overflow.
switch {
case item.Width < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width must be at least 1, got %d", layoutIndex, i, item.Width)
case item.Height < 1:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: height must be at least 1, got %d", layoutIndex, i, item.Height)
case item.X < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x must not be negative, got %d", layoutIndex, i, item.X)
case item.Y < 0:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: y must not be negative, got %d", layoutIndex, i, item.Y)
case item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: width (%d) exceeds grid width %d", layoutIndex, i, item.Width, gridColumnCount)
case item.X >= gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) must be less than grid width %d", layoutIndex, i, item.X, gridColumnCount)
case item.X+item.Width > gridColumnCount:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d]: x (%d) + width (%d) exceeds grid width %d", layoutIndex, i, item.X, item.Width, gridColumnCount)
}
// Could cap y/height but skipping for now: the grid grows vertically
// without limit (frontend autoSize), so "too big" has no natural bound.
}
// Two items overlap iff their rectangles intersect on both axes.
overlap := func(a, b dashboard.GridItem) bool {
return a.X < b.X+b.Width && b.X < a.X+a.Width &&
a.Y < b.Y+b.Height && b.Y < a.Y+a.Height
}
for i := 0; i < len(spec.Items); i++ {
for j := i + 1; j < len(spec.Items); j++ {
if overlap(spec.Items[i], spec.Items[j]) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.items[%d] and items[%d] overlap", layoutIndex, i, j)
}
}
}
return nil
}
func (Layout) JSONSchemaOneOf() []any {
return []any{
LayoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},

View File

@@ -6,7 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
type Source struct {
@@ -23,23 +22,6 @@ func (Source) Enum() []any {
return []any{SourceUser, SourceSystem, SourceIntegration}
}
// JSONSchema exposes Source as a string enum. Without this the reflector sees the
// unexported valuer.String field and emits `type: object`. The enum values are
// derived from Enum() so the list of sources lives in exactly one place.
func (Source) JSONSchema() (jsonschema.Schema, error) {
sources := Source{}.Enum()
enum := make([]any, 0, len(sources))
for _, source := range sources {
enum = append(enum, source.(Source).StringValue())
}
schema := jsonschema.Schema{}
schema.WithType(jsonschema.String.Type())
schema.WithEnum(enum...)
return schema, nil
}
func (s Source) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}

View File

@@ -173,152 +173,6 @@ def test_create_rejects_too_many_tags(
assert response.json()["error"]["code"] == "dashboard_invalid_input"
def test_create_rejects_long_display_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Display names are bounded at 128 characters; one over must be rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "long-display-name",
"spec": {"display": {"name": "x" * 129}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert (
"spec.display.name: dashboard name must be at most 128 characters"
in response.json()["error"]["message"]
)
def test_create_rejects_invalid_grid_layout(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def panel(name: str) -> dict:
return {
"kind": "Panel",
"spec": {
"display": {"name": name},
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
},
}
},
}
],
},
}
# Two grid items reference valid, distinct panels but share cells, so the
# overlap is the only violation.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-overlap",
"spec": {
"display": {"name": "Rejects Overlap"},
"panels": {"p1": panel("P1"), "p2": panel("P2")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "overlap" in response.json()["error"]["message"]
# One panel placed by two grid items (side by side, so they clear the overlap
# check first). The frontend keys grid items by panel id, so this is rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-multiref",
"spec": {
"display": {"name": "Rejects Multiref"},
"panels": {"p1": panel("P1")},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
]
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "already placed" in response.json()["error"]["message"]
# More grid items than allowed. The item-count check runs before the
# panel-ref check, so content-less items suffice here.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-too-many-items",
"spec": {
"display": {"name": "Rejects Too Many"},
"layouts": [
{
"kind": "Grid",
"spec": {"items": [{"x": 0, "y": 0, "width": 1, "height": 1} for _ in range(101)]},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "maximum" in response.json()["error"]["message"]
@pytest.mark.parametrize(
"params",
[
@@ -593,28 +447,6 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Epsilon Metrics",
"Zeta Overview",
}
# top-level tags = org-wide distinct tag set, sorted case-insensitively
# by (key, value). Asserting the exact list (not a set) locks in the sort.
assert body["data"]["tags"] == [
{"key": "env", "value": "dev"},
{"key": "env", "value": "prod"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "metrics"},
{"key": "team", "value": "pulse"},
{"key": "team", "value": "storage"},
{"key": "tier", "value": "critical"},
]
# reserved keywords = the filterable column-level DSL keys, sorted
# alphabetically. Static (independent of the dashboards), so this is the
# full expected set.
assert body["data"]["reservedKeywords"] == [
"created_at",
"created_by",
"description",
"locked",
"name",
"updated_at",
]
# ── stage 4: filter DSL ──────────────────────────────────────────────────
cases = [
@@ -915,7 +747,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
}
# ── stage 11: clone suffixes the display name and mints a new, retrievable one ─
# ── stage 11: clone keeps the display name but mints a new, retrievable one ─
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}/clone"),
headers={"Authorization": f"Bearer {token}"},
@@ -925,7 +757,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
clone = response.json()["data"]
assert clone["id"] != ids["lc-alpha"]
assert clone["name"] != "lc-alpha" # internal name is regenerated
assert clone["spec"]["display"]["name"] == "Alpha Overview - Copy" # Copy suffix appended
assert clone["spec"]["display"]["name"] == "Alpha Overview" # display name preserved
assert clone["source"] == "user"
assert clone["locked"] is False

View File

@@ -82,14 +82,14 @@ def test_create_rejects_name_too_long(
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"name": "x" * 65, "data": {"version": "v1"}},
json={"name": "x" * 33, "data": {"version": "v1"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_view_invalid_input"
assert response.json()["error"]["message"] == "name must be at most 64 characters, got 65"
assert response.json()["error"]["message"] == "name must be at most 32 characters, got 33"
def test_create_rejects_wrong_schema_version(