mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 04:40:37 +01:00
Compare commits
1 Commits
main
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05fd505ac8 |
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user