mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 12:50:37 +01:00
Compare commits
14 Commits
perf/ts-ch
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a2fce2d7 | ||
|
|
74f61c746c | ||
|
|
05a33ea912 | ||
|
|
cda75cc37d | ||
|
|
c494afdc1c | ||
|
|
86671d43dd | ||
|
|
ec3ada3a70 | ||
|
|
dc6ce4051b | ||
|
|
c36226050e | ||
|
|
a72484f12c | ||
|
|
71eabac1e7 | ||
|
|
fea3be7c51 | ||
|
|
66f03d5912 | ||
|
|
cf69a05f74 |
@@ -3571,7 +3571,7 @@ components:
|
||||
- user
|
||||
- system
|
||||
- integration
|
||||
type: object
|
||||
type: string
|
||||
DashboardtypesSpanGaps:
|
||||
properties:
|
||||
fillLessThan:
|
||||
|
||||
@@ -328,6 +328,11 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import {
|
||||
ActiveElement,
|
||||
Chart,
|
||||
ChartConfiguration,
|
||||
ChartData,
|
||||
ChartEvent,
|
||||
ChartType,
|
||||
Color,
|
||||
TooltipItem,
|
||||
} from 'chart.js';
|
||||
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
|
||||
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -69,189 +60,184 @@ 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,
|
||||
},
|
||||
borderWidth: 0,
|
||||
position: 'start',
|
||||
backgroundColor: 'transparent',
|
||||
color: staticLine.textColor,
|
||||
): 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
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)';
|
||||
},
|
||||
},
|
||||
position: 'custom',
|
||||
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
|
||||
return item2.parsed.y - item1.parsed.y;
|
||||
},
|
||||
},
|
||||
[dragSelectPluginId]: createDragSelectPluginOptions(
|
||||
!!onDragSelect,
|
||||
onDragSelect,
|
||||
dragSelectColor,
|
||||
),
|
||||
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
|
||||
!!onDragSelect,
|
||||
currentTheme === 'dark' ? 'white' : 'black',
|
||||
),
|
||||
},
|
||||
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,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
},
|
||||
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)';
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
title: {
|
||||
display: title !== undefined,
|
||||
text: title,
|
||||
},
|
||||
}) as CustomChartOptions;
|
||||
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, item2): number {
|
||||
return item2.parsed.y - item1.parsed.y;
|
||||
},
|
||||
},
|
||||
[dragSelectPluginId]: createDragSelectPluginOptions(
|
||||
!!onDragSelect,
|
||||
onDragSelect,
|
||||
dragSelectColor,
|
||||
),
|
||||
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
|
||||
!!onDragSelect,
|
||||
currentTheme === 'dark' ? 'white' : 'black',
|
||||
),
|
||||
},
|
||||
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,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
},
|
||||
ticks: {
|
||||
color: getAxisLabelColor(currentTheme),
|
||||
// Include a dollar sign in the ticks
|
||||
callback(value): 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)';
|
||||
},
|
||||
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,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (interactions[0]) {
|
||||
nearestDatasetIndex.current = interactions[0].datasetIndex;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
declare module 'chart.js' {
|
||||
interface TooltipPositionerMap {
|
||||
|
||||
@@ -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 classNames from 'classnames';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { UniversalYAxisUnitMappings } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
|
||||
@@ -72,9 +72,7 @@ function YAxisUnitSelector({
|
||||
}, [categoriesOverride, source]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('y-axis-unit-selector-component', containerClassName)}
|
||||
>
|
||||
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
|
||||
<Select
|
||||
showSearch
|
||||
value={universalUnit}
|
||||
@@ -84,12 +82,17 @@ function YAxisUnitSelector({
|
||||
loading={loading}
|
||||
suffixIcon={
|
||||
incompatibleUnitMessage ? (
|
||||
<Tooltip title={incompatibleUnitMessage}>
|
||||
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
|
||||
<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>
|
||||
) : undefined
|
||||
}
|
||||
className={classNames({
|
||||
className={cx({
|
||||
'warning-state': incompatibleUnitMessage,
|
||||
})}
|
||||
data-testid={dataTestId}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { YAxisCategoryNames } from '../constants';
|
||||
import { UniversalYAxisUnit, YAxisSource } from '../types';
|
||||
@@ -6,9 +7,13 @@ 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', () => {
|
||||
@@ -34,7 +39,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when a value is selected', () => {
|
||||
it('calls onChange when a value is selected', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -44,9 +49,8 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const option = screen.getByText('Bytes (B)');
|
||||
fireEvent.click(option);
|
||||
await user.click(select);
|
||||
await user.click(screen.getByText('Bytes (B)'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('By', {
|
||||
children: 'Bytes (B)',
|
||||
@@ -55,7 +59,7 @@ describe('YAxisUnitSelector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('filters options based on search input', () => {
|
||||
it('filters options based on search input', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -65,14 +69,13 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'bytes/sec' } });
|
||||
await user.click(select);
|
||||
await user.type(select, 'bytes/sec');
|
||||
|
||||
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories and their units', () => {
|
||||
it('shows all categories and their units', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -80,9 +83,8 @@ describe('YAxisUnitSelector', () => {
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
// Check for category headers
|
||||
expect(screen.getByText('Data')).toBeInTheDocument();
|
||||
@@ -93,7 +95,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning message when incompatible unit is selected', () => {
|
||||
it('shows warning message when incompatible unit is selected', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
source={YAxisSource.ALERTS}
|
||||
@@ -104,12 +106,12 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const warningIcon = screen.getByLabelText('warning');
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
fireEvent.mouseOver(warningIcon);
|
||||
return screen
|
||||
.findByText(
|
||||
await user.hover(warningIcon);
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
|
||||
)
|
||||
.then((el) => expect(el).toBeInTheDocument());
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show warning message when compatible unit is selected', () => {
|
||||
@@ -125,7 +127,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses categories override to render custom units', () => {
|
||||
it('uses categories override to render custom units', async () => {
|
||||
const customCategories = [
|
||||
{
|
||||
name: YAxisCategoryNames.Data,
|
||||
@@ -147,9 +149,7 @@ describe('YAxisUnitSelector', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -17,3 +24,7 @@
|
||||
right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.y-axis-unit-warning-tooltip {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
selectIsDashboardLocked,
|
||||
useDashboardStore,
|
||||
} from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { getChartManagerColumns } from './getChartMangerColumns';
|
||||
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
|
||||
@@ -44,7 +44,6 @@ export default function ChartManager({
|
||||
decimalPrecision = PrecisionOptionsEnum.TWO,
|
||||
onCancel,
|
||||
}: ChartManagerProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const { legendItemsMap } = useLegendsSync({
|
||||
config,
|
||||
subscribeToFocusChange: false,
|
||||
@@ -136,11 +135,9 @@ export default function ChartManager({
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
notifications.success({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
toast.success('The updated graphs & legends are saved');
|
||||
onCancel?.();
|
||||
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
|
||||
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
|
||||
|
||||
return (
|
||||
<div className="chart-manager-container">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
|
||||
import ChartManager from '../ChartManager';
|
||||
|
||||
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
const mockNotificationsSuccess = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
|
||||
usePlotContext: (): {
|
||||
@@ -46,12 +46,11 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}): boolean => s.dashboardData?.locked ?? false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: { success: jest.Mock } } => ({
|
||||
notifications: {
|
||||
success: mockNotificationsSuccess,
|
||||
},
|
||||
}),
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('components/ResizeTable', () => {
|
||||
@@ -160,7 +159,7 @@ describe('ChartManager', () => {
|
||||
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
|
||||
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
|
||||
render(
|
||||
<ChartManager
|
||||
config={createMockConfig() as UPlotConfigBuilder}
|
||||
@@ -172,9 +171,9 @@ describe('ChartManager', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'The updated graphs & legends are saved',
|
||||
);
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
// in the same container; size the chart region to its content so they aren't
|
||||
// pushed out. Only this case opts out of filling the height — the dashboard grid,
|
||||
// alert preview, and other charts keep 100% so they fill their container.
|
||||
&--with-layout-children {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function ChartLayout({
|
||||
className={cx('chart-layout', {
|
||||
'chart-layout--legend-right':
|
||||
legendConfig.position === LegendPosition.RIGHT,
|
||||
'chart-layout--with-layout-children': !!layoutChildren,
|
||||
})}
|
||||
>
|
||||
<div className="chart-layout__content">
|
||||
|
||||
@@ -79,13 +79,11 @@ function Panel({
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [
|
||||
widget?.query,
|
||||
widget?.panelTypes,
|
||||
requestData,
|
||||
startTime,
|
||||
endTime,
|
||||
],
|
||||
// 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],
|
||||
retry(failureCount, error): boolean {
|
||||
if (
|
||||
String(error).includes('status: error') &&
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
@@ -18,6 +17,7 @@ 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,6 +51,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
|
||||
@@ -88,14 +89,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
value: next,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
await patchAsync(patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[id, refetch, showErrorModal],
|
||||
[id, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
const { isEditing, draft, setDraft, startEdit, cancel, commit } =
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
@@ -9,7 +8,7 @@ import { isEqual } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
|
||||
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
|
||||
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
|
||||
@@ -23,7 +22,7 @@ interface OverviewProps {
|
||||
function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
const id = dashboard.id;
|
||||
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
@@ -96,15 +95,14 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
await patchAsync(ops);
|
||||
toast.success('Dashboard updated');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [id, buildPatch, refetch, showErrorModal]);
|
||||
}, [buildPatch, patchAsync, showErrorModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
|
||||
@@ -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,14 +14,9 @@ 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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -33,9 +28,8 @@ export function useSaveVariables(): UseSaveVariables {
|
||||
const dtos = variables.map(formModelToDto);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
|
||||
await patchAsync(buildVariablesPatch(dtos));
|
||||
toast.success('Variables updated');
|
||||
refetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -44,7 +38,7 @@ export function useSaveVariables(): UseSaveVariables {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, refetch, showErrorModal],
|
||||
[dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { save, isSaving };
|
||||
|
||||
@@ -40,6 +40,8 @@ interface ConfigPaneProps {
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
|
||||
metricUnit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +60,7 @@ function ConfigPane({
|
||||
stepInterval,
|
||||
panel,
|
||||
panelId,
|
||||
metricUnit,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
@@ -118,6 +121,7 @@ function ConfigPane({
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
@@ -31,22 +30,7 @@ function PanelTypeSwitcher({
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
});
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface UsePanelTypeSelectItemsArgs {
|
||||
/** Active query type — a kind that can't be authored in it is disabled (defaults to Query Builder). */
|
||||
queryType?: EQueryType;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ 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.
|
||||
@@ -74,6 +75,7 @@ function SectionSlot({
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -19,4 +19,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -46,11 +46,10 @@ function DisconnectValuesField({
|
||||
onChange,
|
||||
}: DisconnectValuesFieldProps): JSX.Element {
|
||||
const duration = value?.fillLessThan || undefined;
|
||||
const isThreshold = !!duration;
|
||||
// Remember the last threshold so toggling Never → Threshold restores it.
|
||||
const [lastDuration, setLastDuration] = useState(
|
||||
duration ?? defaultDuration(stepInterval),
|
||||
);
|
||||
// `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);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration) {
|
||||
@@ -59,11 +58,17 @@ function DisconnectValuesField({
|
||||
}, [duration]);
|
||||
|
||||
const handleMode = (mode: DisconnectValuesMode): void => {
|
||||
onChange(
|
||||
mode === DisconnectValuesMode.THRESHOLD
|
||||
? { ...value, fillLessThan: lastDuration }
|
||||
: undefined,
|
||||
);
|
||||
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 });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,14 +84,16 @@ function DisconnectValuesField({
|
||||
onChange={handleMode}
|
||||
/>
|
||||
</div>
|
||||
{isThreshold && (
|
||||
{isThreshold && duration && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Threshold value</Typography.Text>
|
||||
<DisconnectValuesThresholdInput
|
||||
testId={`${testId}-value`}
|
||||
value={lastDuration}
|
||||
value={duration}
|
||||
minValue={stepInterval}
|
||||
onChange={(next): void => onChange({ ...value, fillLessThan: next })}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, fillOnlyBelow: true, fillLessThan: next })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,28 @@ 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
|
||||
@@ -36,24 +58,21 @@ 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 => {
|
||||
if (!raw) {
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
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)}`);
|
||||
const message = validationError(raw, minValue);
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -69,12 +88,9 @@ function DisconnectValuesThresholdInput({
|
||||
status={error ? 'error' : undefined}
|
||||
prefix={<span className={styles.thresholdPrefix}>></span>}
|
||||
value={text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setText(e.target.value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
handleText(e.target.value)
|
||||
}
|
||||
onBlur={(e): void => commit(e.currentTarget.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} 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.
|
||||
@@ -139,7 +164,7 @@ describe('ChartAppearanceSection', () => {
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '1m' },
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,7 +187,7 @@ describe('ChartAppearanceSection', () => {
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '5m' },
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,7 +208,7 @@ describe('ChartAppearanceSection', () => {
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '300' },
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '300' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +225,24 @@ describe('ChartAppearanceSection', () => {
|
||||
|
||||
await user.click(screen.getByText('Never'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
|
||||
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();
|
||||
});
|
||||
|
||||
it('shows an error and does not commit an invalid duration', async () => {
|
||||
@@ -244,4 +286,117 @@ 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import ColumnUnits from './ColumnUnits';
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
|
||||
Pick<SectionEditorContext, 'tableColumns'>;
|
||||
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
|
||||
|
||||
// `full` means "show the raw value, no rounding"; the digits round to that many places.
|
||||
const DECIMAL_OPTIONS: {
|
||||
@@ -39,6 +39,7 @@ function FormattingSection({
|
||||
controls,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
metricUnit,
|
||||
}: FormattingSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -50,6 +51,7 @@ function FormattingSection({
|
||||
data-testid="panel-editor-v2-unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value?.unit}
|
||||
initialValue={metricUnit}
|
||||
onChange={(unit): void => onChange({ ...value, unit })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ 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> {
|
||||
@@ -71,4 +73,31 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -82,6 +82,15 @@ 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;
|
||||
@@ -90,6 +99,11 @@ function ThresholdsSection({
|
||||
setUnsavedIndex(nextIndex);
|
||||
};
|
||||
|
||||
const beginEdit = (index: number): void => {
|
||||
editSnapshot.current = thresholds[index] ?? null;
|
||||
setEditingIndex(index);
|
||||
};
|
||||
|
||||
const saveAt =
|
||||
(index: number) =>
|
||||
(next: AnyThreshold): void => {
|
||||
@@ -105,11 +119,15 @@ function ThresholdsSection({
|
||||
};
|
||||
|
||||
const discardAt = (index: number) => (): void => {
|
||||
// Discarding a row that was never saved removes it; otherwise just exit edit.
|
||||
// A never-saved row is removed; otherwise revert the live edits to the snapshot.
|
||||
if (index === unsavedIndex) {
|
||||
removeAt(index);
|
||||
return;
|
||||
}
|
||||
const original = editSnapshot.current;
|
||||
if (original) {
|
||||
onChange(thresholds.map((t, i) => (i === index ? original : t)));
|
||||
}
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
@@ -120,8 +138,9 @@ function ThresholdsSection({
|
||||
index,
|
||||
yAxisUnit,
|
||||
isEditing: editingIndex === index,
|
||||
onEdit: (): void => setEditingIndex(index),
|
||||
onEdit: (): void => beginEdit(index),
|
||||
onSave: saveAt(index),
|
||||
onLiveChange: updateAt(index),
|
||||
onDiscard: discardAt(index),
|
||||
onRemove: (): void => removeAt(index),
|
||||
};
|
||||
|
||||
@@ -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 } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import UnifiedThresholdsSection from '../ThresholdsSection';
|
||||
|
||||
@@ -36,9 +36,16 @@ const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Stateful harness for flows that depend on the value updating (add/discard).
|
||||
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
|
||||
const [value, setValue] = useState<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);
|
||||
return (
|
||||
<ComparisonThresholdsSection
|
||||
value={value}
|
||||
@@ -142,24 +149,46 @@ describe('ComparisonThresholdsSection', () => {
|
||||
expect(valueInput).toHaveValue(5);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', async () => {
|
||||
it('reflects edits live (before Save) so the preview can react', 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'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
// Back to view mode.
|
||||
// Back to view mode, and re-opening shows the rolled-back 80, not 90.
|
||||
expect(
|
||||
screen.queryByTestId('comparison-threshold-value-0'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
|
||||
expect(screen.getByTestId('comparison-threshold-value-0')).toHaveValue(80);
|
||||
});
|
||||
|
||||
it('removes a threshold from view mode', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } 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,10 +10,16 @@ const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
|
||||
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
|
||||
];
|
||||
|
||||
// Stateful harness for flows that depend on the value updating (add/discard);
|
||||
// Stateful harness for flows that depend on the value updating (add/discard/live);
|
||||
// omits `controls` to exercise the default `label` variant.
|
||||
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
|
||||
const [value, setValue] = useState<AnyThreshold[]>([]);
|
||||
function Harness({
|
||||
yAxisUnit,
|
||||
initial = [],
|
||||
}: {
|
||||
yAxisUnit?: string;
|
||||
initial?: AnyThreshold[];
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState<AnyThreshold[]>(initial);
|
||||
return (
|
||||
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
|
||||
);
|
||||
@@ -37,19 +43,20 @@ describe('ThresholdsSection', () => {
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits a threshold value and commits it on Save', () => {
|
||||
it('edits a threshold value and commits it on Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
|
||||
await user.click(screen.getByTestId('threshold-edit-0'));
|
||||
const valueInput = screen.getByTestId('threshold-value-0');
|
||||
expect(valueInput).toHaveValue(80);
|
||||
|
||||
fireEvent.change(screen.getByTestId('threshold-value-0'), {
|
||||
target: { value: '90' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('threshold-save-0'));
|
||||
await user.clear(valueInput);
|
||||
await user.type(valueInput, '90');
|
||||
await user.click(screen.getByTestId('threshold-save-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
|
||||
]);
|
||||
});
|
||||
@@ -70,43 +77,63 @@ describe('ThresholdsSection', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', () => {
|
||||
it('reflects edits live (before Save) so the preview can react', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
fireEvent.change(screen.getByTestId('threshold-value-0'), {
|
||||
target: { value: '90' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
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');
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
// 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('removes a threshold from view mode', () => {
|
||||
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(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('threshold-edit-0'));
|
||||
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
|
||||
});
|
||||
|
||||
it('removes a threshold from view mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-remove-0'));
|
||||
await user.click(screen.getByTestId('threshold-remove-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('adds a threshold that opens in edit mode, and discards it away', () => {
|
||||
it('adds a threshold that opens in edit mode, and discards it away', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
|
||||
// Discarding a never-saved row removes it entirely.
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
await user.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', () => {
|
||||
it('flags a threshold unit in a different category than the y-axis unit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThresholdsSection
|
||||
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
|
||||
@@ -115,11 +142,12 @@ describe('ThresholdsSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
await user.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', () => {
|
||||
it('does not flag a threshold unit in the same category as the y-axis unit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThresholdsSection
|
||||
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
|
||||
@@ -128,7 +156,7 @@ describe('ThresholdsSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
await user.click(screen.getByTestId('threshold-edit-0'));
|
||||
expect(
|
||||
screen.queryByTestId('threshold-unit-invalid-0'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ComparisonThresholdRowProps {
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
|
||||
onLiveChange: (next: DashboardtypesComparisonThresholdDTO) => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
@@ -42,10 +43,15 @@ function ComparisonThresholdRow({
|
||||
isEditing,
|
||||
onEdit,
|
||||
onSave,
|
||||
onLiveChange,
|
||||
onDiscard,
|
||||
onRemove,
|
||||
}: ComparisonThresholdRowProps): JSX.Element {
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(
|
||||
threshold,
|
||||
isEditing,
|
||||
onLiveChange,
|
||||
);
|
||||
|
||||
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
|
||||
const summary = (
|
||||
|
||||
@@ -20,6 +20,7 @@ interface LabelThresholdRowProps {
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
|
||||
onLiveChange: (next: DashboardtypesThresholdWithLabelDTO) => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
@@ -32,10 +33,15 @@ function LabelThresholdRow({
|
||||
isEditing,
|
||||
onEdit,
|
||||
onSave,
|
||||
onLiveChange,
|
||||
onDiscard,
|
||||
onRemove,
|
||||
}: LabelThresholdRowProps): JSX.Element {
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(
|
||||
threshold,
|
||||
isEditing,
|
||||
onLiveChange,
|
||||
);
|
||||
|
||||
// Persist an empty-string label when none was entered — the spec requires a string.
|
||||
const handleSave = useCallback((): void => {
|
||||
|
||||
@@ -28,6 +28,7 @@ interface TableThresholdRowProps {
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (next: DashboardtypesTableThresholdDTO) => void;
|
||||
onLiveChange: (next: DashboardtypesTableThresholdDTO) => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
@@ -45,10 +46,15 @@ function TableThresholdRow({
|
||||
isEditing,
|
||||
onEdit,
|
||||
onSave,
|
||||
onLiveChange,
|
||||
onDiscard,
|
||||
onRemove,
|
||||
}: TableThresholdRowProps): JSX.Element {
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(
|
||||
threshold,
|
||||
isEditing,
|
||||
onLiveChange,
|
||||
);
|
||||
|
||||
// Stored columnName is the query key; resolve its label + configured unit.
|
||||
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
|
||||
interface ThresholdDraft<T> {
|
||||
draft: T;
|
||||
@@ -7,17 +8,25 @@ 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 (so Discard simply drops the draft and the
|
||||
* next edit starts clean) and exposes the numeric `value` setter all variants use.
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
@@ -25,6 +34,20 @@ 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 }));
|
||||
|
||||
@@ -164,7 +164,7 @@ function PanelEditorQueryBuilder({
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
<RunQueryBtn
|
||||
className="run-query-dashboard-btn"
|
||||
label="Stage & Run Query"
|
||||
label="Run Query"
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
// Standalone View stacks the graph-manager below the chart inside the surface (it
|
||||
// must stay within the chart's PlotContext). Let it flow out of the surface so the
|
||||
// modal body scrolls as a whole, instead of clipping it or scrolling the panel.
|
||||
.surfaceStacked {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
PanelPagination,
|
||||
@@ -30,6 +32,14 @@ interface PreviewPaneProps {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
|
||||
hideHeader?: boolean;
|
||||
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
|
||||
dashboardPreference?: DashboardPreference;
|
||||
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +57,10 @@ function PreviewPane({
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
panelMode = PanelMode.DASHBOARD_EDIT,
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
@@ -58,18 +72,24 @@ function PreviewPane({
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
{!hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
<div
|
||||
className={cx(styles.surface, {
|
||||
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
|
||||
})}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
@@ -90,9 +110,11 @@ function PreviewPane({
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
* options it forwards to the draft/query/query-sync/type-switch hooks and to its
|
||||
* children. The leaf hooks are mocked as arg-capturing spies so this pins the
|
||||
* wiring; it stays valid (and guards behavior) after that wiring is pulled into a
|
||||
* shared edit-session hook, since the mocks intercept the leaf hooks either way.
|
||||
*/
|
||||
|
||||
const mockSetSpec = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
|
||||
}));
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('../../hooks/usePanelQuery', () => ({
|
||||
usePanelQuery: (args: unknown): unknown => mockUseQuery(args),
|
||||
}));
|
||||
|
||||
const mockUseQuerySync = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorQuerySync', () => ({
|
||||
usePanelEditorQuerySync: (args: unknown): unknown => mockUseQuerySync(args),
|
||||
}));
|
||||
|
||||
const mockUseTypeSwitch = jest.fn();
|
||||
jest.mock('../hooks/usePanelTypeSwitch', () => ({
|
||||
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
usePanelEditorSave: (): unknown => ({ save: mockSave, isSaving: false }),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
|
||||
useSwitchColumnsOnSignalChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useSeedNewListColumns', () => ({
|
||||
useSeedNewListColumns: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hooks/useLegendSeries', () => ({
|
||||
useLegendSeries: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
|
||||
() => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: {},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@signozhq/ui/resizable', () => ({
|
||||
__esModule: true,
|
||||
ResizablePanelGroup: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <div>{children}</div>,
|
||||
ResizablePanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ResizableHandle: (): null => null,
|
||||
useDefaultLayout: (): unknown => ({
|
||||
defaultLayout: undefined,
|
||||
onLayoutChanged: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// Children mocked to capture props (and expose a Save trigger / footer slot).
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
const mockPreviewProps = jest.fn();
|
||||
jest.mock('../PreviewPane/PreviewPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockPreviewProps(props);
|
||||
return <div data-testid="preview" />;
|
||||
},
|
||||
}));
|
||||
const mockQbProps = jest.fn();
|
||||
jest.mock('../PanelEditorQueryBuilder/PanelEditorQueryBuilder', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { footer?: React.ReactNode }): JSX.Element => {
|
||||
mockQbProps(props);
|
||||
return <div data-testid="qb">{props.footer}</div>;
|
||||
},
|
||||
}));
|
||||
const mockConfigProps = jest.fn();
|
||||
jest.mock('../ConfigPane/ConfigPane', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown): JSX.Element => {
|
||||
mockConfigProps(props);
|
||||
return <div data-testid="config" />;
|
||||
},
|
||||
}));
|
||||
jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="list-columns" />,
|
||||
}));
|
||||
|
||||
function makePanel(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
): void {
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: false,
|
||||
});
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { response: undefined },
|
||||
isFetching: false,
|
||||
error: null,
|
||||
cancelQuery: mockCancelQuery,
|
||||
refetch: mockRefetch,
|
||||
pagination: undefined,
|
||||
});
|
||||
mockUseQuerySync.mockReturnValue({
|
||||
runQuery: jest.fn(),
|
||||
isQueryDirty: false,
|
||||
buildSaveSpec: mockBuildSaveSpec,
|
||||
});
|
||||
mockUseTypeSwitch.mockReturnValue({
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
});
|
||||
render(<PanelEditorContainer {...baseProps} panel={panel} {...overrides} />);
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('qb')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('config')).toBeInTheDocument();
|
||||
|
||||
expect(mockPreviewProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
panelDefinition: getPanelDefinition('signoz/TimeSeriesPanel'),
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panel,
|
||||
spec: panel.spec,
|
||||
onChangePanelKind: mockOnChangePanelKind,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the derived panel type + query-sync options to the leaf hooks', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel);
|
||||
|
||||
expect(mockUseQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panel, panelId: 'panel-1', enabled: true }),
|
||||
);
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
spec: panel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel dirty and always serializes its query', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alwaysSerializeQuery: true }),
|
||||
);
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bakes the live query into the spec on save, then notifies', async () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
setup(panel, { onSaved: baseProps.onSaved });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
|
||||
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('renders the list-columns editor only for list panels', () => {
|
||||
setup(makePanel('signoz/ListPanel'));
|
||||
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the list-columns editor for non-list panels', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
expect(screen.queryByTestId('list-columns')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
@@ -95,4 +97,141 @@ 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type TelemetrytypesSignalDTO,
|
||||
type 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 {
|
||||
SectionKind,
|
||||
type AnyThreshold,
|
||||
type PanelFormattingSlice,
|
||||
type SectionConfig,
|
||||
SectionKind,
|
||||
type ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import {
|
||||
buildDefaultPluginSpec,
|
||||
@@ -24,13 +29,73 @@ 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 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.
|
||||
* 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.
|
||||
*
|
||||
* Revisiting a kind restores its stashed spec instead, so this runs only on first visit.
|
||||
*/
|
||||
@@ -66,5 +131,19 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,36 @@
|
||||
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 mockInvalidateQueries = jest.fn();
|
||||
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.
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
useQueryClient: (): { getQueryData: jest.Mock } => ({
|
||||
getQueryData: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
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();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
mockIsPatching = false;
|
||||
});
|
||||
|
||||
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
|
||||
it('optimistically patches an add replacing the whole panel spec', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
@@ -50,28 +46,17 @@ describe('usePanelEditorSave', () => {
|
||||
|
||||
await result.current.save(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',
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec',
|
||||
value: spec,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
it('surfaces the patch in-flight state as isSaving', () => {
|
||||
mockIsPatching = true;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
usePanelQuery,
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import { usePanelEditorDraft } from './usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
|
||||
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
|
||||
time?: PanelQueryTimeOverride;
|
||||
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
|
||||
seedQuerySignal?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePanelEditSessionApi {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
isSpecDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
/** Draft kind → V1 panel type (drives the query builder + preview). */
|
||||
panelType: PANEL_TYPES;
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The kind's first supported signal — seeds new queries/columns. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Shared query result for the draft over the resolved time window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft. */
|
||||
runQuery: () => void;
|
||||
isQueryDirty: boolean;
|
||||
/** Bake the live (possibly un-run) query into a spec — for save / editor handoff. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/** Switch the draft's visualization kind in place (reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel-editing pipeline shared by the full-page editor and the View modal's
|
||||
* drilldown editor: a local draft, its query result over the resolved time window,
|
||||
* the staged-query sync, and the visualization-kind switch. Each consumer layers its
|
||||
* own concerns on top (the editor adds save + list seeding; the modal adds per-view
|
||||
* time isolation + reset). Keeping the wiring here stops the two from drifting.
|
||||
*/
|
||||
export function usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionApi {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
usePanelEditorDraft(panel);
|
||||
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(fullKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[fullKind];
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
time,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch: query.refetch,
|
||||
alwaysSerializeQuery,
|
||||
signal: seedQuerySignal ? defaultSignal : undefined,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draft.spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
reset,
|
||||
panelType,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
@@ -13,6 +10,7 @@ import {
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
@@ -43,15 +41,14 @@ export function usePanelEditorSave({
|
||||
layoutIndex,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
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({
|
||||
@@ -70,11 +67,11 @@ export function usePanelEditorSave({
|
||||
];
|
||||
}
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(dashboardQueryKey);
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
return { save, isSaving: isPatching, error };
|
||||
}
|
||||
|
||||
@@ -8,15 +8,11 @@ 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';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
@@ -27,11 +23,9 @@ import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
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 { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
@@ -67,7 +61,28 @@ function PanelEditorContainer({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
const { data, isFetching, error, cancelQuery, refetch, pagination } = query;
|
||||
|
||||
// Live query type (the selected tab) — the type switcher disables kinds that can't be
|
||||
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
|
||||
// query until staged, so the spec would lag the tab.
|
||||
@@ -91,38 +106,35 @@ function PanelEditorContainer({
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
// Panel kind → V1 panel type, which drives the query builder and preview.
|
||||
const fullKind = draft.spec.plugin.kind;
|
||||
const panelType =
|
||||
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
|
||||
PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
// One shared query result for the whole editor; the preview renders it.
|
||||
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
|
||||
const { data, isFetching, error, cancelQuery, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
enabled: !!panelDefinition,
|
||||
});
|
||||
|
||||
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
draft,
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch,
|
||||
// New panel's seed query is the builder default, not a real saved query —
|
||||
// always serialize it on save.
|
||||
alwaysSerializeQuery: isNew,
|
||||
signal: defaultSignal,
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// 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;
|
||||
@@ -251,6 +263,7 @@ function PanelEditorContainer({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Router location state for opening the panel editor pre-loaded with edits instead of
|
||||
* the saved panel. The View modal sets this so "Switch to Edit Mode" carries its
|
||||
* drilldown-edited spec (queries/plugin) into the editor.
|
||||
*/
|
||||
export interface PanelEditorHandoffState {
|
||||
editSpec?: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -37,6 +39,7 @@ function BarPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -114,6 +117,32 @@ function BarPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -147,6 +176,7 @@ function BarPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,7 @@ export interface PreparePieDataArgs {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** One pie slice per (row × value column); column name labels slices when a query has several value columns. */
|
||||
export function preparePieData({
|
||||
tables,
|
||||
customColors,
|
||||
@@ -27,26 +23,35 @@ export function preparePieData({
|
||||
|
||||
const slices: PieSlice[] = [];
|
||||
tables.forEach((table) => {
|
||||
const valueColumn = table.columns.find((column) => column.isValueColumn);
|
||||
if (!valueColumn) {
|
||||
const valueColumns = table.columns.filter((column) => column.isValueColumn);
|
||||
if (valueColumns.length === 0) {
|
||||
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 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 });
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
@@ -37,6 +39,7 @@ function TimeSeriesPanelRenderer({
|
||||
onDragSelect,
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -115,6 +118,32 @@ function TimeSeriesPanelRenderer({
|
||||
return resolveLegendPosition(spec.legend?.position);
|
||||
}, [spec.legend?.position]);
|
||||
|
||||
// The standalone View modal shows V1's graph-manager legend below the chart:
|
||||
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
|
||||
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
|
||||
const layoutChildren = useMemo(
|
||||
() =>
|
||||
panelMode === PanelMode.STANDALONE_VIEW ? (
|
||||
<div className={PanelStyles.chartManagerContainer}>
|
||||
<ChartManager
|
||||
config={config}
|
||||
alignedData={chartData}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
onCancel={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
panelMode,
|
||||
config,
|
||||
chartData,
|
||||
spec.formatting?.unit,
|
||||
decimalPrecision,
|
||||
onCloseStandaloneView,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
|
||||
@@ -148,6 +177,7 @@ function TimeSeriesPanelRenderer({
|
||||
config={config}
|
||||
data={chartData}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
canPinTooltip
|
||||
timezone={timezone}
|
||||
|
||||
@@ -108,7 +108,9 @@ 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 = resolveSpanGaps(chartAppearance?.spanGaps?.fillLessThan);
|
||||
const spanGaps = chartAppearance?.spanGaps
|
||||
? resolveSpanGaps(chartAppearance?.spanGaps)
|
||||
: true;
|
||||
|
||||
const lineStyle = chartAppearance?.lineStyle
|
||||
? LINE_STYLE_MAP[chartAppearance.lineStyle]
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chartManagerContainer {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export type PanelClickEvent =
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
/** Close the standalone View modal — fired by the chart's graph-manager Save/Cancel. */
|
||||
type CloseStandaloneView = () => void;
|
||||
|
||||
/**
|
||||
* Per-kind interaction props — each kind exposes only the gestures it supports.
|
||||
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
|
||||
@@ -31,10 +34,12 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
|
||||
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
|
||||
@@ -50,4 +55,5 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
export interface AnyPanelInteractionProps {
|
||||
onClick?: (event: PanelClickEvent) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { resolveSpanGaps } from '../resolvers';
|
||||
|
||||
describe('resolveSpanGaps', () => {
|
||||
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('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('tolerates a bare seconds number (back-compat)', () => {
|
||||
expect(resolveSpanGaps('600')).toBe(600);
|
||||
expect(resolveSpanGaps({ fillOnlyBelow: true, fillLessThan: '600' })).toBe(
|
||||
600,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to true for unparseable input', () => {
|
||||
expect(resolveSpanGaps('abc')).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -39,15 +40,14 @@ export function resolveDecimalPrecision(
|
||||
}
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* 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).
|
||||
*/
|
||||
export function resolveSpanGaps(
|
||||
fillLessThan: string | undefined,
|
||||
spanGaps: DashboardtypesSpanGapsDTO,
|
||||
): boolean | number {
|
||||
if (!fillLessThan) {
|
||||
const fillLessThan = spanGaps.fillLessThan;
|
||||
if (spanGaps.fillOnlyBelow === false || !fillLessThan) {
|
||||
return true;
|
||||
}
|
||||
const seconds = rangeUtil.isValidTimeSpan(fillLessThan)
|
||||
|
||||
@@ -14,6 +14,19 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockOpenView = jest.fn();
|
||||
jest.mock('../../hooks/useViewPanel', () => ({
|
||||
useViewPanel: (): {
|
||||
openView: jest.Mock;
|
||||
closeView: jest.Mock;
|
||||
expandedPanelId: string | null;
|
||||
} => ({
|
||||
openView: mockOpenView,
|
||||
closeView: jest.fn(),
|
||||
expandedPanelId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockMovePanel = jest.fn();
|
||||
jest.mock('../../hooks/useMovePanelToSection', () => ({
|
||||
useMovePanelToSection: (): jest.Mock => mockMovePanel,
|
||||
@@ -264,18 +277,13 @@ describe('usePanelActionItems', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
|
||||
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
it('view opens the View modal for the panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
|
||||
const view = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'view-panel',
|
||||
);
|
||||
(view as { onClick: () => void }).onClick();
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledTimes(1);
|
||||
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
|
||||
alertSpy.mockRestore();
|
||||
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type MovePanelArgs,
|
||||
useMovePanelToSection,
|
||||
} from '../hooks/useMovePanelToSection';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
@@ -146,6 +147,7 @@ export function usePanelActionItems({
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { openView } = useViewPanel();
|
||||
|
||||
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
|
||||
// supplies data (`sections`), so no callbacks are threaded through it.
|
||||
@@ -178,7 +180,7 @@ export function usePanelActionItems({
|
||||
key: 'view-panel',
|
||||
label: 'View',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: (): void => notImplementedYet('View'),
|
||||
onClick: (): void => openView(panelId),
|
||||
});
|
||||
}
|
||||
if (isEditable && canEditWidget && panelCapabilities.edit) {
|
||||
@@ -263,6 +265,7 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
sections,
|
||||
panelId,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
movePanel,
|
||||
|
||||
@@ -32,6 +32,8 @@ interface PanelBodyProps {
|
||||
searchTerm?: string;
|
||||
/** Server-side paging handles — only consumed by raw/list renderers. */
|
||||
pagination?: PanelPagination;
|
||||
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
|
||||
onCloseStandaloneView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,7 @@ function PanelBody({
|
||||
panelMode = PanelMode.DASHBOARD_VIEW,
|
||||
searchTerm,
|
||||
pagination,
|
||||
onCloseStandaloneView,
|
||||
}: PanelBodyProps): JSX.Element {
|
||||
// react-query keeps the previous response during refetches, so its presence is
|
||||
// the "have something to show" signal — only fail hard when there's nothing.
|
||||
@@ -112,6 +115,7 @@ function PanelBody({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// Expanded state: a compact input that fits the header row.
|
||||
.input {
|
||||
width: 180px;
|
||||
width: min(100%, 320px);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
--button-height: 18px;
|
||||
--button-width: 18px;
|
||||
--button-padding: 0;
|
||||
}
|
||||
|
||||
.searchTrigger {
|
||||
--button-width: 24px;
|
||||
--button-height: 24px;
|
||||
--button-padding: 4px;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function PanelHeaderSearch({
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => setExpanded(true)}
|
||||
className={styles.searchTrigger}
|
||||
data-testid="panel-header-search-trigger"
|
||||
aria-label="Search"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.modal {
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
// Tall, fixed-height column so the renderer's resize observer measures real
|
||||
// dimensions — the chart self-sizes to fill whatever space it's given.
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 78vh;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.queryBuilder {
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbarTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.panelTypeSelector {
|
||||
width: 240px;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Modal } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface ViewPanelModalProps {
|
||||
/**
|
||||
* The expanded panel and its id. Absent while the modal is closed — a single
|
||||
* host instance lives at the layout level and only carries a panel when open.
|
||||
*/
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
panelId?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ViewPanelModal({
|
||||
panel,
|
||||
panelId,
|
||||
open,
|
||||
onClose,
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered
|
||||
width="85%"
|
||||
destroyOnClose
|
||||
className={styles.modal}
|
||||
title={
|
||||
<TooltipSimple title={name} arrow>
|
||||
<span className={styles.title}>{name} - (View mode)</span>
|
||||
</TooltipSimple>
|
||||
}
|
||||
>
|
||||
{open && panel && panelId && (
|
||||
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModal;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { usePanelInteractions } from '../hooks/usePanelInteractions';
|
||||
import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelEditor } from './useViewPanelEditor';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Close the modal — wired to the graph manager's Save/Cancel. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
|
||||
* the panel (preview) over a per-view time window plus the shared query builder, so the
|
||||
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
|
||||
*/
|
||||
function ViewPanelModalContent({
|
||||
panel,
|
||||
panelId,
|
||||
onClose,
|
||||
}: ViewPanelModalContentProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
} = useViewPanelTimeWindow();
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
defaultSignal,
|
||||
queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
} = useViewPanelEditor({ panel, panelId, time: timeOverride });
|
||||
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
|
||||
|
||||
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
|
||||
// cursor-sync group so a drag here can't replay onto the grid panels.
|
||||
const { dashboardPreference } = usePanelInteractions();
|
||||
const isolatedPreference = useMemo<DashboardPreference>(
|
||||
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
|
||||
[dashboardPreference],
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
selectedInterval={selectedInterval}
|
||||
startMs={timeOverride.startMs}
|
||||
endMs={timeOverride.endMs}
|
||||
onTimeChange={onTimeChange}
|
||||
isFetching={isFetching}
|
||||
onRefresh={(): void => {
|
||||
// Relative windows re-anchor to now (new key → refetch); a fixed
|
||||
// custom window just re-runs the same query.
|
||||
if (selectedInterval === 'custom') {
|
||||
refetch();
|
||||
} else {
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={(): void =>
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
|
||||
}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
/>
|
||||
<div className={styles.queryBuilder}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
signal={signal ?? defaultSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
onCloseStandaloneView={onClose}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalContent;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PenLine, RotateCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { usePanelTypeSelectItems } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
|
||||
import ConfigSelect from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
|
||||
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalHeaderProps {
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Current window bounds (epoch ms) — seed the picker's modal display. */
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Any query in flight — spins the refresh icon and disables it. */
|
||||
isFetching: boolean;
|
||||
onRefresh: () => void;
|
||||
onSwitchToEdit: () => void;
|
||||
/** Draft's current kind (selected value of the panel-type selector). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — disables kinds that can't be authored in it (e.g. List under PromQL). */
|
||||
queryType?: EQueryType;
|
||||
/** Current builder datasource — disables types that don't support it. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the saved query + kind (drilldown reset). */
|
||||
onResetQuery: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
|
||||
* visualization kind, pick a per-view time window (isolated from the dashboard), and
|
||||
* refresh. Mirrors V1's FullView header controls.
|
||||
*/
|
||||
function ViewPanelModalHeader({
|
||||
selectedInterval,
|
||||
startMs,
|
||||
endMs,
|
||||
onTimeChange,
|
||||
isFetching,
|
||||
onRefresh,
|
||||
onSwitchToEdit,
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
onChangePanelKind,
|
||||
onResetQuery,
|
||||
}: ViewPanelModalHeaderProps): JSX.Element {
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.panelTypeSelector}>
|
||||
<ConfigSelect<PanelKind>
|
||||
testId="view-panel-type-selector"
|
||||
value={panelKind}
|
||||
items={panelTypeItems}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<PenLine />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="view-panel-switch-to-edit"
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={onResetQuery}
|
||||
data-testid="view-panel-reset-query"
|
||||
>
|
||||
Reset Query
|
||||
</Button>
|
||||
<div className={styles.toolbarTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection
|
||||
disableUrlSync
|
||||
onTimeChange={onTimeChange}
|
||||
modalSelectedInterval={selectedInterval as Time}
|
||||
modalInitialStartTime={startMs}
|
||||
modalInitialEndTime={endMs}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onRefresh}
|
||||
disabled={isFetching}
|
||||
aria-label="Refresh"
|
||||
data-testid="view-panel-refresh"
|
||||
>
|
||||
<RotateCw className={cx({ 'animate-spin': isFetching })} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalHeader;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { usePanelEditSession } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface UseViewPanelEditorArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
|
||||
time: PanelQueryTimeOverride;
|
||||
}
|
||||
|
||||
export interface UseViewPanelEditorApi {
|
||||
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Resolved renderer for the draft's current kind. */
|
||||
panelDefinition: RenderablePanelDefinition | undefined;
|
||||
/** Current builder datasource — drives the panel-type selector's disabled rule. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** The kind's first supported signal — the query builder's fallback datasource. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
|
||||
queryType: EQueryType;
|
||||
/** Query result for the draft over the per-view window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
|
||||
runQuery: () => void;
|
||||
/** Switch the draft's visualization kind (temporary; reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the saved panel's query + kind, discarding the drilldown edits. */
|
||||
resetQuery: () => void;
|
||||
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the View modal into a compact, drilldown panel editor on top of the shared
|
||||
* `usePanelEditSession`: the same draft/query/query-sync/type-switch pipeline the
|
||||
* full editor uses, scoped to a per-view time window, plus drilldown-only extras
|
||||
* (the saved-query snapshot for Reset, and the builder signal for the type selector).
|
||||
* Edits are temporary — they live in the builder/URL and the draft, never the
|
||||
* dashboard, matching V1.
|
||||
*/
|
||||
export function useViewPanelEditor({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
}: UseViewPanelEditorArgs): UseViewPanelEditorApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
buildSaveSpec,
|
||||
reset,
|
||||
} = usePanelEditSession({ panel, panelId, time });
|
||||
|
||||
// The saved panel's query, captured once — the restore target for Reset Query.
|
||||
const savedQuery = useMemo(
|
||||
() =>
|
||||
fromPerses(
|
||||
panel.spec.queries,
|
||||
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
|
||||
[],
|
||||
);
|
||||
|
||||
const resetQuery = useCallback((): void => {
|
||||
// Draft back to the saved panel (query + kind); builder back to the saved query.
|
||||
reset();
|
||||
redirectWithQueryBuilderData(savedQuery);
|
||||
}, [reset, redirectWithQueryBuilderData, savedQuery]);
|
||||
|
||||
// Current builder datasource for the panel-type disabled rule — resolved the same
|
||||
// way as the full editor's ConfigPane so the two selectors stay in sync.
|
||||
const signal = resolveSignal(draft.spec.queries, defaultSignal);
|
||||
|
||||
return {
|
||||
draft,
|
||||
panelDefinition,
|
||||
signal,
|
||||
defaultSignal,
|
||||
queryType: currentQuery.queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
export interface ViewPanelTimeWindow {
|
||||
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
|
||||
timeOverride: PanelQueryTimeOverride;
|
||||
/** Interval shown in the picker — a relative `Time` or `'custom'`. */
|
||||
selectedInterval: Time | CustomTimeType;
|
||||
/** Apply a selection from DateTimeSelectionV2 (modal mode). */
|
||||
onTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
range?: [number, number],
|
||||
) => void;
|
||||
/** Re-anchor a relative window to "now" (manual refresh); no-op for custom. */
|
||||
refreshWindow: () => void;
|
||||
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view time window for the panel View modal, isolated from the dashboard's
|
||||
* global time (V1 parity: the modal's time selector doesn't move the grid). Seeded
|
||||
* once from the current global window, then owned locally. Relative intervals
|
||||
* resolve to an absolute ms window via the same `GetMinMax` the app-wide picker uses.
|
||||
*/
|
||||
export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
|
||||
const { selectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const [selectedInterval, setSelectedInterval] = useState<
|
||||
Time | CustomTimeType
|
||||
>(selectedTime as Time);
|
||||
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
|
||||
() => ({
|
||||
startMs: Math.floor(minTime / NS_PER_MS),
|
||||
endMs: Math.floor(maxTime / NS_PER_MS),
|
||||
}),
|
||||
);
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, range?: [number, number]): void => {
|
||||
setSelectedInterval(interval);
|
||||
// Absolute range comes through directly (already epoch ms).
|
||||
if (interval === 'custom' && range) {
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(range[0]),
|
||||
endMs: Math.floor(range[1]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// GetMinMax returns nanoseconds — convert to the ms window we work in.
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshWindow = useCallback((): void => {
|
||||
// A custom window is fixed; only relative intervals re-anchor to now.
|
||||
if (selectedInterval === 'custom') {
|
||||
return;
|
||||
}
|
||||
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
|
||||
setTimeOverride({
|
||||
startMs: Math.floor(startNs / NS_PER_MS),
|
||||
endMs: Math.floor(endNs / NS_PER_MS),
|
||||
});
|
||||
}, [selectedInterval]);
|
||||
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
// Drag values are already epoch ms (same as the global custom range).
|
||||
const startMs = Math.floor(start);
|
||||
const endMs = Math.floor(end);
|
||||
// Ignore a click / zero-width or inverted selection.
|
||||
if (startMs >= endMs) {
|
||||
return;
|
||||
}
|
||||
setSelectedInterval('custom');
|
||||
setTimeOverride({ startMs, endMs });
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
}),
|
||||
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
|
||||
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
|
||||
// path); stub it (capturing props) so this suite asserts the modal shell + what it
|
||||
// threads down, not the preview internals (PreviewPane/PanelHeader own those).
|
||||
const mockPreviewPaneRender = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
|
||||
() =>
|
||||
function MockPreviewPane(props: Record<string, unknown>): ReactElement {
|
||||
mockPreviewPaneRender(props);
|
||||
return <div data-testid="preview-pane" />;
|
||||
},
|
||||
);
|
||||
|
||||
// Isolate from the draft/query-builder plumbing (its own suite covers it).
|
||||
jest.mock('../ViewPanelModal/useViewPanelEditor', () => ({
|
||||
useViewPanelEditor: (args: {
|
||||
panel: { spec: { plugin: { kind: string } } };
|
||||
}): unknown => {
|
||||
const { kind } = args.panel.spec.plugin;
|
||||
return {
|
||||
draft: args.panel,
|
||||
panelDefinition: {
|
||||
kind,
|
||||
actions: { search: kind === 'signoz/ListPanel' },
|
||||
Renderer: (): null => null,
|
||||
},
|
||||
query: {
|
||||
data: { response: undefined, requestPayload: undefined, legendMap: {} },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
cancelQuery: jest.fn(),
|
||||
pagination: undefined,
|
||||
},
|
||||
runQuery: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
signal: undefined,
|
||||
defaultSignal: 'logs',
|
||||
buildSaveSpec: (spec: unknown): unknown => spec,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// The View modal reuses the edit page's query builder, which reads the global
|
||||
// QueryBuilder context and pulls in the ClickHouse/PromQL editors; stub it here.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder',
|
||||
() =>
|
||||
function MockPanelEditorQueryBuilder(): ReactElement {
|
||||
return <div data-testid="panel-editor-v2-query-builder" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../hooks/usePanelInteractions', () => ({
|
||||
usePanelInteractions: (): unknown => ({
|
||||
onDragSelect: jest.fn(),
|
||||
dashboardPreference: { syncMode: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
// The header mounts DateTimeSelectionV2 (redux + router + heavy deps); stub it so
|
||||
// this suite asserts the modal body, not the toolbar internals.
|
||||
jest.mock(
|
||||
'../ViewPanelModal/ViewPanelModalHeader',
|
||||
() =>
|
||||
function MockViewPanelModalHeader(): ReactElement {
|
||||
return <div data-testid="view-panel-header" />;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
|
||||
useViewPanelTimeWindow: (): unknown => ({
|
||||
timeOverride: { startMs: 0, endMs: 0 },
|
||||
selectedInterval: '5m',
|
||||
onTimeChange: jest.fn(),
|
||||
refreshWindow: jest.fn(),
|
||||
onDragSelect: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockOpenEditor = jest.fn();
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
|
||||
() => ({
|
||||
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
|
||||
}),
|
||||
);
|
||||
|
||||
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
|
||||
render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
function makePanel(kind: string, name = 'My panel'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind, spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('ViewPanelModal', () => {
|
||||
it('renders nothing until opened', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open={false}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('view-panel-modal-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the header, query builder, and preview when open', () => {
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel', 'CPU usage')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('view-panel-modal-content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-query-builder'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onClose when the modal is dismissed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText('Close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Charts share one global cursor-sync key and uPlot replays drag across the
|
||||
// group; the modal must opt out so a drag here can't move the dashboard's time.
|
||||
it('opts the chart out of the dashboard cursor-sync group', () => {
|
||||
mockPreviewPaneRender.mockClear();
|
||||
renderWithProvider(
|
||||
<ViewPanelModal
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="p1"
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const props = mockPreviewPaneRender.mock.calls.at(-1)?.[0] as {
|
||||
dashboardPreference?: { syncMode?: unknown };
|
||||
};
|
||||
expect(props.dashboardPreference?.syncMode).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import { useViewPanelTimeWindow } from '../ViewPanelModal/useViewPanelTimeWindow';
|
||||
|
||||
const NS_PER_MS = 1e6;
|
||||
|
||||
// Global time is stored in nanoseconds; the hook must surface milliseconds.
|
||||
const mockState = {
|
||||
globalTime: {
|
||||
selectedTime: '6h',
|
||||
minTime: 6_000_000 * NS_PER_MS,
|
||||
maxTime: 7_000_000 * NS_PER_MS,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector(mockState),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetMinMax = GetMinMax as unknown as jest.Mock;
|
||||
|
||||
describe('useViewPanelTimeWindow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('seeds the window from global time, converting ns → ms', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: mockState.globalTime.minTime / NS_PER_MS,
|
||||
endMs: mockState.globalTime.maxTime / NS_PER_MS,
|
||||
});
|
||||
expect(result.current.selectedInterval).toBe('6h');
|
||||
});
|
||||
|
||||
it('converts GetMinMax (ns) to ms on a relative selection', () => {
|
||||
mockGetMinMax.mockReturnValue({
|
||||
minTime: 1_700_000_000_000 * NS_PER_MS,
|
||||
maxTime: 1_700_000_300_000 * NS_PER_MS,
|
||||
});
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('5m'));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('5m');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1_700_000_000_000,
|
||||
endMs: 1_700_000_300_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an absolute custom range as-is (already ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onTimeChange('custom', [111, 222]));
|
||||
|
||||
expect(mockGetMinMax).not.toHaveBeenCalled();
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 111,
|
||||
endMs: 222,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets a custom window from a drag selection (modal-local, ms)', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
|
||||
act(() => result.current.onDragSelect(1000, 5000));
|
||||
|
||||
expect(result.current.selectedInterval).toBe('custom');
|
||||
expect(result.current.timeOverride).toStrictEqual({
|
||||
startMs: 1000,
|
||||
endMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a zero-width or inverted drag selection', () => {
|
||||
const { result } = renderHook(() => useViewPanelTimeWindow());
|
||||
const initial = result.current.timeOverride;
|
||||
|
||||
act(() => result.current.onDragSelect(5000, 5000));
|
||||
act(() => result.current.onDragSelect(9000, 1000));
|
||||
|
||||
expect(result.current.timeOverride).toStrictEqual(initial);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
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';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
|
||||
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mock('../../../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
|
||||
patchAsync: mockPatchAsync,
|
||||
isPatching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockToastPromise = jest.fn();
|
||||
@@ -16,8 +19,6 @@ 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: {
|
||||
@@ -45,7 +46,7 @@ function sections(): DashboardSection[] {
|
||||
describe('useClonePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1' });
|
||||
});
|
||||
|
||||
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
|
||||
@@ -53,7 +54,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/cloned-id',
|
||||
@@ -92,7 +93,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
const ops = mockPatch.mock.calls[0][1];
|
||||
const ops = mockPatchAsync.mock.calls[0][0];
|
||||
// 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 });
|
||||
});
|
||||
@@ -102,7 +103,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
const ops = mockPatch.mock.calls[0][1];
|
||||
const ops = mockPatchAsync.mock.calls[0][0];
|
||||
expect(ops[0].value).toStrictEqual(sourcePanel);
|
||||
expect(ops[0].value).not.toBe(sourcePanel);
|
||||
});
|
||||
@@ -112,7 +113,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'missing', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
expect(mockPatchAsync).not.toHaveBeenCalled();
|
||||
expect(mockToastPromise).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -132,7 +133,7 @@ describe('useClonePanel', () => {
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('boom'));
|
||||
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -18,12 +19,55 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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=1',
|
||||
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.
|
||||
jest.mock('../../utils/buildCreateAlertUrl', () => ({
|
||||
buildCreateAlertUrl: (): string => '/alerts/new?composite=sync',
|
||||
buildAlertUrl: (): string => '/alerts/new?composite=substituted',
|
||||
readPanelUnit: (): string | undefined => undefined,
|
||||
}));
|
||||
|
||||
// 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 = {
|
||||
@@ -38,17 +82,7 @@ const panel = {
|
||||
describe('useCreateAlertFromPanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
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,
|
||||
});
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1', resolvedVariables: {} });
|
||||
});
|
||||
|
||||
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
|
||||
@@ -66,4 +100,80 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
findFreeSlot,
|
||||
@@ -32,7 +31,7 @@ export function useClonePanel({
|
||||
sections,
|
||||
}: Params): (args: ClonePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
|
||||
@@ -45,8 +44,7 @@ export function useClonePanel({
|
||||
const newPanelId = uuid();
|
||||
const { x, y } = findFreeSlot(section.items, source.width);
|
||||
|
||||
const clone = patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
const clone = patchAsync(
|
||||
addPanelToSectionOps({
|
||||
panelId: newPanelId,
|
||||
panel: cloneDeep(source.panel),
|
||||
@@ -68,15 +66,14 @@ export function useClonePanel({
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
// Refetch only on success; toast.promise owns the error UX, so swallow
|
||||
// the rejection to avoid an unhandled rejection.
|
||||
// toast.promise owns the error UX; swallow here to avoid an unhandled
|
||||
// rejection (the optimistic cache write + settle refetch handle state).
|
||||
try {
|
||||
await clone;
|
||||
refetch();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch],
|
||||
[sections, dashboardId, patchAsync],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
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 { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
|
||||
import {
|
||||
buildAlertUrl,
|
||||
buildCreateAlertUrl,
|
||||
readPanelUnit,
|
||||
} from '../utils/buildCreateAlertUrl';
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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).
|
||||
*/
|
||||
export function useCreateAlertFromPanel(): (
|
||||
panel: DashboardtypesPanelDTO,
|
||||
@@ -20,18 +34,61 @@ 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: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
panelType,
|
||||
dashboardId,
|
||||
widgetId: panelId,
|
||||
queryType: getPanelQueryType(panel),
|
||||
});
|
||||
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
|
||||
|
||||
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',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[dashboardId, safeNavigate],
|
||||
[dashboardId, variables, minTime, maxTime, substituteVars, safeNavigate],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
@@ -40,15 +40,14 @@ export function useDeletePanel({
|
||||
|
||||
const nextItems = section.items.filter((i) => i.id !== panelId);
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
await patchAsync([
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
removePanelOp(panelId),
|
||||
]);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch, showErrorModal],
|
||||
[sections, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
@@ -60,8 +60,7 @@ export function useMovePanelToSection({
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
await patchAsync(
|
||||
movePanelBetweenSectionsOps({
|
||||
sourceIndex: fromLayoutIndex,
|
||||
sourceItems,
|
||||
@@ -69,11 +68,10 @@ export function useMovePanelToSection({
|
||||
targetItems,
|
||||
}),
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch, showErrorModal],
|
||||
[sections, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
export interface UseViewPanelApi {
|
||||
/** Panel id currently expanded in the View modal; null when none is open. */
|
||||
expandedPanelId: string | null;
|
||||
/** Open the View modal for a panel by writing its id to the URL. */
|
||||
openView: (panelId: string) => void;
|
||||
/** Close the View modal by clearing the URL param. */
|
||||
closeView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
|
||||
* the open state is shareable, survives refresh, and the browser back-button
|
||||
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
|
||||
|
||||
const openView = useCallback(
|
||||
(panelId: string): void => {
|
||||
// Copy before mutating: useUrlQuery returns a memoized instance.
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.set(QueryParams.expandedWidgetId, panelId);
|
||||
safeNavigate(`${pathname}?${next.toString()}`);
|
||||
},
|
||||
[pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const closeView = useCallback((): void => {
|
||||
const next = new URLSearchParams(urlQuery);
|
||||
next.delete(QueryParams.expandedWidgetId);
|
||||
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
|
||||
// (the in-modal query builder writes compositeQuery, V1 parity).
|
||||
next.delete(QueryParams.compositeQuery);
|
||||
next.delete(QueryParams.graphType);
|
||||
const search = next.toString();
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
return { expandedPanelId, openView, closeView };
|
||||
}
|
||||
@@ -5,11 +5,14 @@ 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';
|
||||
|
||||
function readPanelUnit(
|
||||
/** The panel's configured y-axis unit, for the kinds that carry one. */
|
||||
export function readPanelUnit(
|
||||
plugin: DashboardtypesPanelPluginDTO,
|
||||
): string | undefined {
|
||||
switch (plugin.kind) {
|
||||
@@ -24,20 +27,17 @@ function readPanelUnit(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
export function buildAlertUrl(
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
unit?: string,
|
||||
): string {
|
||||
if (unit) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
query.unit = unit;
|
||||
}
|
||||
|
||||
@@ -52,3 +52,15 @@ export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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 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.
|
||||
* 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.
|
||||
*/
|
||||
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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -66,8 +66,7 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [op]);
|
||||
refetch();
|
||||
await patchAsync([op]);
|
||||
scrollToNewSection(prevSectionCount);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -75,7 +74,7 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, refetch, showErrorModal],
|
||||
[layouts, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
|
||||
@@ -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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -38,14 +38,13 @@ export function useDeleteSection({ section }: Params): Result {
|
||||
ops.push(removeSectionOp(section.layoutIndex));
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
refetch();
|
||||
await patchAsync(ops);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [section, dashboardId, refetch, showErrorModal]);
|
||||
}, [section, dashboardId, patchAsync, showErrorModal]);
|
||||
|
||||
return { deleteSection, isSaving };
|
||||
}
|
||||
|
||||
@@ -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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -49,15 +49,14 @@ export function useFirstSectionMigration({ sections }: Params): Result {
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
refetch();
|
||||
await patchAsync(ops);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch, showErrorModal],
|
||||
[sections, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { migrate, isSaving };
|
||||
|
||||
@@ -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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -80,17 +80,14 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
]);
|
||||
refetch();
|
||||
await patchAsync([replaceSectionItemsOp(layoutIndex, nextItems)]);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, items, layoutIndex, refetch, showErrorModal],
|
||||
[dashboardId, items, layoutIndex, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { handleLayoutChange, isSaving };
|
||||
|
||||
@@ -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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -31,10 +31,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
renameSectionOp(layoutIndex, trimmed),
|
||||
]);
|
||||
refetch();
|
||||
await patchAsync([renameSectionOp(layoutIndex, trimmed)]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -43,7 +40,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, layoutIndex, refetch, showErrorModal],
|
||||
[dashboardId, layoutIndex, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { rename, isSaving };
|
||||
|
||||
@@ -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 refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -99,14 +99,13 @@ export function useSectionDragReorder({ sections, layouts }: Params): Result {
|
||||
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
|
||||
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
|
||||
refetch();
|
||||
await patchAsync([reorderLayoutsOp(newLayouts)]);
|
||||
} catch (error) {
|
||||
setLocalOrderIds(null); // revert optimistic order on failure
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
|
||||
[orderedSections, layouts, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
const activeSection = useMemo(
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { layoutsToSections } from '../utils';
|
||||
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import { useViewPanel } from './Panel/hooks/useViewPanel';
|
||||
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
|
||||
import Section from './Section/Section/Section';
|
||||
import SectionList from './Section/SectionList';
|
||||
import styles from './PanelsAndSectionsLayout.module.scss';
|
||||
@@ -26,6 +28,12 @@ function PanelsAndSectionsLayout({
|
||||
}: PanelsAndSectionsLayoutProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
// Single View-modal host for the whole dashboard, driven by the URL
|
||||
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
|
||||
// subscriptions, and the expanded panel is looked up by id from the map.
|
||||
const { expandedPanelId, closeView } = useViewPanel();
|
||||
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
|
||||
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
@@ -56,7 +64,17 @@ function PanelsAndSectionsLayout({
|
||||
));
|
||||
};
|
||||
|
||||
return <div className={styles.body}>{renderContent()}</div>;
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
{renderContent()}
|
||||
<ViewPanelModal
|
||||
open={!!expandedPanel}
|
||||
panel={expandedPanel}
|
||||
panelId={expandedPanelId ?? undefined}
|
||||
onClose={closeView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelsAndSectionsLayout;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,21 +3,28 @@ import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id.
|
||||
* caller can open the editor with just the panel id. The optional `state` is passed as router
|
||||
* location state — the View modal uses it to hand off its drilldown-edited spec so the editor
|
||||
* opens on those edits rather than the saved panel.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (panelId: string) => void {
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
state?: PanelEditorHandoffState,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string): void => {
|
||||
(panelId: string, state?: PanelEditorHandoffState): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
state ? { state } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} 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 { fromPerses, toPerses } from '../persesQueryAdapters';
|
||||
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
|
||||
|
||||
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
|
||||
function bareQuery(
|
||||
@@ -58,6 +61,26 @@ 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(
|
||||
|
||||
@@ -74,14 +74,14 @@ export function deriveQueryType(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function fromPerses(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
export function envelopesToQuery(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
panelType: PANEL_TYPES,
|
||||
): Query {
|
||||
const envelopes = toQueryEnvelopes(queries);
|
||||
if (envelopes.length === 0) {
|
||||
return initialQueriesMap[DataSource.METRICS];
|
||||
}
|
||||
@@ -99,6 +99,17 @@ export function fromPerses(
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
|
||||
import { buildDefaultPluginSpec } from '../DashboardContainer/Panels/utils/buildDefaultPluginSpec';
|
||||
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
|
||||
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
|
||||
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
|
||||
import {
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
@@ -32,9 +33,13 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
}>();
|
||||
const { search } = useLocation();
|
||||
const { search, state } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// Edits handed off from the View modal's drilldown — open the editor on these
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
|
||||
|
||||
const { data, isLoading, isError, error } = useGetDashboardV2({
|
||||
id: dashboardId,
|
||||
});
|
||||
@@ -44,17 +49,20 @@ function PanelEditorPage(): JSX.Element {
|
||||
// kind rather than looking one up. Persisted (with a real id) only on save.
|
||||
const newKind = parseNewPanelKind(panelId, search);
|
||||
const existingPanel = dashboard?.spec.panels[panelId];
|
||||
const panel = useMemo(
|
||||
() =>
|
||||
newKind
|
||||
? createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildDefaultQueries(newKind),
|
||||
)
|
||||
: existingPanel,
|
||||
[newKind, existingPanel],
|
||||
);
|
||||
const panel = useMemo(() => {
|
||||
if (newKind) {
|
||||
return createDefaultPanel(
|
||||
newKind,
|
||||
buildDefaultPluginSpec(getPanelDefinition(newKind)?.sections ?? []),
|
||||
buildDefaultQueries(newKind),
|
||||
);
|
||||
}
|
||||
if (!existingPanel) {
|
||||
return undefined;
|
||||
}
|
||||
// Open on the modal's drilldown edits when handed off; else the saved panel.
|
||||
return handoffSpec ? { ...existingPanel, spec: handoffSpec } : existingPanel;
|
||||
}, [newKind, existingPanel, handoffSpec]);
|
||||
|
||||
// Target section for a newly-created panel (set by the "Add panel" trigger).
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./node_modules/.cache/ts/tsconfig.tsbuildinfo",
|
||||
"paths": {
|
||||
"*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@constants/*": [
|
||||
"./src/container/OnboardingContainer/constants/*"
|
||||
],
|
||||
@@ -34,32 +35,7 @@
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
@@ -76,11 +52,18 @@
|
||||
],
|
||||
"include": [
|
||||
"./src",
|
||||
"./__mocks__",
|
||||
"./babel.config.cjs",
|
||||
"./src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.d.ts",
|
||||
"babel.config.cjs",
|
||||
"./jest.config.ts",
|
||||
"./jest.setup.ts",
|
||||
"./__mocks__",
|
||||
"./conf/default.conf",
|
||||
"./public",
|
||||
"./commitlint.config.ts",
|
||||
"./vite.config.ts",
|
||||
"./commitlint.config.ts"
|
||||
"./jest.setup.ts",
|
||||
"./tests/**.ts",
|
||||
"./**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1100,24 +1100,25 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
reducedSumSB.Where(reducedSumSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedSumSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
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 {
|
||||
// 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
|
||||
reducedFpSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedFpSB.Select("metric_name", "fingerprint")
|
||||
reducedFpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedFpSB.Select("fingerprint")
|
||||
reducedFpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFpSB.Where(reducedFpSB.Between("unix_milli", start, end))
|
||||
reducedFpSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedFpSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
reducedFpSB.GroupBy("metric_name", "fingerprint")
|
||||
reducedFpSB.GroupBy("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)")
|
||||
}
|
||||
@@ -1422,7 +1423,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.TimeseriesV4ReducedTableName))
|
||||
reducedFingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFingerprintSB.Where(reducedFingerprintSB.Between("unix_milli", start, end))
|
||||
reducedFingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedFingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
|
||||
@@ -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 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"
|
||||
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"
|
||||
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.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"
|
||||
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"
|
||||
|
||||
// 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: 17, reductionEnabled: true},
|
||||
{name: "WithFilter_FingerprintSQL", opts: []statsOpt{withStatsFilter("host.name = 'foo'")}, seedKey: "host.name", expectSQL: statsWithFilterSQL, argCount: 20, 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},
|
||||
|
||||
@@ -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.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",
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
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.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",
|
||||
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",
|
||||
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},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -296,7 +296,7 @@ func (b *MetricQueryStatementBuilder) buildReducedTimeSeriesCTE(
|
||||
}
|
||||
}
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, TimeseriesV4ReducedTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, TimeseriesV4ReducedLocalTableName))
|
||||
sb.Select("fingerprint")
|
||||
for _, g := range query.GroupBy {
|
||||
col, err := b.fm.ColumnExpressionFor(ctx, start, end, &g.TelemetryFieldKey, keys)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
type Source struct {
|
||||
@@ -22,6 +23,23 @@ 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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user