mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-17 03:40:31 +01:00
Compare commits
2 Commits
fix/query-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bcc2d66d9 | ||
|
|
5ee1ca7d05 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -57,4 +57,7 @@ export enum QueryParams {
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
matchType = 'matchType',
|
||||
compareOp = 'compareOp',
|
||||
evaluationWindowPreset = 'evaluationWindowPreset',
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user