Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi kumar
6bcc2d66d9 feat(dashboards-v2): pre-populate alert condition from panel Reduce To + thresholds (#5291) (#12137)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* refactor(alerts-v2): extract alert condition normalizers to a leaf module

normalizeOperator/normalizeMatchType move from CreateAlertV2/utils.tsx into a
dependency-free context/conditionNormalizers.ts (re-exported from utils for existing
callers), so the CreateAlert context can consume them without an index<->utils cycle.

* feat(alerts-v2): apply alert-condition prefill declaratively from the URL

The CreateAlertV2 context now resolves a declarative prefill from the URL
(resolveUrlAlertPrefill) and applies each field iff present, instead of branching on
the request source. Producers own their semantics: ingestion settings now explicitly
declares matchType=in_total + evaluationWindowPreset=meter, so adding a new producer
needs no consumer change.

* feat(dashboards-v2): pre-populate alert condition from panel Reduce To + thresholds (#5291)

Creating an alert from a V2 panel now seeds the condition: Reduce To -> occurrence
type (sum->in total, avg->on average; Value panel only), and the highest-danger
threshold (Red>Orange>Green>Blue) -> operator, target value, and unit. Formula panels
apply the reduce only when all queries agree. The seed rides the /alerts/new URL and is
applied by the declarative CreateAlertV2 consumer.
2026-07-16 22:08:24 +00:00
Pandey
5ee1ca7d05 fix(ruletypes): round-trip empty notificationSettings fields (#12138)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(ruletypes): always serialize notificationSettings.usePolicy

usePolicy is a plain bool tagged omitempty, so a false value is dropped
from the GET response. Clients that pin usePolicy to false read it back as
absent/null. Drop omitempty (matching Renotify.Enabled) so false always
serializes and round-trips.

* test(ruletypes): assert usePolicy round-trips in minimal read shape

usePolicy now always serializes, so the minimal read shape includes it with a false value rather than omitting it.

* fix(ruletypes): always serialize notificationSettings.groupBy

Like usePolicy, groupBy dropped its omitempty so an explicitly-set empty value round-trips instead of reading back as absent. A nil groupBy now serializes as null.

* fix(ruletypes): use omitzero for notificationSettings.groupBy

omitzero omits groupBy only when nil (unset) while preserving an explicitly-set empty array as [], so unset and empty stay distinguishable instead of every response carrying groupBy: null. Matches the sibling newGroupEvalDelay tag.

* fix(ruletypes): use omitzero for renotify.alertStates

Symmetric to groupBy: omitzero omits alertStates only when nil (unset renotify configs stay absent, no regression) while echoing an explicitly-set empty array as [].

* chore(ruletypes): regenerate openapi spec and api client

groupBy and renotify.alertStates switched to omitzero, so the generated spec marks both slices nullable and the orval client types them as [] | null.
2026-07-16 18:13:24 +00:00
18 changed files with 890 additions and 96 deletions

View File

@@ -7571,6 +7571,7 @@ components:
groupBy:
items:
type: string
nullable: true
type: array
newGroupEvalDelay:
type: string
@@ -7642,6 +7643,7 @@ components:
alertStates:
items:
$ref: '#/components/schemas/RuletypesAlertState'
nullable: true
type: array
enabled:
type: boolean

View File

@@ -8675,9 +8675,9 @@ export interface RuletypesGettableTestRuleDTO {
export interface RuletypesRenotifyDTO {
/**
* @type array
* @type array,null
*/
alertStates?: RuletypesAlertStateDTO[];
alertStates?: RuletypesAlertStateDTO[] | null;
/**
* @type boolean
*/
@@ -8690,9 +8690,9 @@ export interface RuletypesRenotifyDTO {
export interface RuletypesNotificationSettingsDTO {
/**
* @type array
* @type array,null
*/
groupBy?: string[];
groupBy?: string[] | null;
/**
* @type string
*/

View File

@@ -57,4 +57,7 @@ export enum QueryParams {
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',
matchType = 'matchType',
compareOp = 'compareOp',
evaluationWindowPreset = 'evaluationWindowPreset',
}

View File

@@ -0,0 +1,110 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { CreateAlertProvider, useCreateAlertState } from '../index';
// The provider only needs a query with a unit + empty builder for these assertions.
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: {
unit: 'bytes',
builder: { queryData: [], queryFormulas: [] },
},
redirectWithQueryBuilderData: jest.fn(),
}),
}));
const mutation = { mutate: jest.fn(), isLoading: false };
jest.mock('api/generated/services/rules', () => ({
useCreateRule: (): unknown => mutation,
useTestRule: (): unknown => mutation,
useUpdateRuleByID: (): unknown => mutation,
}));
function Probe(): JSX.Element {
const { thresholdState, evaluationWindow } = useCreateAlertState();
return (
<div>
<span data-testid="match-type">{thresholdState.matchType}</span>
<span data-testid="operator">{thresholdState.operator}</span>
<span data-testid="threshold-count">{thresholdState.thresholds.length}</span>
<span data-testid="threshold-value">
{thresholdState.thresholds[0]?.thresholdValue}
</span>
<span data-testid="window-type">{evaluationWindow.windowType}</span>
</div>
);
}
function renderWithSearch(search: string): void {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<Probe />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
);
}
function serializeThreshold(thresholdValue: number): string {
return encodeURIComponent(
JSON.stringify([
{
id: 't1',
label: 'critical',
thresholdValue,
recoveryThresholdValue: null,
unit: 'bytes',
channels: [],
color: '#F1575F',
},
]),
);
}
describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
it('applies matchType, operator, and thresholds without the meter window', () => {
// Dashboard-style prefill: no evaluationWindowPreset → default window.
renderWithSearch(
`?matchType=on_average&compareOp=below&thresholds=${serializeThreshold(90)}`,
);
expect(screen.getByTestId('match-type')).toHaveTextContent('on_average');
expect(screen.getByTestId('operator')).toHaveTextContent('below');
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
expect(screen.getByTestId('threshold-value')).toHaveTextContent('90');
// The meter evaluation-window preset must NOT fire.
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
});
it('adjusts only the occurrence type when no thresholds are supplied', () => {
renderWithSearch(`?matchType=in_total`);
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
// The default single critical threshold and operator are retained.
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
expect(screen.getByTestId('threshold-value')).toHaveTextContent('0');
expect(screen.getByTestId('operator')).toHaveTextContent('above');
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
});
it('applies the meter evaluation window when the preset is declared', () => {
// Ingestion-style prefill: explicit in_total + meter preset.
renderWithSearch(
`?matchType=in_total&evaluationWindowPreset=meter&thresholds=${serializeThreshold(
500,
)}`,
);
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
expect(screen.getByTestId('threshold-value')).toHaveTextContent('500');
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
});
});

View File

@@ -0,0 +1,71 @@
import { AlertThresholdMatchType, AlertThresholdOperator } from '../types';
import {
EvaluationWindowPreset,
resolveUrlAlertPrefill,
} from '../resolveUrlAlertPrefill';
function resolve(search: string): ReturnType<typeof resolveUrlAlertPrefill> {
return resolveUrlAlertPrefill(new URLSearchParams(search));
}
describe('resolveUrlAlertPrefill', () => {
it('returns an empty plan when no params are present', () => {
expect(resolve('')).toStrictEqual({
thresholds: undefined,
matchType: undefined,
operator: undefined,
evaluationWindowPreset: undefined,
});
});
it('parses a thresholds array', () => {
const thresholds = [{ id: 't1', thresholdValue: 90 }];
const { thresholds: parsed } = resolve(
`thresholds=${encodeURIComponent(JSON.stringify(thresholds))}`,
);
expect(parsed).toStrictEqual(thresholds);
});
it('ignores malformed or non-array thresholds without throwing', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(resolve('thresholds=not-json').thresholds).toBeUndefined();
expect(
resolve(`thresholds=${encodeURIComponent('{"a":1}')}`).thresholds,
).toBeUndefined();
consoleError.mockRestore();
});
it('normalizes matchType aliases', () => {
expect(resolve('matchType=on_average').matchType).toBe(
AlertThresholdMatchType.ON_AVERAGE,
);
expect(resolve('matchType=sum').matchType).toBe(
AlertThresholdMatchType.IN_TOTAL,
);
expect(resolve('matchType=nonsense').matchType).toBeUndefined();
});
it('normalizes operator aliases', () => {
expect(resolve('compareOp=above').operator).toBe(
AlertThresholdOperator.IS_ABOVE,
);
expect(resolve('compareOp=%3E').operator).toBe(
AlertThresholdOperator.IS_ABOVE,
);
expect(resolve('compareOp=nonsense').operator).toBeUndefined();
});
it('recognizes the meter evaluation-window preset only', () => {
expect(resolve('evaluationWindowPreset=meter').evaluationWindowPreset).toBe(
EvaluationWindowPreset.METER,
);
expect(
resolve('evaluationWindowPreset=rolling').evaluationWindowPreset,
).toBeUndefined();
expect(resolve('').evaluationWindowPreset).toBeUndefined();
});
});

View File

@@ -0,0 +1,63 @@
import { AlertThresholdMatchType, AlertThresholdOperator } from './types';
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
switch (raw) {
case '1':
case 'above':
case '>':
return AlertThresholdOperator.IS_ABOVE;
case '2':
case 'below':
case '<':
return AlertThresholdOperator.IS_BELOW;
case '3':
case 'equal':
case 'eq':
case '=':
return AlertThresholdOperator.IS_EQUAL_TO;
case '4':
case 'not_equal':
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;
default:
return undefined;
}
}
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
export function normalizeMatchType(
raw: string | undefined,
): AlertThresholdMatchType | undefined {
switch (raw) {
case '1':
case 'at_least_once':
return AlertThresholdMatchType.AT_LEAST_ONCE;
case '2':
case 'all_the_times':
return AlertThresholdMatchType.ALL_THE_TIME;
case '3':
case 'on_average':
case 'avg':
return AlertThresholdMatchType.ON_AVERAGE;
case '4':
case 'in_total':
case 'sum':
return AlertThresholdMatchType.IN_TOTAL;
case '5':
case 'last':
return AlertThresholdMatchType.LAST;
default:
return undefined;
}
}

View File

@@ -23,10 +23,13 @@ import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/map
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { INITIAL_CREATE_ALERT_STATE } from './constants';
import {
EvaluationWindowPreset,
resolveUrlAlertPrefill,
} from './resolveUrlAlertPrefill';
import {
AdvancedOptionsAction,
AlertThresholdAction,
AlertThresholdMatchType,
CreateAlertAction,
CreateAlertSlice,
EvaluationWindowAction,
@@ -123,9 +126,13 @@ export function CreateAlertProvider(
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
// Prefill declared in the URL; applied verbatim, agnostic of the producer.
const urlPrefill = useMemo(
() => resolveUrlAlertPrefill(new URLSearchParams(location.search)),
[location.search],
);
const [alertType, setAlertType] = useState<AlertTypes>(() => {
if (isEditMode) {
@@ -168,32 +175,41 @@ export function CreateAlertProvider(
},
});
if (thresholdsFromURL) {
try {
const thresholds = JSON.parse(thresholdsFromURL);
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_THRESHOLDS',
payload: thresholds,
},
});
} catch (error) {
console.error('Error parsing thresholds from URL:', error);
}
if (urlPrefill.thresholds) {
setCreateAlertState({
slice: CreateAlertSlice.EVALUATION_WINDOW,
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_INITIAL_STATE_FOR_METER',
type: 'SET_THRESHOLDS',
payload: urlPrefill.thresholds,
},
});
}
if (urlPrefill.matchType) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
payload: urlPrefill.matchType,
},
});
}
if (urlPrefill.operator) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_OPERATOR',
payload: urlPrefill.operator,
},
});
}
if (urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER) {
setCreateAlertState({
slice: CreateAlertSlice.EVALUATION_WINDOW,
action: {
type: 'SET_INITIAL_STATE_FOR_METER',
},
});
}
@@ -219,7 +235,7 @@ export function CreateAlertProvider(
},
});
}
}, [alertType, thresholdsFromURL, ruleNameFromURL, yAxisUnitFromURL]);
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL]);
useEffect(() => {
if (isEditMode && initialAlertState) {

View File

@@ -0,0 +1,54 @@
import { QueryParams } from 'constants/query';
import { normalizeMatchType, normalizeOperator } from './conditionNormalizers';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
Threshold,
} from './types';
export enum EvaluationWindowPreset {
METER = 'meter',
}
export interface ResolvedAlertPrefill {
thresholds?: Threshold[];
matchType?: AlertThresholdMatchType;
operator?: AlertThresholdOperator;
evaluationWindowPreset?: EvaluationWindowPreset;
}
function parseThresholds(raw: string | null): Threshold[] | undefined {
if (!raw) {
return undefined;
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Threshold[]) : undefined;
} catch (error) {
console.error('Error parsing thresholds from URL:', error);
return undefined;
}
}
function parseEvaluationWindowPreset(
raw: string | null,
): EvaluationWindowPreset | undefined {
return raw === EvaluationWindowPreset.METER
? EvaluationWindowPreset.METER
: undefined;
}
/** URL → declarative prefill plan; the consumer applies it without knowing the producer. */
export function resolveUrlAlertPrefill(
params: URLSearchParams,
): ResolvedAlertPrefill {
return {
thresholds: parseThresholds(params.get(QueryParams.thresholds)),
matchType: normalizeMatchType(params.get(QueryParams.matchType) ?? undefined),
operator: normalizeOperator(params.get(QueryParams.compareOp) ?? undefined),
evaluationWindowPreset: parseEvaluationWindowPreset(
params.get(QueryParams.evaluationWindowPreset),
),
};
}

View File

@@ -238,67 +238,12 @@ export function getAdvancedOptionsStateFromAlertDef(
};
}
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
switch (raw) {
case '1':
case 'above':
case '>':
return AlertThresholdOperator.IS_ABOVE;
case '2':
case 'below':
case '<':
return AlertThresholdOperator.IS_BELOW;
case '3':
case 'equal':
case 'eq':
case '=':
return AlertThresholdOperator.IS_EQUAL_TO;
case '4':
case 'not_equal':
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;
default:
return undefined;
}
}
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
export function normalizeMatchType(
raw: string | undefined,
): AlertThresholdMatchType | undefined {
switch (raw) {
case '1':
case 'at_least_once':
return AlertThresholdMatchType.AT_LEAST_ONCE;
case '2':
case 'all_the_times':
return AlertThresholdMatchType.ALL_THE_TIME;
case '3':
case 'on_average':
case 'avg':
return AlertThresholdMatchType.ON_AVERAGE;
case '4':
case 'in_total':
case 'sum':
return AlertThresholdMatchType.IN_TOTAL;
case '5':
case 'last':
return AlertThresholdMatchType.LAST;
default:
return undefined;
}
}
// Condition-alias normalizers live in a leaf module (they depend only on the
// enums) so `context/index` can consume them without an index↔utils cycle.
export {
normalizeOperator,
normalizeMatchType,
} from './context/conditionNormalizers';
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,

View File

@@ -48,6 +48,8 @@ import { QueryParams } from 'constants/query';
import { initialQueryMeterWithType } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
import { EvaluationWindowPreset } from 'container/CreateAlertV2/context/resolveUrlAlertPrefill';
import { AlertThresholdMatchType } from 'container/CreateAlertV2/context/types';
import dayjs from 'dayjs';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
@@ -907,6 +909,7 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
// Declare the metering prefill explicitly: "in total" + the meter window.
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
@@ -915,7 +918,9 @@ function MultiIngestionSettings(): JSX.Element {
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
}=${encodeURIComponent(yAxisUnit)}&${QueryParams.matchType}=${
AlertThresholdMatchType.IN_TOTAL
}&${QueryParams.evaluationWindowPreset}=${EvaluationWindowPreset.METER}`;
history.push(URL);
};

View File

@@ -37,12 +37,22 @@ jest.mock('api/generated/services/querier', () => ({
}));
// Stub the builders so this asserts only the hook's orchestration.
const mockBuildAlertUrl = jest.fn(
(..._args: unknown[]) => '/alerts/new?composite=substituted',
);
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=sync',
buildAlertUrl: (): string => '/alerts/new?composite=substituted',
buildAlertUrl: (...args: unknown[]): string => mockBuildAlertUrl(...args),
readPanelUnit: (): string | undefined => undefined,
}));
// Prefill derivation has its own coverage; return a sentinel so the hook test can
// assert it is threaded into the resolved-alert URL.
const mockPrefill = { matchType: 'in_total' };
jest.mock('../../utils/deriveAlertPrefill', () => ({
deriveAlertPrefill: (): unknown => mockPrefill,
}));
// Keep the real exports (getPanelQueryType reads them); stub only the builder.
const mockBuildQueryRangeRequest = jest.fn((_args?: unknown) => ({
request: 'payload',
@@ -155,6 +165,13 @@ describe('useCreateAlertFromPanel', () => {
const { onSuccess } = mockSubstituteVars.mock.calls[0][1];
onSuccess({ data: { compositeQuery: { queries: [{ type: 'builder' }] } } });
// The resolved query is seeded with the panel-derived alert prefill.
expect(mockBuildAlertUrl).toHaveBeenCalledWith(
{ resolved: 'query' },
PANEL_TYPES.TIME_SERIES,
undefined,
mockPrefill,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/alerts/new?composite=substituted',
{ newTab: true },

View File

@@ -16,6 +16,7 @@ import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/stor
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { deriveAlertPrefill } from '../utils/deriveAlertPrefill';
import {
buildAlertUrl,
buildCreateAlertUrl,
@@ -75,10 +76,12 @@ export function useCreateAlertFromPanel(): (
response.data.compositeQuery?.queries ?? [],
panelType,
);
const unit = readPanelUnit(panel.spec.plugin);
const url = buildAlertUrl(
query,
panelType,
readPanelUnit(panel.spec.plugin),
unit,
deriveAlertPrefill(panel, query, unit),
);
safeNavigate(url, { newTab: true });
},

View File

@@ -100,4 +100,47 @@ describe('buildCreateAlertUrl', () => {
);
expect(decoded.unit).toBeUndefined();
});
it('omits the alert-condition params when the panel has no reduceTo or thresholds', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.thresholds)).toBeNull();
expect(params.get(QueryParams.matchType)).toBeNull();
expect(params.get(QueryParams.compareOp)).toBeNull();
});
it('seeds the alert condition from the panel Reduce To + highest-danger threshold', () => {
mockFromPerses.mockReturnValue({
...translatedQuery,
builder: {
queryData: [{ aggregations: [{ reduceTo: 'sum' }] }],
queryFormulas: [],
queryTraceOperator: [],
},
});
const numberPanel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/NumberPanel',
spec: {
thresholds: [{ color: '#F1575F', value: 90, operator: 'above' }],
},
},
queries: [{ some: 'query' }],
},
} as unknown as DashboardtypesPanelDTO;
const params = parse(buildCreateAlertUrl(numberPanel));
expect(params.get(QueryParams.matchType)).toBe('in_total');
expect(params.get(QueryParams.compareOp)).toBe('above');
// The alert page parses this param directly (no decodeURIComponent), so it
// must be single-encoded — reading it the same way guards against
// double-encoding regressions.
const thresholds = JSON.parse(params.get(QueryParams.thresholds) as string);
expect(thresholds).toHaveLength(1);
expect(thresholds[0].thresholdValue).toBe(90);
});
});

View File

@@ -0,0 +1,226 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
import { deriveAlertPrefill } from '../deriveAlertPrefill';
const RED = '#F1575F';
const ORANGE = '#F5B225';
const GREEN = '#2BB673';
/** Query with one metric builder query per supplied reduceTo (on the V5 aggregation). */
function makeQuery(...reduceTos: (ReduceOperators | undefined)[]): Query {
return {
builder: {
queryData: reduceTos.map((reduceTo) => ({
aggregations: [{ reduceTo }],
})),
queryFormulas: [],
},
} as unknown as Query;
}
/** Query carrying reduceTo on the legacy V1 field instead of the aggregation. */
function makeLegacyQuery(reduceTo: ReduceOperators): Query {
return {
builder: {
queryData: [{ reduceTo }],
queryFormulas: [],
},
} as unknown as Query;
}
type ComparisonThreshold = {
color: string;
value: number;
unit?: string;
operator?: string;
};
type LabelThreshold = { color: string; value: number; unit?: string };
function makeNumberPanel(
thresholds: ComparisonThreshold[],
): DashboardtypesPanelDTO {
return {
spec: { plugin: { kind: 'signoz/NumberPanel', spec: { thresholds } } },
} as unknown as DashboardtypesPanelDTO;
}
function makeTimeSeriesPanel(
thresholds: LabelThreshold[],
): DashboardtypesPanelDTO {
return {
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: { thresholds } } },
} as unknown as DashboardtypesPanelDTO;
}
const emptyNumberPanel = makeNumberPanel([]);
describe('deriveAlertPrefill', () => {
describe('Reduce To → matchType', () => {
it('maps sum → in total', () => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeQuery(ReduceOperators.SUM))
.matchType,
).toBe(AlertThresholdMatchType.IN_TOTAL);
});
it('maps avg → on average', () => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeQuery(ReduceOperators.AVG))
.matchType,
).toBe(AlertThresholdMatchType.ON_AVERAGE);
});
it.each([ReduceOperators.MAX, ReduceOperators.MIN, ReduceOperators.LAST])(
'leaves matchType undefined for %s (no occurrence-type equivalent per issue #5291)',
(reduceTo) => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeQuery(reduceTo)).matchType,
).toBeUndefined();
},
);
it('ignores reduceTo on non-Value panels (only the Value panel drives occurrence)', () => {
const { matchType } = deriveAlertPrefill(
makeTimeSeriesPanel([{ color: RED, value: 100 }]),
makeQuery(ReduceOperators.AVG),
);
expect(matchType).toBeUndefined();
});
it('reads reduceTo from the legacy V1 field too', () => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeLegacyQuery(ReduceOperators.SUM))
.matchType,
).toBe(AlertThresholdMatchType.IN_TOTAL);
});
it('falls back to the legacy field when the aggregation reduceTo is empty', () => {
const query = {
builder: {
queryData: [
{ aggregations: [{ reduceTo: '' }], reduceTo: ReduceOperators.AVG },
],
queryFormulas: [],
},
} as unknown as Query;
expect(deriveAlertPrefill(emptyNumberPanel, query).matchType).toBe(
AlertThresholdMatchType.ON_AVERAGE,
);
});
});
describe('formula panels (multiple builder queries)', () => {
it('applies the reduce value when every query agrees', () => {
expect(
deriveAlertPrefill(
emptyNumberPanel,
makeQuery(ReduceOperators.AVG, ReduceOperators.AVG),
).matchType,
).toBe(AlertThresholdMatchType.ON_AVERAGE);
});
it('keeps the default when queries disagree', () => {
expect(
deriveAlertPrefill(
emptyNumberPanel,
makeQuery(ReduceOperators.AVG, ReduceOperators.SUM),
).matchType,
).toBeUndefined();
});
});
describe('thresholds → operator + target + unit', () => {
it('picks the highest-danger threshold (red over orange over green)', () => {
const { threshold, operator } = deriveAlertPrefill(
makeNumberPanel([
{ color: GREEN, value: 10, operator: 'below' },
{ color: RED, value: 90, operator: 'above' },
{ color: ORANGE, value: 80, operator: 'above' },
]),
makeQuery(),
);
expect(threshold?.thresholdValue).toBe(90);
expect(threshold?.color).toBe(RED);
expect(operator).toBe(AlertThresholdOperator.IS_ABOVE);
});
it('sorts unknown/custom colors last', () => {
const { threshold } = deriveAlertPrefill(
makeNumberPanel([
{ color: '#123456', value: 5, operator: 'above' },
{ color: ORANGE, value: 80, operator: 'above' },
]),
makeQuery(),
);
expect(threshold?.thresholdValue).toBe(80);
});
it.each([
['above', AlertThresholdOperator.IS_ABOVE],
['above_or_equal', AlertThresholdOperator.IS_ABOVE],
['below', AlertThresholdOperator.IS_BELOW],
['below_or_equal', AlertThresholdOperator.IS_BELOW],
['equal', AlertThresholdOperator.IS_EQUAL_TO],
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
])('maps panel operator %s → %s', (op, expected) => {
const { operator } = deriveAlertPrefill(
makeNumberPanel([{ color: RED, value: 1, operator: op }]),
makeQuery(),
);
expect(operator).toBe(expected);
});
it('builds an alert-shaped critical threshold', () => {
const { threshold } = deriveAlertPrefill(
makeNumberPanel([{ color: RED, value: 90, unit: 'ms', operator: 'above' }]),
makeQuery(),
);
expect(threshold).toMatchObject({
label: 'critical',
thresholdValue: 90,
recoveryThresholdValue: null,
unit: 'ms',
channels: [],
color: RED,
});
expect(typeof threshold?.id).toBe('string');
});
it('falls back to the panel unit when the threshold has none', () => {
const { threshold } = deriveAlertPrefill(
makeNumberPanel([{ color: RED, value: 90, operator: 'above' }]),
makeQuery(),
'bytes',
);
expect(threshold?.unit).toBe('bytes');
});
});
describe('label-variant thresholds (TimeSeries/Bar)', () => {
it('seeds the target but leaves operator/matchType default (no operator, no reduceTo)', () => {
const { threshold, operator, matchType } = deriveAlertPrefill(
makeTimeSeriesPanel([{ color: RED, value: 100 }]),
makeQuery(),
);
expect(threshold?.thresholdValue).toBe(100);
expect(operator).toBeUndefined();
expect(matchType).toBeUndefined();
});
});
it('returns an empty prefill for a panel with neither reduceTo nor thresholds', () => {
expect(deriveAlertPrefill(emptyNumberPanel, makeQuery())).toStrictEqual({});
});
});

View File

@@ -11,6 +11,8 @@ import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContain
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { deriveAlertPrefill, PanelAlertPrefill } from './deriveAlertPrefill';
/** The panel's configured y-axis unit, for the kinds that carry one. */
export function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
@@ -27,14 +29,14 @@ export function readPanelUnit(
}
/**
* Assembles the `/alerts/new` URL from a ready V1 `Query`: the alert page reads it
* from `compositeQuery`, tagged with the panel type, entity version, and a
* `dashboards` source.
* Assembles the `/alerts/new` URL from a ready V1 `Query`. An optional `prefill`
* (issue #5291) also seeds the alert condition — occurrence, operator, threshold.
*/
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
prefill?: PanelAlertPrefill,
): string {
if (unit) {
// eslint-disable-next-line no-param-reassign
@@ -50,6 +52,17 @@ export function buildAlertUrl(
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
if (prefill?.threshold) {
// Raw JSON: URLSearchParams encodes once; the alert page uses a bare JSON.parse.
params.set(QueryParams.thresholds, JSON.stringify([prefill.threshold]));
}
if (prefill?.matchType) {
params.set(QueryParams.matchType, prefill.matchType);
}
if (prefill?.operator) {
params.set(QueryParams.compareOp, prefill.operator);
}
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
}
@@ -62,5 +75,11 @@ export function 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));
const unit = readPanelUnit(panel.spec.plugin);
return buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);
}

View File

@@ -0,0 +1,158 @@
/** Derives an alert-condition seed from a panel's Reduce To + Thresholds (issue #5291). */
import type {
DashboardtypesComparisonOperatorDTO,
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
Threshold,
} from 'container/CreateAlertV2/context/types';
import type { MetricAggregation } from 'types/api/v5/queryRange';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export interface PanelAlertPrefill {
matchType?: AlertThresholdMatchType;
operator?: AlertThresholdOperator;
threshold?: Threshold;
}
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
const THRESHOLD_COLOR_DANGER_ORDER = [
'#f1575f',
'#f5b225',
'#2bb673',
'#4e74f8',
];
interface NormalizedPanelThreshold {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
}
export function reduceToMatchType(
reduceTo: ReduceOperators | undefined,
): AlertThresholdMatchType | undefined {
switch (reduceTo) {
case ReduceOperators.SUM:
return AlertThresholdMatchType.IN_TOTAL;
case ReduceOperators.AVG:
return AlertThresholdMatchType.ON_AVERAGE;
default:
return undefined;
}
}
function readReduceTo(
queryData: Query['builder']['queryData'][number],
): ReduceOperators | undefined {
const aggregationReduceTo = (
queryData.aggregations?.[0] as MetricAggregation | undefined
)?.reduceTo;
// `||` not `??`: the aggregation often holds an empty-string placeholder.
return aggregationReduceTo || queryData.reduceTo || undefined;
}
export function uniformReduceTo(query: Query): ReduceOperators | undefined {
const { queryData } = query.builder;
const first = queryData[0] && readReduceTo(queryData[0]);
if (!first) {
return undefined;
}
return queryData.every((data) => readReduceTo(data) === first)
? first
: undefined;
}
function readPanelThresholds(
plugin: DashboardtypesPanelPluginDTO,
): NormalizedPanelThreshold[] {
switch (plugin.kind) {
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
return (plugin.spec.thresholds ?? []).map((t) => ({
color: t.color,
value: t.value,
unit: t.unit,
}));
case 'signoz/NumberPanel':
return (plugin.spec.thresholds ?? []).map((t) => ({
color: t.color,
value: t.value,
unit: t.unit,
operator: t.operator,
}));
default:
return [];
}
}
function colorRank(color: string): number {
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
}
function pickHighestDanger(
thresholds: NormalizedPanelThreshold[],
): NormalizedPanelThreshold | undefined {
return [...thresholds].sort(
(a, b) => colorRank(a.color) - colorRank(b.color),
)[0];
}
function panelOperatorToAlertOperator(
operator: DashboardtypesComparisonOperatorDTO | undefined,
): AlertThresholdOperator | undefined {
switch (operator) {
case 'above':
case 'above_or_equal':
return AlertThresholdOperator.IS_ABOVE;
case 'below':
case 'below_or_equal':
return AlertThresholdOperator.IS_BELOW;
case 'equal':
return AlertThresholdOperator.IS_EQUAL_TO;
case 'not_equal':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
default:
return undefined;
}
}
export function deriveAlertPrefill(
panel: DashboardtypesPanelDTO,
query: Query,
panelUnit?: string,
): PanelAlertPrefill {
const prefill: PanelAlertPrefill = {};
// Reduce To is user-facing only on the Value panel; elsewhere it's a default.
if (panel.spec.plugin.kind === 'signoz/NumberPanel') {
const matchType = reduceToMatchType(uniformReduceTo(query));
if (matchType) {
prefill.matchType = matchType;
}
}
const top = pickHighestDanger(readPanelThresholds(panel.spec.plugin));
if (top) {
prefill.operator = panelOperatorToAlertOperator(top.operator);
prefill.threshold = {
id: uuid(),
label: 'critical',
thresholdValue: top.value,
recoveryThresholdValue: null,
unit: top.unit ?? panelUnit ?? '',
channels: [],
color: top.color,
};
}
return prefill;
}

View File

@@ -76,9 +76,9 @@ type PostableRule struct {
}
type NotificationSettings struct {
GroupBy []string `json:"groupBy,omitempty"`
GroupBy []string `json:"groupBy,omitzero"`
Renotify *Renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy,omitempty"`
UsePolicy bool `json:"usePolicy"`
// NewGroupEvalDelay is the grace period for new series to be excluded from alerts evaluation
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero"`
}
@@ -86,7 +86,7 @@ type NotificationSettings struct {
type Renotify struct {
Enabled bool `json:"enabled"`
ReNotifyInterval valuer.TextDuration `json:"interval,omitzero"`
AlertStates []AlertState `json:"alertStates,omitempty"`
AlertStates []AlertState `json:"alertStates,omitzero"`
}
func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanagertypes.NotificationConfig {

View File

@@ -153,9 +153,10 @@ func TestV2MinimalReadShape(t *testing.T) {
var ns map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["notificationSettings"], &ns))
for _, field := range []string{"renotify", "groupBy", "newGroupEvalDelay", "usePolicy"} {
for _, field := range []string{"renotify", "groupBy", "newGroupEvalDelay"} {
assert.NotContains(t, ns, field, "notificationSettings.%s", field)
}
assert.JSONEq(t, `false`, string(ns["usePolicy"]))
assert.JSONEq(t, `false`, string(body["disabled"]))
assert.JSONEq(t, `"v5"`, string(body["version"]))
@@ -205,6 +206,11 @@ func TestRenotifyRoundTrip(t *testing.T) {
settings: `{"renotify": {"enabled": false}}`,
wantRenotify: `{"enabled": false}`,
},
{
name: "explicitly empty alert states are echoed",
settings: `{"renotify": {"enabled": true, "alertStates": []}}`,
wantRenotify: `{"enabled": true, "alertStates": []}`,
},
{
name: "enabled renotify with states is echoed",
settings: `{"renotify": {"enabled": true, "interval": "30m", "alertStates": ["firing"]}}`,
@@ -226,6 +232,59 @@ func TestRenotifyRoundTrip(t *testing.T) {
}
}
func TestGroupByRoundTrip(t *testing.T) {
base := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": %s
}`
cases := []struct {
name string
settings string
wantGroupBy string
}{
{
name: "unset group by stays absent",
settings: `{"usePolicy": false}`,
},
{
name: "explicitly empty group by is echoed",
settings: `{"groupBy": []}`,
wantGroupBy: `[]`,
},
{
name: "populated group by is echoed",
settings: `{"groupBy": ["service.name", "deployment.environment"]}`,
wantGroupBy: `["service.name", "deployment.environment"]`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := readBody(t, strings.Replace(base, "%s", tc.settings, 1))
var ns map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body["notificationSettings"], &ns))
if tc.wantGroupBy == "" {
assert.NotContains(t, ns, "groupBy")
} else {
assert.JSONEq(t, tc.wantGroupBy, string(ns["groupBy"]))
}
})
}
}
func TestPatchMergePreservesUnpatchedFields(t *testing.T) {
stored := `{
"alert": "cpu high",