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/span-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09f76238f | ||
|
|
39c4196678 | ||
|
|
92d624fbd1 | ||
|
|
c36226050e | ||
|
|
a72484f12c | ||
|
|
71eabac1e7 | ||
|
|
b3c8ef8d3d | ||
|
|
0d84d09981 | ||
|
|
dea03501c4 | ||
|
|
fea3be7c51 | ||
|
|
f07c2af288 | ||
|
|
6a9eb7fe85 | ||
|
|
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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* Overlay stays below content */
|
||||
[data-slot='dialog-overlay'] {
|
||||
z-index: 50;
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
/* Dialog content always above overlay */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
z-index: 1001 !important;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,6 +8,8 @@ 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';
|
||||
@@ -30,6 +32,7 @@ import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { usePanelQuery } from '../hooks/usePanelQuery';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
|
||||
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
@@ -123,6 +126,33 @@ function PanelEditorContainer({
|
||||
// Switch the panel's visualization kind in place (reversible per session).
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
|
||||
|
||||
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
|
||||
const formattingUnit = (
|
||||
spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
}
|
||||
).formatting?.unit;
|
||||
const seedFormattingUnit = useCallback(
|
||||
(unit: string): void => {
|
||||
const pluginSpec = spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
};
|
||||
setSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
},
|
||||
[spec, setSpec],
|
||||
);
|
||||
const { metricUnit } = useMetricYAxisUnit({
|
||||
isNewPanel: isNew,
|
||||
unit: formattingUnit,
|
||||
onSelectUnit: seedFormattingUnit,
|
||||
});
|
||||
|
||||
// Spec and query dirtiness are tracked independently so query re-serialization
|
||||
// never false-dirties. A new panel is always savable (you're creating it).
|
||||
const isDirty = isNew || isSpecDirty || isQueryDirty;
|
||||
@@ -251,6 +281,7 @@ function PanelEditorContainer({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -12,16 +12,6 @@
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&__mode-select {
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
// Dropdown content is rendered in a portal; bump above FloatingPanel
|
||||
// (z-index 999) so it stays visible when the consumer panel is floating.
|
||||
&__mode-dropdown {
|
||||
--dropdown-menu-content-z-index: 1000;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -45,8 +35,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
border-top: 0px;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { ChevronDown, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
import { PrettyView, PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
|
||||
import './DataViewer.styles.scss';
|
||||
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
enum ViewMode {
|
||||
Pretty = 'pretty',
|
||||
Json = 'json',
|
||||
}
|
||||
|
||||
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
|
||||
|
||||
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: 'Pretty', value: ViewMode.Pretty },
|
||||
{ label: 'JSON', value: ViewMode.Json },
|
||||
];
|
||||
|
||||
export interface DataViewerProps {
|
||||
@@ -32,13 +33,18 @@ function DataViewer({
|
||||
drawerKey = 'default',
|
||||
prettyViewProps,
|
||||
}: DataViewerProps): JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Pretty);
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
const handleViewModeChange = (value: string): void => {
|
||||
const next = value as ViewMode;
|
||||
// A single-select toggle can emit '' when the active item is toggled off;
|
||||
// ignore it so one mode is always selected.
|
||||
if (next !== ViewMode.Pretty && next !== ViewMode.Json) {
|
||||
return;
|
||||
}
|
||||
setViewMode(next);
|
||||
try {
|
||||
logEvent(VIEW_MODE_CHANGED_EVENT, {
|
||||
@@ -59,41 +65,17 @@ function DataViewer({
|
||||
});
|
||||
};
|
||||
|
||||
const currentLabel =
|
||||
VIEW_MODE_OPTIONS.find((opt) => opt.value === viewMode)?.label ?? 'Pretty';
|
||||
|
||||
return (
|
||||
<div className="data-viewer">
|
||||
<div className="data-viewer__toolbar">
|
||||
<Dropdown
|
||||
align="start"
|
||||
className="data-viewer__mode-dropdown"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: viewMode,
|
||||
onChange: handleViewModeChange,
|
||||
children: VIEW_MODE_OPTIONS.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.value,
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="data-viewer__mode-select"
|
||||
suffix={<ChevronDown size={12} />}
|
||||
>
|
||||
{currentLabel}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
items={VIEW_MODE_OPTIONS}
|
||||
testId="data-viewer-view-mode"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="data-viewer__copy-btn"
|
||||
@@ -105,10 +87,10 @@ function DataViewer({
|
||||
</div>
|
||||
|
||||
<div className="data-viewer__content">
|
||||
{viewMode === 'pretty' && (
|
||||
{viewMode === ViewMode.Pretty && (
|
||||
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
|
||||
)}
|
||||
{viewMode === 'json' && <JsonView data={jsonString} />}
|
||||
{viewMode === ViewMode.Json && <JsonView data={jsonString} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,9 @@ const editorOptions: EditorProps['options'] = {
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
// Disabled: the transparent editor background leaves the sticky-scroll widget
|
||||
// without an opaque backing, so scrolling lines bleed through and overlap it.
|
||||
stickyScroll: { enabled: false },
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
@@ -63,7 +66,7 @@ function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
|
||||
value={data}
|
||||
language="json"
|
||||
options={{ ...editorOptions, wordWrap: isWrapWord ? 'on' : 'off' }}
|
||||
onChange={(): void => {}}
|
||||
onChange={(): void => { }}
|
||||
height={height}
|
||||
theme={isDarkMode ? 'signoz-dark' : 'light'}
|
||||
beforeMount={setEditorTheme}
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
|
||||
&__search-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-radius: 4px;
|
||||
border: 0 !important;
|
||||
border-top: 1px solid var(--l2-border) !important;
|
||||
border-bottom: 1px solid var(--l2-border) !important;
|
||||
border-radius: 0 !important;
|
||||
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 18px !important;
|
||||
@@ -22,6 +24,7 @@
|
||||
box-shadow: none !important;
|
||||
|
||||
&:focus {
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-color: var(--primary) !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
@@ -47,6 +50,9 @@
|
||||
> ul,
|
||||
&__pinned > ul {
|
||||
padding-left: 0 !important;
|
||||
// Clip the hover bleed to the panel width. `clip` (not `hidden`) does
|
||||
// not create a scroll container, so the sticky search stays working.
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
// Force font on all tree elements
|
||||
@@ -71,45 +77,87 @@
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
// Leaf node row — hover highlights only this row
|
||||
// Leaf node row — full-width hover highlight
|
||||
&__row {
|
||||
border-radius: 2px;
|
||||
display: flex !important;
|
||||
align-items: baseline;
|
||||
position: relative;
|
||||
isolation: isolate; // own stacking context so ::before sits behind content, not the panel bg
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l3-background);
|
||||
// Keep actions visible on hover, or while this row's menu is open
|
||||
&:hover .pretty-view__actions,
|
||||
&:has(> span [data-state='open']) .pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
// Edge-to-edge highlight, bled past indentation, clipped by `> ul`.
|
||||
// Persists while this row's ... menu is open (the trigger stays in the
|
||||
// row and carries data-state=open, even though the menu is portaled out).
|
||||
&:hover::before,
|
||||
&:has(> span [data-state='open'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 -9999px;
|
||||
background: var(--l3-background);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
// Push actions to the right edge
|
||||
> span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Brighten the value text from its default grey to foreground-hover
|
||||
// while the row is active (hover, or its ... menu open). !important
|
||||
// overrides react-json-tree's per-type inline color on the value span.
|
||||
&:hover > span,
|
||||
&:has(> span [data-state='open']) > span {
|
||||
color: var(--l1-foreground-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Nested node (object/array) — hover only on the label line, not children
|
||||
// Nested node (object/array) — full-width hover on the header line only
|
||||
&__nested-row {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
> label,
|
||||
> span:not(ul span) {
|
||||
border-radius: 2px;
|
||||
padding: 1px 2px;
|
||||
display: inline !important; // keep item string inline with label
|
||||
}
|
||||
|
||||
// Highlight label + item string on hover, show actions
|
||||
&:hover > label,
|
||||
&:hover > label + span {
|
||||
background-color: var(--l3-background);
|
||||
// Edge-to-edge highlight, capped to the header line so it doesn't
|
||||
// cover the children this <li> contains. Persists while this row's own
|
||||
// menu is open — `> span` scopes it so a child row's open menu (nested
|
||||
// in `> ul`) doesn't light up this parent.
|
||||
&:hover::before,
|
||||
&:has(> span [data-state='open'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 20px; // 18px line-height + 2px top padding
|
||||
left: -9999px;
|
||||
right: -9999px;
|
||||
background: var(--l3-background);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:hover > label + span .pretty-view__actions {
|
||||
&:hover > label + span .pretty-view__actions,
|
||||
&:has(> span [data-state='open']) > label + span .pretty-view__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Push the ... to the right edge of the row instead of hugging the key.
|
||||
// Absolute (not flex) so the arrow/label/children layout stays intact;
|
||||
// the row <li> is position: relative (react-json-tree sets it inline).
|
||||
.pretty-view__actions {
|
||||
position: absolute;
|
||||
top: 2px; // align with the header line (li padding-top)
|
||||
right: 0;
|
||||
height: 18px; // line-height — centers the icon (span is align-items: center)
|
||||
}
|
||||
|
||||
// In nested rows, value-row should not take full width
|
||||
.pretty-view__value-row {
|
||||
width: auto;
|
||||
@@ -172,6 +220,7 @@
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding-left: 6px !important;
|
||||
}
|
||||
|
||||
&__pinned-icon {
|
||||
|
||||
@@ -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