mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-07 17:00:41 +00:00
Compare commits
41 Commits
fix/genera
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc60764059 | ||
|
|
de75470c0d | ||
|
|
602d959c6d | ||
|
|
6ab1ed87f4 | ||
|
|
98a7c6f851 | ||
|
|
0c6c9fccf6 | ||
|
|
16b057cded | ||
|
|
f5e9786f91 | ||
|
|
1b2a5e54f2 | ||
|
|
94113eeed5 | ||
|
|
680f7de69e | ||
|
|
2237dbde9a | ||
|
|
aac321de58 | ||
|
|
7a7c35eca0 | ||
|
|
41d81cfcbc | ||
|
|
00afd873e3 | ||
|
|
428acb6f23 | ||
|
|
736ad2aba3 | ||
|
|
99f21adadd | ||
|
|
bdfadbbe87 | ||
|
|
e23f96c7a1 | ||
|
|
3e1d76f3c6 | ||
|
|
5b91346a27 | ||
|
|
7525667f28 | ||
|
|
1e4c96907d | ||
|
|
55e76a182d | ||
|
|
9135807532 | ||
|
|
a5f5dc4fb6 | ||
|
|
872b4172d3 | ||
|
|
87b1eed20a | ||
|
|
f6cfcdfc78 | ||
|
|
37cf5ae407 | ||
|
|
9a8f66be63 | ||
|
|
3a5078e29b | ||
|
|
7ebb1910b9 | ||
|
|
1e1d6f8d24 | ||
|
|
6f6eea7187 | ||
|
|
0bec6c5494 | ||
|
|
d6744c1d8e | ||
|
|
6476be26bb | ||
|
|
06c54c6483 |
@@ -11576,20 +11576,10 @@ components:
|
||||
description: Whether the rule is paused. A disabled rule is neither evaluated
|
||||
nor notifies. Defaults to false.
|
||||
type: boolean
|
||||
evalWindow:
|
||||
description: The lookback window as a Go duration such as 5m. Only read
|
||||
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
|
||||
instead.
|
||||
type: string
|
||||
evaluation:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
description: The evaluation window, as a kind and spec.
|
||||
frequency:
|
||||
description: How often the rule is evaluated, as a Go duration such as 1m.
|
||||
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
|
||||
it in evaluation.spec instead.
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -11601,13 +11591,6 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
preferredChannels:
|
||||
description: The displayName of each notification channel to notify, for
|
||||
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
|
||||
its own channels.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
@@ -11717,20 +11700,10 @@ components:
|
||||
description: Whether the rule is paused. A disabled rule is neither evaluated
|
||||
nor notifies. Defaults to false.
|
||||
type: boolean
|
||||
evalWindow:
|
||||
description: The lookback window as a Go duration such as 5m. Only read
|
||||
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
|
||||
instead.
|
||||
type: string
|
||||
evaluation:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
description: The evaluation window, as a kind and spec.
|
||||
frequency:
|
||||
description: How often the rule is evaluated, as a Go duration such as 1m.
|
||||
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
|
||||
it in evaluation.spec instead.
|
||||
type: string
|
||||
id:
|
||||
description: The unique identifier of the rule.
|
||||
type: string
|
||||
@@ -11745,13 +11718,6 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
preferredChannels:
|
||||
description: The displayName of each notification channel to notify, for
|
||||
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
|
||||
its own channels.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
@@ -11813,20 +11779,6 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesAlertCompositeQuery'
|
||||
description: The queries whose result is compared against the thresholds.
|
||||
matchType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesMatchType'
|
||||
description: 'schemaVersion v1 only: which points in the window must satisfy
|
||||
op. Accepted as at_least_once, all_the_times, on_average, in_total or
|
||||
last, their aliases avg and sum, or the legacy codes as strings, ''1''
|
||||
to ''5''. Required for v1; v2alpha1 sets matchType on each threshold.'
|
||||
op:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesCompareOperator'
|
||||
description: 'schemaVersion v1 only: how the value is compared with target.
|
||||
Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal
|
||||
or outside_bounds, their symbols, or the legacy codes as strings, ''1''
|
||||
to ''7''. Required for v1; v2alpha1 sets op on each threshold.'
|
||||
requireMinPoints:
|
||||
description: Whether a series is skipped until it has at least requiredNumPoints
|
||||
points in the window. Defaults to false.
|
||||
@@ -11844,17 +11796,6 @@ components:
|
||||
description: The name of the query whose result is evaluated, such as A
|
||||
or F1. Must match a query in compositeQuery.
|
||||
type: string
|
||||
target:
|
||||
description: 'schemaVersion v1 only: the value compared against, in targetUnit.
|
||||
Required for v1; v2alpha1 sets target on each threshold.'
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
description: 'schemaVersion v1 only: the unit of target, such as percent
|
||||
or ms. Converted to compositeQuery.unit before comparison when the two
|
||||
differ.'
|
||||
type: string
|
||||
thresholds:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleThresholdData'
|
||||
@@ -25342,56 +25283,6 @@ paths:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules:
|
||||
get:
|
||||
deprecated: true
|
||||
description: 'This endpoint lists all alert rules with their current evaluation
|
||||
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
|
||||
pagination.'
|
||||
operationId: ListRules
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesRule'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List alert rules
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new alert rule
|
||||
|
||||
@@ -34,8 +34,7 @@ type AnomalyRule struct {
|
||||
|
||||
provider anomaly.Provider
|
||||
|
||||
version string
|
||||
logger *slog.Logger
|
||||
logger *slog.Logger
|
||||
|
||||
seasonality anomaly.Seasonality
|
||||
}
|
||||
@@ -63,7 +62,6 @@ func NewAnomalyRule(
|
||||
r := AnomalyRule{
|
||||
BaseRule: baseRule,
|
||||
querier: querier,
|
||||
version: p.Version,
|
||||
logger: logger.With(slog.String("rule.id", id)),
|
||||
}
|
||||
|
||||
@@ -105,15 +103,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", end-start),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
@@ -420,12 +418,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
func (r *AnomalyRule) String() string {
|
||||
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
PreferredChannels: r.PreferredChannels(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -54,9 +54,6 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
AlertOnAbsent: true,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
|
||||
@@ -53,7 +53,6 @@ export enum QueryParams {
|
||||
variables = 'variables',
|
||||
version = 'version',
|
||||
source = 'source',
|
||||
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
|
||||
@@ -26,7 +26,6 @@ export const REACT_QUERY_KEY = {
|
||||
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
|
||||
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
|
||||
GET_HOST_LIST: 'GET_HOST_LIST',
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { act, fireEvent, render } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
jest
|
||||
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
|
||||
.mockReturnValue({
|
||||
matchType: '3',
|
||||
op: '1',
|
||||
target: 100,
|
||||
targetUnit: 'rpm',
|
||||
});
|
||||
|
||||
let mockWindowOpen: jest.Mock;
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
function findLinkForAlertType(
|
||||
links: HTMLElement[],
|
||||
alertType: AlertTypes,
|
||||
): HTMLElement {
|
||||
const link = links.find(
|
||||
(el) =>
|
||||
el.closest('[data-testid]')?.getAttribute('data-testid') ===
|
||||
`alert-type-card-${alertType}`,
|
||||
);
|
||||
expect(link).toBeTruthy();
|
||||
return link as HTMLElement;
|
||||
}
|
||||
|
||||
function clickLinkAndVerifyRedirect(
|
||||
link: HTMLElement,
|
||||
expectedUrl: string,
|
||||
): void {
|
||||
fireEvent.click(link);
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(expectedUrl, '_blank');
|
||||
}
|
||||
describe('Alert rule documentation redirection', () => {
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
|
||||
beforeAll(() => {
|
||||
mockWindowOpen = jest.fn();
|
||||
window.open = mockWindowOpen;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
renderResult = render(
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: ROUTES.ALERTS_NEW,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render alert type cards', () => {
|
||||
const { getByText, getAllByText } = renderResult;
|
||||
|
||||
// Check for the heading
|
||||
expect(getByText('choose_alert_type')).toBeInTheDocument();
|
||||
|
||||
// Check for alert type titles and descriptions
|
||||
Object.values(AlertTypes).forEach((alertType) => {
|
||||
const title = ALERT_TYPE_TO_TITLE[alertType];
|
||||
expect(getByText(title)).toBeInTheDocument();
|
||||
expect(getByText(`${title}_desc`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const clickHereLinks = getAllByText(
|
||||
'Click here to see how to create a sample alert.',
|
||||
);
|
||||
|
||||
expect(clickHereLinks).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should redirect to correct documentation for each alert type', () => {
|
||||
const { getAllByText } = renderResult;
|
||||
|
||||
const clickHereLinks = getAllByText(
|
||||
'Click here to see how to create a sample alert.',
|
||||
);
|
||||
const alertTypeCount = Object.keys(AlertTypes).length;
|
||||
|
||||
expect(clickHereLinks).toHaveLength(alertTypeCount);
|
||||
|
||||
Object.values(AlertTypes).forEach((alertType) => {
|
||||
const linkForAlertType = findLinkForAlertType(clickHereLinks, alertType);
|
||||
const expectedUrl = ALERT_TYPE_URL_MAP[alertType];
|
||||
|
||||
clickLinkAndVerifyRedirect(linkForAlertType, expectedUrl.selection);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(alertTypeCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create alert page redirection', () => {
|
||||
Object.values(AlertTypes)
|
||||
.filter((type) => type !== AlertTypes.ANOMALY_BASED_ALERT)
|
||||
.forEach((alertType) => {
|
||||
it(`should redirect to create alert page for ${alertType} and "Check an example alert" should redirect to the correct documentation`, () => {
|
||||
const { getByRole } = render(
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: `${ROUTES.ALERTS_NEW}?alertType=${alertType}&showClassicCreateAlertsPage=true`,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
getByRole('button', {
|
||||
name: /alert setup guide/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
ALERT_TYPE_URL_MAP[alertType].creation,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { act, fireEvent, render } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest
|
||||
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
|
||||
.mockReturnValue({
|
||||
matchType: '3',
|
||||
op: '1',
|
||||
target: 100,
|
||||
targetUnit: 'rpm',
|
||||
});
|
||||
|
||||
describe('Anomaly Alert Documentation Redirection', () => {
|
||||
let mockWindowOpen: jest.Mock;
|
||||
|
||||
beforeAll(() => {
|
||||
mockWindowOpen = jest.fn();
|
||||
window.open = mockWindowOpen;
|
||||
});
|
||||
|
||||
it('should handle anomaly alert documentation redirection correctly', () => {
|
||||
// The setup guide button only exists in the classic form.
|
||||
const { getByRole } = render(<CreateAlertPage />, undefined, {
|
||||
initialRoute: `${ROUTES.ALERTS_NEW}?ruleType=anomaly_rule&showClassicCreateAlertsPage=true`,
|
||||
});
|
||||
|
||||
const alertType = AlertTypes.ANOMALY_BASED_ALERT;
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
getByRole('button', {
|
||||
name: /alert setup guide/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
ALERT_TYPE_URL_MAP[alertType].creation,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,25 +23,6 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('container/FormAlertRules', () => ({
|
||||
__esModule: true,
|
||||
default: function MockFormAlertRules({
|
||||
alertType,
|
||||
}: {
|
||||
alertType: AlertTypes;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<h1>Form Alert Rules</h1>
|
||||
<p>{alertType}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
AlertDetectionTypes: {
|
||||
THRESHOLD_ALERT: 'threshold_rule',
|
||||
ANOMALY_DETECTION_ALERT: 'anomaly_rule',
|
||||
},
|
||||
}));
|
||||
jest.mock('container/CreateAlertV2', () => ({
|
||||
__esModule: true,
|
||||
default: function MockCreateAlertV2({
|
||||
@@ -76,7 +57,6 @@ const mockGetUrlQuery = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockDeleteUrlQuery = jest.fn();
|
||||
|
||||
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
|
||||
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
|
||||
|
||||
describe('CreateAlertRule', () => {
|
||||
@@ -95,20 +75,6 @@ describe('CreateAlertRule', () => {
|
||||
useAppContextSpy.mockReturnValue(getAppContextMockState());
|
||||
});
|
||||
|
||||
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'true';
|
||||
}
|
||||
if (key === QueryParams.alertType) {
|
||||
return AlertTypes.METRICS_BASED_ALERT;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render new flow when alertType is provided', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.alertType) {
|
||||
@@ -124,7 +90,6 @@ describe('CreateAlertRule', () => {
|
||||
mockGetUrlQuery.mockReturnValue(null);
|
||||
useCompositeQueryParamSpy.mockReturnValue(null);
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -149,9 +114,6 @@ describe('CreateAlertRule', () => {
|
||||
|
||||
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'false';
|
||||
}
|
||||
if (key === QueryParams.ruleType) {
|
||||
return 'anomaly_rule';
|
||||
}
|
||||
@@ -160,7 +122,6 @@ describe('CreateAlertRule', () => {
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
@@ -284,27 +245,5 @@ describe('CreateAlertRule', () => {
|
||||
);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'true';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
render(<CreateAlertRule />);
|
||||
fireEvent.click(screen.getByText('metric_based_alert'));
|
||||
|
||||
expect(mockSetUrlQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleType,
|
||||
'threshold_rule',
|
||||
);
|
||||
expect(mockSetUrlQuery).toHaveBeenCalledWith(
|
||||
QueryParams.alertType,
|
||||
AlertTypes.METRICS_BASED_ALERT,
|
||||
);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
AlertDef,
|
||||
@@ -63,7 +63,7 @@ export const alertDefaults: AlertDef = {
|
||||
export const anamolyAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -201,14 +201,6 @@ export const exceptionAlertDefaults: AlertDef = {
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: anamolyAlertDefaults,
|
||||
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
|
||||
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
|
||||
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
|
||||
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
|
||||
};
|
||||
|
||||
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
|
||||
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Form, Tabs, TabsProps } from 'antd';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import CreateAlertV2 from 'container/CreateAlertV2';
|
||||
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -16,25 +15,20 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
|
||||
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
|
||||
import { ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
|
||||
import SelectAlertType from './SelectAlertType';
|
||||
|
||||
import './CreateAlertRule.styles.scss';
|
||||
|
||||
function CreateRules(): JSX.Element {
|
||||
const [formInstance] = Form.useForm();
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const queryParams = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
|
||||
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
|
||||
const version = queryParams.get(QueryParams.version);
|
||||
const showClassicCreateAlertsPageFlag =
|
||||
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
|
||||
|
||||
const isTypeSelectionMode =
|
||||
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
|
||||
@@ -46,7 +40,7 @@ function CreateRules(): JSX.Element {
|
||||
}, [isTypeSelectionMode]);
|
||||
|
||||
const alertType = useMemo(() => {
|
||||
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
if (ruleTypeFromURL === RuletypesRuleTypeDTO.anomaly_rule) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
if (!alertTypeFromURL) {
|
||||
@@ -59,14 +53,6 @@ function CreateRules(): JSX.Element {
|
||||
return alertTypeFromURL as AlertTypes;
|
||||
}, [alertTypeFromURL, ruleTypeFromURL, compositeQuery?.builder.queryData]);
|
||||
|
||||
const initialAlertValue: AlertDef = useMemo(
|
||||
() => ({
|
||||
...ALERTS_VALUES_MAP[alertType],
|
||||
version: version || ENTITY_VERSION_V5,
|
||||
}),
|
||||
[alertType, version],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
queryParams.set('tab', tab);
|
||||
@@ -80,13 +66,10 @@ function CreateRules(): JSX.Element {
|
||||
const handleSelectType = useCallback(
|
||||
(type: AlertTypes, newTab?: boolean): void => {
|
||||
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
queryParams.set(
|
||||
QueryParams.ruleType,
|
||||
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
);
|
||||
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.anomaly_rule);
|
||||
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
|
||||
} else {
|
||||
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
|
||||
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.threshold_rule);
|
||||
queryParams.set(QueryParams.alertType, type);
|
||||
}
|
||||
|
||||
@@ -99,26 +82,8 @@ function CreateRules(): JSX.Element {
|
||||
if (isTypeSelectionMode) {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
if (showClassicCreateAlertsPageFlag) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
formInstance={formInstance}
|
||||
initialValue={initialAlertValue}
|
||||
ruleId=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <CreateAlertV2 alertType={alertType} />;
|
||||
}, [
|
||||
isTypeSelectionMode,
|
||||
handleSelectType,
|
||||
showClassicCreateAlertsPageFlag,
|
||||
alertType,
|
||||
formInstance,
|
||||
initialAlertValue,
|
||||
]);
|
||||
}, [isTypeSelectionMode, handleSelectType, alertType]);
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { RotateCcw } from '@signozhq/icons';
|
||||
import { useAlertRuleOptional } from 'providers/Alert';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
@@ -22,8 +15,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
const alertRuleContext = useAlertRuleOptional();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const groupByLabels = useMemo(() => {
|
||||
const labels = new Array<string>();
|
||||
@@ -46,14 +37,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
[groupByLabels],
|
||||
);
|
||||
|
||||
const handleSwitchToClassicExperience = useCallback(() => {
|
||||
logEvent('Alert: Switch to classic experience button clicked', {});
|
||||
|
||||
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
|
||||
safeNavigate(url, { replace: true });
|
||||
}, [safeNavigate, urlQuery]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
|
||||
@@ -61,15 +44,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
{!isEditMode && (
|
||||
<div className="alert-header__tab-bar">
|
||||
<div className="alert-header__tab">New Alert Rule</div>
|
||||
<Button
|
||||
prefix={<RotateCcw size={12} />}
|
||||
onClick={handleSwitchToClassicExperience}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Switch to Classic Experience
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="alert-header__content">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
|
||||
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
|
||||
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
@@ -11,11 +8,6 @@ import * as rulesHook from '../../../../api/generated/services/rules';
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import CreateAlertHeader from '../CreateAlertHeader';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
|
||||
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
@@ -101,37 +93,4 @@ describe('CreateAlertHeader', () => {
|
||||
screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER),
|
||||
).toHaveValue('TEST_ALERT');
|
||||
});
|
||||
|
||||
it('should navigate to classic experience when button is clicked', () => {
|
||||
renderCreateAlertHeader();
|
||||
const switchToClassicExperienceButton = screen.getByText(
|
||||
'Switch to Classic Experience',
|
||||
);
|
||||
expect(switchToClassicExperienceButton).toBeInTheDocument();
|
||||
fireEvent.click(switchToClassicExperienceButton);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render "switch to classic experience" button when isEditMode is true', () => {
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
isEditMode
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
initialAlertState={getCreateAlertLocalStateFromAlertDef(
|
||||
defaultPostableAlertRuleV2,
|
||||
)}
|
||||
>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText('Switch to Classic Experience'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -556,8 +556,6 @@ describe('Footer utils', () => {
|
||||
},
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: 'http://localhost/',
|
||||
version: 'v5',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
@@ -266,7 +266,7 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
// Evaluation
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
|
||||
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
|
||||
let ruleType: string = RuletypesRuleTypeDTO.threshold_rule;
|
||||
if (query.queryType === EQueryType.PROM) {
|
||||
ruleType = 'promql_rule';
|
||||
}
|
||||
@@ -292,9 +292,7 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
version: 'v5',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -321,7 +319,7 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
|
||||
return {
|
||||
...payload,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
condition: {
|
||||
...payload.condition,
|
||||
|
||||
@@ -179,7 +179,6 @@ function QuerySection(): JSX.Element {
|
||||
isAnomalyDetection={isAnomalyDetection}
|
||||
key={currentQuery.queryType}
|
||||
ruleId=""
|
||||
hideTitle
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialClickHouseData,
|
||||
initialQueryPromQLData,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import store from 'store';
|
||||
import { TestRouter } from 'tests/router';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -237,7 +237,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@@ -252,7 +252,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@@ -376,7 +376,7 @@ describe('QuerySection', () => {
|
||||
|
||||
expect(result[1]).toStrictEqual({
|
||||
[QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -92,30 +92,17 @@
|
||||
.chart-preview-container {
|
||||
margin-right: 4px;
|
||||
.alert-chart-container {
|
||||
.ant-card {
|
||||
border: 1px solid var(--l1-border);
|
||||
.ant-card-body {
|
||||
background-color: var(--l1-background);
|
||||
.chart-preview-headline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.chart-preview-headline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.y-axis-unit-selector-component {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.y-axis-unit-selector-component {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-query-section-container {
|
||||
margin: 0;
|
||||
background-color: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
@@ -18,8 +18,8 @@ export function buildAlertDefForChartPreview({
|
||||
...initialAlertDef,
|
||||
ruleType:
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
? RuletypesRuleTypeDTO.anomaly_rule
|
||||
: RuletypesRuleTypeDTO.threshold_rule,
|
||||
condition: {
|
||||
...initialAlertDef.condition,
|
||||
targetUnit: thresholdState.thresholds?.[0].unit,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
@@ -39,7 +38,6 @@ const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
|
||||
|
||||
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import {
|
||||
useCreateRule,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
useUpdateRuleByID,
|
||||
} from 'api/generated/services/rules';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -186,8 +186,8 @@ export function CreateAlertProvider(
|
||||
[QueryParams.alertType]: value,
|
||||
[QueryParams.ruleType]:
|
||||
value === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
? RuletypesRuleTypeDTO.anomaly_rule
|
||||
: RuletypesRuleTypeDTO.threshold_rule,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Spin } from 'antd';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -251,8 +251,7 @@ export {
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
const isAnomalyRule =
|
||||
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
const isAnomalyRule = alertDef.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
|
||||
return {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds:
|
||||
@@ -287,7 +286,7 @@ export function getThresholdStateFromAlertDef(
|
||||
export function getAlertTypeFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): AlertTypes {
|
||||
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
if (alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
return alertDef?.alertType as AlertTypes;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Form } from 'antd';
|
||||
import EditAlertV2 from 'container/EditAlertV2';
|
||||
import FormAlertRules from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
function EditRules({
|
||||
initialValue,
|
||||
ruleId,
|
||||
initialV2AlertValue,
|
||||
}: EditRulesProps): JSX.Element {
|
||||
const [formInstance] = Form.useForm();
|
||||
|
||||
if (
|
||||
initialV2AlertValue !== null &&
|
||||
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
|
||||
) {
|
||||
return (
|
||||
<EditAlertV2
|
||||
initialAlert={initialV2AlertValue}
|
||||
alertType={initialValue.alertType as AlertTypes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={
|
||||
initialValue.alertType
|
||||
? (initialValue.alertType as AlertTypes)
|
||||
: AlertTypes.METRICS_BASED_ALERT
|
||||
}
|
||||
formInstance={formInstance}
|
||||
initialValue={initialValue}
|
||||
ruleId={ruleId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditRulesProps {
|
||||
initialValue: AlertDef;
|
||||
ruleId: string;
|
||||
initialV2AlertValue: PostableAlertRuleV2 | null;
|
||||
}
|
||||
|
||||
export default EditRules;
|
||||
@@ -1,268 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button, Flex, Form, Select, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef, Labels } from 'types/api/alerts/def';
|
||||
import APIError from 'types/api/error';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ChannelSelect from './ChannelSelect';
|
||||
import LabelSelect from './labels';
|
||||
import {
|
||||
FormContainer,
|
||||
FormItemMedium,
|
||||
InputSmall,
|
||||
SeveritySelect,
|
||||
StepHeading,
|
||||
TextareaMedium,
|
||||
} from './styles';
|
||||
|
||||
import './FormAlertRules.styles.scss';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface BasicInfoProps {
|
||||
isNewRule: boolean;
|
||||
alertDef: AlertDef;
|
||||
setAlertDef: (a: AlertDef) => void;
|
||||
}
|
||||
|
||||
function BasicInfo({
|
||||
isNewRule,
|
||||
alertDef,
|
||||
setAlertDef,
|
||||
}: BasicInfoProps): JSX.Element {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
data: channels,
|
||||
error,
|
||||
isError,
|
||||
refetch,
|
||||
} = useChannelOptions();
|
||||
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
|
||||
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasPreferredChannels =
|
||||
(alertDef.preferredChannels && alertDef.preferredChannels.length > 0) ||
|
||||
isNewRule;
|
||||
|
||||
setShouldBroadCastToAllChannels(!hasPreferredChannels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleBroadcastToAllChannels = (shouldBroadcast: boolean): void => {
|
||||
setShouldBroadCastToAllChannels(shouldBroadcast);
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
broadcastToAll: shouldBroadcast,
|
||||
});
|
||||
};
|
||||
|
||||
const noChannels = channels.length === 0;
|
||||
const handleCreateNewChannels = useCallback(() => {
|
||||
logEvent('Alert: Create notification channel button clicked', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
ruleId: isNewRule ? 0 : alertDef?.id,
|
||||
});
|
||||
openInNewTab(ROUTES.CHANNELS_NEW);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
|
||||
logEvent('Alert: New alert creation page visited', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
numberOfChannels: channels.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]);
|
||||
|
||||
const refetchChannels = async (): Promise<void> => {
|
||||
await refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StepHeading> {t('alert_form_step4')} </StepHeading>
|
||||
<FormContainer>
|
||||
<Form.Item
|
||||
label={t('field_severity')}
|
||||
labelAlign="left"
|
||||
name={['labels', 'severity']}
|
||||
>
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
labels: {
|
||||
...alertDef.labels,
|
||||
severity: s,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Option value="critical">{t('option_critical')}</Option>
|
||||
<Option value="error">{t('option_error')}</Option>
|
||||
<Option value="warning">{t('option_warning')}</Option>
|
||||
<Option value="info">{t('option_info')}</Option>
|
||||
</SeveritySelect>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
required
|
||||
name="alert"
|
||||
labelAlign="left"
|
||||
label={t('field_alert_name')}
|
||||
rules={[
|
||||
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
alert: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('field_alert_desc')}
|
||||
labelAlign="left"
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
annotations: {
|
||||
...alertDef.annotations,
|
||||
description: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormItemMedium label={t('field_labels')}>
|
||||
<LabelSelect
|
||||
onSetLabels={(l: Labels): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
labels: {
|
||||
...l,
|
||||
},
|
||||
});
|
||||
}}
|
||||
initialValues={alertDef.labels}
|
||||
/>
|
||||
</FormItemMedium>
|
||||
|
||||
<FormItemMedium
|
||||
name="alert_all_configured_channels"
|
||||
label="Alert all the configured channels"
|
||||
>
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
disabledTooltip={
|
||||
isLoading
|
||||
? 'Wait for the channels to load'
|
||||
: 'No channels. Ask an admin to create a notification channel'
|
||||
}
|
||||
value={shouldBroadCastToAllChannels}
|
||||
onChange={handleBroadcastToAllChannels}
|
||||
disabled={noChannels || !!isLoading}
|
||||
testId="alert-broadcast-to-all-channels"
|
||||
/>
|
||||
</FormItemMedium>
|
||||
|
||||
{!shouldBroadCastToAllChannels && (
|
||||
<Tooltip
|
||||
title={
|
||||
noChannels && !addNewChannelPermission
|
||||
? 'No channels. Ask an admin to create a notification channel'
|
||||
: undefined
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<FormItemMedium
|
||||
label="Notification Channels"
|
||||
name="notification_channels"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
|
||||
]}
|
||||
>
|
||||
<ChannelSelect
|
||||
onDropdownOpen={refetchChannels}
|
||||
disabled={shouldBroadCastToAllChannels}
|
||||
currentValue={alertDef.preferredChannels}
|
||||
handleCreateNewChannels={handleCreateNewChannels}
|
||||
channels={channels}
|
||||
isLoading={isLoading}
|
||||
hasError={isError}
|
||||
error={error as APIError}
|
||||
onSelectChannels={(preferredChannels): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
preferredChannels,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormItemMedium>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{noChannels && (
|
||||
<Tooltip
|
||||
title={
|
||||
!addNewChannelPermission
|
||||
? 'Ask an admin to create a notification channel'
|
||||
: undefined
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<Button
|
||||
onClick={handleCreateNewChannels}
|
||||
className="create-notification-btn"
|
||||
disabled={!addNewChannelPermission}
|
||||
>
|
||||
<Flex align="center" justify="center">
|
||||
<Plus size="md" />
|
||||
Create a notification channel
|
||||
</Flex>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FormContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BasicInfo;
|
||||
@@ -1,116 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { StyledCreateChannelOption, StyledSelect } from './styles';
|
||||
|
||||
export interface ChannelSelectProps {
|
||||
disabled?: boolean;
|
||||
currentValue?: string[];
|
||||
onSelectChannels: (s: string[]) => void;
|
||||
onDropdownOpen: () => void;
|
||||
isLoading: boolean;
|
||||
channels: ChannelOption[];
|
||||
hasError: boolean;
|
||||
error: APIError;
|
||||
handleCreateNewChannels: () => void;
|
||||
}
|
||||
|
||||
function ChannelSelect({
|
||||
disabled,
|
||||
currentValue,
|
||||
onSelectChannels,
|
||||
onDropdownOpen,
|
||||
channels,
|
||||
isLoading,
|
||||
hasError,
|
||||
error,
|
||||
handleCreateNewChannels,
|
||||
}: ChannelSelectProps): JSX.Element | null {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleChange = (value: string[]): void => {
|
||||
if (value.includes('add-new-channel')) {
|
||||
handleCreateNewChannels();
|
||||
return;
|
||||
}
|
||||
onSelectChannels(value);
|
||||
};
|
||||
|
||||
if (hasError) {
|
||||
notifications.error({
|
||||
message: error?.getErrorCode?.() || 'Error',
|
||||
description: error?.getErrorMessage?.() || 'Something went wrong',
|
||||
});
|
||||
}
|
||||
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
|
||||
const renderOptions = (): ReactNode[] => {
|
||||
const children: ReactNode[] = [];
|
||||
|
||||
if (!isLoading && addNewChannelPermission) {
|
||||
children.push(
|
||||
<Select.Option key="add-new-channel" value="add-new-channel">
|
||||
<StyledCreateChannelOption>
|
||||
<Plus size="md" />
|
||||
Create a new channel
|
||||
</StyledCreateChannelOption>
|
||||
</Select.Option>,
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || channels.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
channels.forEach((o) => {
|
||||
children.push(
|
||||
<Select.Option key={o.id} value={o.name}>
|
||||
{o.name}
|
||||
</Select.Option>,
|
||||
);
|
||||
});
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledSelect
|
||||
disabled={disabled}
|
||||
status={hasError ? 'error' : ''}
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('placeholder_channel_select')}
|
||||
data-testid="alert-channel-select"
|
||||
value={currentValue}
|
||||
notFoundContent={isLoading && <Spin size="small" />}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
if (open) {
|
||||
onDropdownOpen();
|
||||
}
|
||||
}}
|
||||
onChange={(value): void => {
|
||||
handleChange(value as string[]);
|
||||
}}
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{renderOptions()}
|
||||
</StyledSelect>
|
||||
);
|
||||
}
|
||||
|
||||
ChannelSelect.defaultProps = {
|
||||
disabled: false,
|
||||
currentValue: [],
|
||||
};
|
||||
export default ChannelSelect;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Select } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const StyledSelect = styled(Select)`
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export const StyledCreateChannelOption = styled.div`
|
||||
color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
@@ -0,0 +1,25 @@
|
||||
.chartCard {
|
||||
position: relative;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: var(--radius-2);
|
||||
background-color: var(--card);
|
||||
}
|
||||
|
||||
.chartCardBody {
|
||||
height: 57vh;
|
||||
padding: var(--spacing-12) 0;
|
||||
border-radius: 0 0 var(--radius-1) var(--radius-1);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
:global(.plot-tag) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
width: fit-content;
|
||||
margin-left: var(--spacing-3);
|
||||
padding: 0 var(--spacing-2) 0 var(--spacing-3);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,6 @@
|
||||
height: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.anomaly-alert-evaluation-view-loading-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
@@ -45,11 +46,10 @@ import { getGraphType } from 'utils/getGraphType';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { AlertDetectionTypes } from '..';
|
||||
import ChartContent from './ChartContent';
|
||||
import { ChartContainer } from './styles';
|
||||
import { getThresholds } from './utils';
|
||||
|
||||
import styles from './ChartPreview.module.scss';
|
||||
import './ChartPreview.styles.scss';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
@@ -279,7 +279,7 @@ function ChartPreview({
|
||||
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
|
||||
|
||||
const isAnomalyDetectionAlert =
|
||||
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
|
||||
|
||||
const chartDataAvailable =
|
||||
chartData &&
|
||||
@@ -300,57 +300,59 @@ function ChartPreview({
|
||||
|
||||
return (
|
||||
<div className="alert-chart-container" ref={graphRef}>
|
||||
<ChartContainer>
|
||||
<div className="chart-preview-header">
|
||||
{headline}
|
||||
{isWarning && (
|
||||
<WarningPopover warningData={queryResponse.data?.warning as Warning} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartCardBody}>
|
||||
<div className="chart-preview-header">
|
||||
{headline}
|
||||
{isWarning && (
|
||||
<WarningPopover warningData={queryResponse.data?.warning as Warning} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="threshold-alert-uplot-chart-container">
|
||||
{queryResponse.isLoading && (
|
||||
<Spinner size="large" tip="Loading..." height="100%" />
|
||||
)}
|
||||
{(queryResponse?.isError || queryResponse?.error) && !isCancelled && (
|
||||
<ErrorInPlace error={queryResponse.error as APIError} />
|
||||
)}
|
||||
<div className="threshold-alert-uplot-chart-container">
|
||||
{queryResponse.isLoading && (
|
||||
<Spinner size="large" tip="Loading..." height="100%" />
|
||||
)}
|
||||
{(queryResponse?.isError || queryResponse?.error) && !isCancelled && (
|
||||
<ErrorInPlace error={queryResponse.error as APIError} />
|
||||
)}
|
||||
|
||||
{isCancelled && !queryResponse.isLoading && !hasResultData && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load the chart preview.' />
|
||||
)}
|
||||
{isCancelled && !queryResponse.isLoading && !hasResultData && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load the chart preview.' />
|
||||
)}
|
||||
|
||||
{chartDataAvailable && !isAnomalyDetectionAlert && (
|
||||
<ChartContent
|
||||
panelType={graphType}
|
||||
alertId={alertDef?.id}
|
||||
query={query || currentQuery}
|
||||
apiResponse={queryResponse.data?.payload}
|
||||
data={chartData}
|
||||
thresholds={resolvedThresholds}
|
||||
yAxisUnit={yAxisUnit}
|
||||
legendPosition={legendPosition}
|
||||
isDarkMode={isDarkMode}
|
||||
timezone={timezone}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
minTimeScale={minTimeScale}
|
||||
maxTimeScale={maxTimeScale}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartDataAvailable &&
|
||||
isAnomalyDetectionAlert &&
|
||||
isAnomalyDetectionEnabled &&
|
||||
queryResponse?.data?.payload?.data?.resultType === 'time_series' && (
|
||||
<AnomalyAlertEvaluationView
|
||||
data={queryResponse?.data?.payload}
|
||||
{chartDataAvailable && !isAnomalyDetectionAlert && (
|
||||
<ChartContent
|
||||
panelType={graphType}
|
||||
alertId={alertDef?.id}
|
||||
query={query || currentQuery}
|
||||
apiResponse={queryResponse.data?.payload}
|
||||
data={chartData}
|
||||
thresholds={resolvedThresholds}
|
||||
yAxisUnit={yAxisUnit}
|
||||
legendPosition={legendPosition}
|
||||
isDarkMode={isDarkMode}
|
||||
timezone={timezone}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
minTimeScale={minTimeScale}
|
||||
maxTimeScale={maxTimeScale}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartDataAvailable &&
|
||||
isAnomalyDetectionAlert &&
|
||||
isAnomalyDetectionEnabled &&
|
||||
queryResponse?.data?.payload?.data?.resultType === 'time_series' && (
|
||||
<AnomalyAlertEvaluationView
|
||||
data={queryResponse?.data?.payload}
|
||||
yAxisUnit={yAxisUnit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { red } from '@ant-design/colors';
|
||||
import { Card, Tooltip } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const NotFoundContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 55vh;
|
||||
`;
|
||||
|
||||
export const FailedMessageContainer = styled(Tooltip)`
|
||||
position: absolute;
|
||||
color: ${red};
|
||||
top: 4rem;
|
||||
left: 10px;
|
||||
`;
|
||||
|
||||
export const ChartContainer = styled(Card)`
|
||||
border-radius: 4px;
|
||||
&&& {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plot-tag {
|
||||
margin-left: 6px;
|
||||
display: inline-flex;
|
||||
padding: 0px 4px 0px 6px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 1.5rem 0;
|
||||
height: 57vh;
|
||||
/* padding-bottom: 2rem; */
|
||||
}
|
||||
`;
|
||||
@@ -1,146 +0,0 @@
|
||||
.create-alert-modal {
|
||||
.ant-modal-content {
|
||||
background-color: var(--l3-background);
|
||||
.ant-modal-confirm-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.ant-modal-confirm-content {
|
||||
.ant-typography {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-confirm-btns {
|
||||
button:nth-of-type(1) {
|
||||
background-color: var(--l1-border);
|
||||
border: none;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-alert-rules-container {
|
||||
&.create-mode {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.triggered-alerts-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.alert-rules-list-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.planned-downtime-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.steps-container {
|
||||
width: 80%;
|
||||
|
||||
.alert-query-section-container {
|
||||
--query-builder-v2-color: var(--l2-foreground);
|
||||
--query-builder-v2-background-color: var(--l3-background);
|
||||
--query-builder-v2-border-color: var(--l3-border);
|
||||
--query-builder-v2-selection-background-color: var(--l2-background);
|
||||
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
|
||||
--query-builder-v2-placeholder-color: var(--l3-foreground);
|
||||
--query-builder-v2-selected-background-color: var(--l3-foreground);
|
||||
--query-search-background-color: var(--l3-background);
|
||||
--query-search-background-color-selection: var(--l3-background);
|
||||
--input-with-label-border-color: var(--l3-border);
|
||||
--input-with-label-background-color: var(--l3-background);
|
||||
--input-with-label-color: var(--l2-foreground);
|
||||
--query-builder-v2-toggle-group-background-color: var(--l3-background);
|
||||
--query-builder-v2-toggle-group-border-color: var(--l3-border);
|
||||
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
|
||||
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
|
||||
--input-height: 36px;
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.qb-chart-preview-container {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.overview-header {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.alert-type-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.alert-type-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-preview-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
|
||||
.ant-card {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.detection-method-container {
|
||||
margin: 24px 0;
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detection-method-description {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-help-btns {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
|
||||
.doc-redirection-btn {
|
||||
color: var(--bg-aqua-500) !important;
|
||||
border-color: var(--bg-aqua-500) !important;
|
||||
}
|
||||
|
||||
.facing-issue-btn {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.create-notification-btn {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--spacing-6);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
:global(.alert-tabs) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.alert-tabs .ant-tabs .ant-tabs-nav) {
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
:global(.alert-tabs .ant-tabs-nav-wrap),
|
||||
:global(.alert-tabs .ant-tabs-extra-content) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -51,27 +51,3 @@
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-query-section-container {
|
||||
.ant-card-body {
|
||||
padding: 0px;
|
||||
|
||||
.alert-tabs {
|
||||
padding: 0px;
|
||||
|
||||
.ant-tabs {
|
||||
.ant-tabs-nav {
|
||||
padding: 8px 0;
|
||||
|
||||
.ant-tabs-nav-wrap {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-extra-content {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Tabs, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -21,8 +20,8 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ChQuerySection from './ChQuerySection';
|
||||
import PromqlSection from './PromqlSection';
|
||||
import { FormContainer, StepHeading } from './styles';
|
||||
|
||||
import styles from './QuerySection.module.scss';
|
||||
import './QuerySection.styles.scss';
|
||||
|
||||
const ANOMALY_QUERY_SUPPORT_CLICKHOUSE_ISSUE =
|
||||
@@ -41,11 +40,8 @@ function QuerySection({
|
||||
alertDef,
|
||||
panelType,
|
||||
ruleId,
|
||||
hideTitle,
|
||||
isAnomalyDetection,
|
||||
}: QuerySectionProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
const [currentTab, setCurrentTab] = useState(queryCategory);
|
||||
const [signalSource, setSignalSource] = useState<string>('metrics');
|
||||
|
||||
@@ -290,18 +286,11 @@ function QuerySection({
|
||||
return null;
|
||||
};
|
||||
|
||||
const step2Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '2' : '1';
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideTitle && (
|
||||
<StepHeading> {t('alert_form_step2', { step: step2Label })}</StepHeading>
|
||||
)}
|
||||
<FormContainer className="alert-query-section-container">
|
||||
<div>{renderTabs(alertType)}</div>
|
||||
{renderQuerySection(currentTab)}
|
||||
</FormContainer>
|
||||
</>
|
||||
<div className={styles.container}>
|
||||
<div>{renderTabs(alertType)}</div>
|
||||
{renderQuerySection(currentTab)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,12 +304,10 @@ interface QuerySectionProps {
|
||||
alertDef: AlertDef;
|
||||
panelType: PANEL_TYPES;
|
||||
ruleId: string;
|
||||
hideTitle?: boolean;
|
||||
isAnomalyDetection?: boolean;
|
||||
}
|
||||
|
||||
QuerySection.defaultProps = {
|
||||
hideTitle: false,
|
||||
isAnomalyDetection: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.rule-definition {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-unit-selector {
|
||||
width: 150px;
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Form,
|
||||
InputNumber,
|
||||
InputNumberProps,
|
||||
Select,
|
||||
SelectProps,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
} from 'container/CreateAlertV2/AlertCondition/utils';
|
||||
import {
|
||||
AlertDef,
|
||||
defaultAlgorithm,
|
||||
defaultCompareOp,
|
||||
defaultEvalWindow,
|
||||
defaultFrequency,
|
||||
defaultMatchType,
|
||||
defaultSeasonality,
|
||||
} from 'types/api/alerts/def';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { AlertDetectionTypes } from '.';
|
||||
import {
|
||||
FormContainer,
|
||||
InlineSelect,
|
||||
StepHeading,
|
||||
VerticalLine,
|
||||
} from './styles';
|
||||
|
||||
import './RuleOptions.styles.scss';
|
||||
|
||||
function RuleOptions({
|
||||
alertDef,
|
||||
setAlertDef,
|
||||
queryCategory,
|
||||
queryOptions,
|
||||
yAxisUnit,
|
||||
}: RuleOptionsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const { ruleType } = alertDef;
|
||||
|
||||
const handleMatchOptChange = (value: string | unknown): void => {
|
||||
const m = (value as string) || alertDef.condition?.matchType;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
matchType: m,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeSelectedQueryName = (value: string | unknown): void => {
|
||||
if (typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
selectedQueryName: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderCompareOps = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
op: newOp,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Select.Option value="1">{t('option_above')}</Select.Option>
|
||||
<Select.Option value="2">{t('option_below')}</Select.Option>
|
||||
|
||||
{/* hide equal and not eqaul in case of analmoy based alert */}
|
||||
|
||||
{ruleType !== 'anomaly_rule' && (
|
||||
<>
|
||||
<Select.Option value="3">{t('option_equal')}</Select.Option>
|
||||
<Select.Option value="4">{t('option_notequal')}</Select.Option>
|
||||
</>
|
||||
)}
|
||||
{/* the value 5 and 6 are reserved for above or equal and below or equal */}
|
||||
{ruleType === 'anomaly_rule' && (
|
||||
<Select.Option value="7">{t('option_above_below')}</Select.Option>
|
||||
)}
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderMatchOpts = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
<Select.Option value="2">{t('option_allthetimes')}</Select.Option>
|
||||
|
||||
{ruleType !== 'anomaly_rule' && (
|
||||
<>
|
||||
<Select.Option value="3">{t('option_onaverage')}</Select.Option>
|
||||
<Select.Option value="4">{t('option_intotal')}</Select.Option>
|
||||
<Select.Option value="5">{t('option_last')}</Select.Option>
|
||||
</>
|
||||
)}
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const onChangeEvalWindow = (value: string | unknown): void => {
|
||||
const ew = (value as string) || alertDef.evalWindow;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
evalWindow: ew,
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeAlgorithm = (value: string | unknown): void => {
|
||||
const alg = (value as string) || alertDef.condition.algorithm;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
algorithm: alg,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeSeasonality = (value: string | unknown): void => {
|
||||
const seasonality = (value as string) || alertDef.condition.seasonality;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
seasonality,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeDeviation = (value: number): void => {
|
||||
const target = value || alertDef.condition.target || 3;
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: { ...alertDef.condition, target: Number(target) },
|
||||
});
|
||||
};
|
||||
|
||||
const renderEvalWindows = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultEvalWindow}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
|
||||
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
|
||||
<Select.Option value="4h0m0s">{t('option_4hours')}</Select.Option>
|
||||
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderPromEvalWindows = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultEvalWindow}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderAlgorithms = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultAlgorithm}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.condition.algorithm}
|
||||
onChange={onChangeAlgorithm}
|
||||
>
|
||||
<Select.Option value="standard">Standard</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderDeviationOpts = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={3}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.condition.target}
|
||||
onChange={(value: number | unknown): void => {
|
||||
if (typeof value === 'number') {
|
||||
onChangeDeviation(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Option value={1}>1</Select.Option>
|
||||
<Select.Option value={2}>2</Select.Option>
|
||||
<Select.Option value={3}>3</Select.Option>
|
||||
<Select.Option value={4}>4</Select.Option>
|
||||
<Select.Option value={5}>5</Select.Option>
|
||||
<Select.Option value={6}>6</Select.Option>
|
||||
<Select.Option value={7}>7</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderSeasonality = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultSeasonality}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.condition.seasonality}
|
||||
onChange={onChangeSeasonality}
|
||||
>
|
||||
<Select.Option value="hourly">Hourly</Select.Option>
|
||||
<Select.Option value="daily">Daily</Select.Option>
|
||||
<Select.Option value="weekly">Weekly</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderThresholdRuleOpts = (): JSX.Element => (
|
||||
<Form.Item>
|
||||
<Typography.Text>
|
||||
{t('text_condition1')}
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={queryOptions}
|
||||
placeholder={t('selected_query_placeholder')}
|
||||
value={alertDef.condition.selectedQueryName}
|
||||
onChange={onChangeSelectedQueryName}
|
||||
/>
|
||||
<Typography.Text>is</Typography.Text>
|
||||
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}{' '}
|
||||
{t('text_condition3')} {renderEvalWindows()}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const renderPromRuleOptions = (): JSX.Element => (
|
||||
<Form.Item>
|
||||
<Typography.Text>
|
||||
{t('text_condition1')}
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={queryOptions}
|
||||
placeholder={t('selected_query_placeholder')}
|
||||
value={alertDef.condition.selectedQueryName}
|
||||
onChange={onChangeSelectedQueryName}
|
||||
/>
|
||||
<Typography.Text>is</Typography.Text>
|
||||
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}
|
||||
{t('text_condition3')} {renderPromEvalWindows()}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const onChange: InputNumberProps['onChange'] = (value): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
op: alertDef.condition?.op || defaultCompareOp,
|
||||
matchType: alertDef.condition?.matchType || defaultMatchType,
|
||||
target: Number(value) || 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeAlertUnit: SelectProps['onChange'] = (value) => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
targetUnit: value as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeFrequency = (value: string | unknown): void => {
|
||||
const freq = (value as string) || alertDef.frequency;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
frequency: freq,
|
||||
});
|
||||
};
|
||||
|
||||
const renderAnomalyRuleOpts = (): JSX.Element => (
|
||||
<Form.Item>
|
||||
<Typography.Text className="rule-definition">
|
||||
{t('text_condition1_anomaly')}
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={queryOptions}
|
||||
placeholder={t('selected_query_placeholder')}
|
||||
value={alertDef.condition.selectedQueryName}
|
||||
onChange={onChangeSelectedQueryName}
|
||||
/>
|
||||
{t('text_condition3')} {renderEvalWindows()}
|
||||
<Typography.Text>is</Typography.Text>
|
||||
{renderDeviationOpts()}
|
||||
<Typography.Text>deviations</Typography.Text>
|
||||
{renderCompareOps()}
|
||||
<Typography.Text>the predicted data</Typography.Text>
|
||||
{renderMatchOpts()}
|
||||
using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '}
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const renderFrequency = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultFrequency}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.frequency}
|
||||
onChange={onChangeFrequency}
|
||||
>
|
||||
<Select.Option value="1m0s">{t('option_1min')}</Select.Option>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
|
||||
<Select.Option value="30m0s">{t('option_30min')}</Select.Option>
|
||||
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
|
||||
<Select.Option value="3h0m0s">{t('option_3hours')}</Select.Option>
|
||||
<Select.Option value="6h0m0s">{t('option_6hours')}</Select.Option>
|
||||
<Select.Option value="12h0m0s">{t('option_12hours')}</Select.Option>
|
||||
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const selectedCategory = getCategoryByOptionId(yAxisUnit);
|
||||
|
||||
const categorySelectOptions = getCategorySelectOptionByName(selectedCategory);
|
||||
|
||||
const step3Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '3' : '2';
|
||||
|
||||
return (
|
||||
<>
|
||||
<StepHeading>{t('alert_form_step3', { step: step3Label })}</StepHeading>
|
||||
<FormContainer>
|
||||
{queryCategory === EQueryType.PROM && renderPromRuleOptions()}
|
||||
{queryCategory !== EQueryType.PROM &&
|
||||
ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
|
||||
<>{renderAnomalyRuleOpts()}</>
|
||||
)}
|
||||
|
||||
{queryCategory !== EQueryType.PROM &&
|
||||
ruleType === AlertDetectionTypes.THRESHOLD_ALERT &&
|
||||
renderThresholdRuleOpts()}
|
||||
|
||||
<Space direction="vertical" size="large">
|
||||
{ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Form.Item noStyle>
|
||||
<InputNumber
|
||||
addonBefore={t('field_threshold')}
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle>
|
||||
<Select
|
||||
className="rule-unit-selector"
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={categorySelectOptions}
|
||||
placeholder={t('field_unit')}
|
||||
value={alertDef.condition.targetUnit}
|
||||
onChange={onChangeAlertUnit}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Collapse>
|
||||
<Collapse.Panel header={t('More options')} key="1">
|
||||
<Space direction="vertical" size="large">
|
||||
<VerticalLine>
|
||||
<Space direction="horizontal" align="center">
|
||||
<Typography.Text>{t('text_alert_frequency')}</Typography.Text>
|
||||
{renderFrequency()}
|
||||
</Space>
|
||||
</VerticalLine>
|
||||
|
||||
<VerticalLine>
|
||||
<Space direction="horizontal" align="center">
|
||||
<Form.Item noStyle name={['condition', 'alertOnAbsent']}>
|
||||
<Checkbox
|
||||
checked={alertDef?.condition?.alertOnAbsent}
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
alertOnAbsent: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_alert_on_absent')}</Typography.Text>
|
||||
|
||||
<Form.Item noStyle name={['condition', 'absentFor']}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={alertDef?.condition?.absentFor}
|
||||
onChange={(value): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
absentFor: Number(value) || 0,
|
||||
},
|
||||
});
|
||||
}}
|
||||
type="number"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_for')}</Typography.Text>
|
||||
</Space>
|
||||
</VerticalLine>
|
||||
|
||||
<VerticalLine>
|
||||
<Space direction="horizontal" align="center">
|
||||
<Form.Item noStyle name={['condition', 'requireMinPoints']}>
|
||||
<Checkbox
|
||||
checked={alertDef?.condition?.requireMinPoints}
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
requireMinPoints: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_require_min_points')}</Typography.Text>
|
||||
|
||||
<Form.Item noStyle name={['condition', 'requiredNumPoints']}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={alertDef?.condition?.requiredNumPoints}
|
||||
onChange={(value): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
requiredNumPoints: Number(value) || 0,
|
||||
},
|
||||
});
|
||||
}}
|
||||
type="number"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_num_points')}</Typography.Text>
|
||||
</Space>
|
||||
</VerticalLine>
|
||||
</Space>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Space>
|
||||
</FormContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface RuleOptionsProps {
|
||||
alertDef: AlertDef;
|
||||
setAlertDef: (a: AlertDef) => void;
|
||||
queryCategory: EQueryType;
|
||||
queryOptions: DefaultOptionType[];
|
||||
yAxisUnit: string;
|
||||
}
|
||||
export default RuleOptions;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePrefillAlertConditions } from '../usePrefillAlertConditions';
|
||||
|
||||
const TEST_MAPPINGS = {
|
||||
op: {
|
||||
'>': '1',
|
||||
'<': '2',
|
||||
'=': '3',
|
||||
},
|
||||
matchType: {
|
||||
avg: '3',
|
||||
sum: '4',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('lib/router/useAppLocation', () => {
|
||||
const mockThreshold1 = {
|
||||
index: '0d11f426-a02e-48da-867c-b79c6ef1ff06',
|
||||
isEditEnabled: false,
|
||||
keyIndex: 1,
|
||||
selectedGraph: 'graph',
|
||||
thresholdColor: 'Orange',
|
||||
thresholdFormat: 'Text',
|
||||
thresholdLabel: 'Caution',
|
||||
thresholdOperator: '>',
|
||||
thresholdTableOptions: 'A',
|
||||
thresholdUnit: 'rpm',
|
||||
thresholdValue: 800,
|
||||
};
|
||||
const mockThreshold2 = {
|
||||
index: 'edbe8ef2-fa54-4cb9-b343-7afe883bb714',
|
||||
isEditEnabled: false,
|
||||
keyIndex: 0,
|
||||
selectedGraph: 'graph',
|
||||
thresholdColor: 'Red',
|
||||
thresholdFormat: 'Text',
|
||||
thresholdLabel: 'Danger',
|
||||
thresholdOperator: '<',
|
||||
thresholdTableOptions: 'A',
|
||||
thresholdUnit: 'rpm',
|
||||
thresholdValue: 900,
|
||||
};
|
||||
return {
|
||||
useAppLocation: jest.fn().mockReturnValue({
|
||||
state: {
|
||||
thresholds: [mockThreshold1, mockThreshold2],
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockStagedQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('usePrefillAlertConditions', () => {
|
||||
it('returns the correct matchType for a single query', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions(mockStagedQuery as any),
|
||||
);
|
||||
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
|
||||
});
|
||||
|
||||
it('returns null matchType for a single query with unsupported time aggregation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions({
|
||||
builder: { queryData: [{ reduceTo: 'p90' }] },
|
||||
} as any),
|
||||
);
|
||||
expect(result.current.matchType).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the correct matchType for multiple queries with same time aggregation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
|
||||
});
|
||||
|
||||
it('returns null matchType for multiple queries with different time aggregation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
{
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
expect(result.current.matchType).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the correct op, target, targetUnit from the higher priority threshold for multiple thresholds', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions(mockStagedQuery as any),
|
||||
);
|
||||
expect(result.current.op).toBe(TEST_MAPPINGS.op['<']);
|
||||
expect(result.current.target).toBe(900);
|
||||
expect(result.current.targetUnit).toBe('rpm');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CircleAlert, CircleX } from '@signozhq/icons';
|
||||
import { Button, Input, message, Modal } from 'antd';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { map } from 'lodash-es';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Pill } from '@signozhq/ui/pill';
|
||||
import { QueryChipContainer, QueryChipItem, SearchContainer } from './styles';
|
||||
import { ILabelRecord } from './types';
|
||||
import { createQuery, flattenLabels, prepareLabels } from './utils';
|
||||
|
||||
type LabelStep = 'Idle' | 'LabelKey' | 'LabelValue';
|
||||
type LabelEvent = 'NEXT' | 'onBlur' | 'RESET';
|
||||
|
||||
interface LabelSelectProps {
|
||||
onSetLabels: (q: Labels) => void;
|
||||
initialValues: Labels | undefined;
|
||||
}
|
||||
|
||||
function LabelSelect({
|
||||
onSetLabels,
|
||||
initialValues,
|
||||
}: LabelSelectProps): JSX.Element | null {
|
||||
const { t } = useTranslation('alerts');
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [currentVal, setCurrentVal] = useState('');
|
||||
const [staging, setStaging] = useState<string[]>([]);
|
||||
const [queries, setQueries] = useState<ILabelRecord[]>(
|
||||
initialValues ? flattenLabels(initialValues) : [],
|
||||
);
|
||||
const [step, setStep] = useState<LabelStep>('Idle');
|
||||
|
||||
const dispatchChanges = (updatedRecs: ILabelRecord[]): void => {
|
||||
onSetLabels(prepareLabels(updatedRecs, initialValues));
|
||||
setQueries(updatedRecs);
|
||||
};
|
||||
|
||||
const onSelectLabelValue = (): void => {
|
||||
if (currentVal !== '') {
|
||||
setStaging((prevState) => [...prevState, currentVal]);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setCurrentVal('');
|
||||
};
|
||||
|
||||
const onValidateQuery = (): void => {
|
||||
if (currentVal === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedQuery = createQuery([...staging, currentVal]);
|
||||
|
||||
if (generatedQuery) {
|
||||
dispatchChanges([...queries, generatedQuery]);
|
||||
setStaging([]);
|
||||
setCurrentVal('');
|
||||
setStep('Idle');
|
||||
}
|
||||
};
|
||||
|
||||
const send = (event: LabelEvent): void => {
|
||||
if (event === 'RESET') {
|
||||
setStep('Idle');
|
||||
return;
|
||||
}
|
||||
if (event === 'NEXT') {
|
||||
if (step === 'Idle') {
|
||||
setStep('LabelKey');
|
||||
} else if (step === 'LabelKey') {
|
||||
onSelectLabelValue();
|
||||
setStep('LabelValue');
|
||||
} else if (step === 'LabelValue') {
|
||||
onValidateQuery();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === 'onBlur') {
|
||||
if (step === 'LabelKey') {
|
||||
onSelectLabelValue();
|
||||
setStep('LabelValue');
|
||||
} else if (step === 'LabelValue') {
|
||||
onValidateQuery();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (): void => {
|
||||
if (step === 'Idle') {
|
||||
send('NEXT');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = useCallback((): void => {
|
||||
if (staging.length === 1 && staging[0] !== undefined) {
|
||||
send('onBlur');
|
||||
}
|
||||
}, [staging]);
|
||||
|
||||
useEffect(() => {
|
||||
handleBlur();
|
||||
}, [handleBlur]);
|
||||
|
||||
const handleLabelChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
// Remove the colon if it's the last character.
|
||||
// As the colon is used to separate the key and value in the query.
|
||||
setCurrentVal(
|
||||
event.target?.value.endsWith(':')
|
||||
? event.target?.value.slice(0, -1)
|
||||
: event.target?.value,
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = (key: string): void => {
|
||||
dispatchChanges(queries.filter((queryData) => queryData.key !== key));
|
||||
};
|
||||
|
||||
const handleClearAll = (): void => {
|
||||
Modal.confirm({
|
||||
title: 'Confirm',
|
||||
icon: <CircleAlert size="md" />,
|
||||
content: t('remove_label_confirm'),
|
||||
onOk() {
|
||||
send('RESET');
|
||||
dispatchChanges([]);
|
||||
setStaging([]);
|
||||
message.success(t('remove_label_success'));
|
||||
},
|
||||
okText: t('button_yes'),
|
||||
cancelText: t('button_no'),
|
||||
});
|
||||
};
|
||||
const renderPlaceholder = useCallback((): string => {
|
||||
if (step === 'LabelKey') {
|
||||
return 'Enter a label key then press ENTER.';
|
||||
}
|
||||
if (step === 'LabelValue') {
|
||||
return `Enter a value for label key(${staging[0]}) then press ENTER.`;
|
||||
}
|
||||
return t('placeholder_label_key_pair');
|
||||
}, [t, step, staging]);
|
||||
return (
|
||||
<SearchContainer isDarkMode={isDarkMode} disabled={false}>
|
||||
<div style={{ display: 'inline-flex', flexWrap: 'wrap' }}>
|
||||
{queries.length > 0 &&
|
||||
map(queries, (query): JSX.Element => {
|
||||
const isClosable =
|
||||
query.key !== 'severity' && query.key !== 'description';
|
||||
return (
|
||||
<QueryChipContainer key={query.key}>
|
||||
{isClosable ? (
|
||||
<Pill.Closeable
|
||||
closeAriaLabel={`Remove ${query.key}`}
|
||||
onClose={(): void => handleClose(query.key)}
|
||||
>
|
||||
{query.key}: {query.value}
|
||||
</Pill.Closeable>
|
||||
) : (
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{query.key}: {query.value}
|
||||
</Badge>
|
||||
)}
|
||||
</QueryChipContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{map(staging, (item) => (
|
||||
<QueryChipItem key={uuid()}>{item}</QueryChipItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
if (e.key === 'Enter' || e.code === 'Enter' || e.key === ':') {
|
||||
send('NEXT');
|
||||
}
|
||||
if (step === 'Idle') {
|
||||
send('NEXT');
|
||||
}
|
||||
}}
|
||||
bordered={false}
|
||||
value={currentVal as never}
|
||||
style={{ flex: 1 }}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{queries.length > 0 || staging.length > 0 || currentVal ? (
|
||||
<Button
|
||||
onClick={handleClearAll}
|
||||
icon={<CircleX size="md" />}
|
||||
type="text"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SearchContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelSelect;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { grey } from '@ant-design/colors';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface SearchContainerProps {
|
||||
isDarkMode: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const SearchContainer = styled.div<SearchContainerProps>`
|
||||
border-radius: 4px;
|
||||
background: ${({ isDarkMode }): string => (isDarkMode ? '#000' : '#fff')};
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.2rem;
|
||||
border: 1px solid #ccc5;
|
||||
${({ disabled }): string => (disabled ? `cursor: not-allowed;` : '')}
|
||||
`;
|
||||
|
||||
export const QueryChipContainer = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 0.5rem;
|
||||
&:hover {
|
||||
& > * {
|
||||
background: ${grey.primary}44;
|
||||
}
|
||||
}
|
||||
[data-slot='badge'] {
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const QueryChipItem = styled(Badge).attrs({
|
||||
color: 'secondary' as const,
|
||||
variant: 'solid' as const,
|
||||
textTransform: 'none' as const,
|
||||
})`
|
||||
margin-right: 0.1rem;
|
||||
`;
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ILabelRecord {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
import { ILabelRecord } from './types';
|
||||
|
||||
const hiddenLabels = ['severity', 'description'];
|
||||
|
||||
export const createQuery = (
|
||||
selectedItems: Array<string | string[]> = [],
|
||||
): ILabelRecord | null => {
|
||||
if (selectedItems.length === 2) {
|
||||
return {
|
||||
key: selectedItems[0] as string,
|
||||
value: selectedItems[1] as string,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const flattenLabels = (labels: Labels): ILabelRecord[] => {
|
||||
const recs: ILabelRecord[] = [];
|
||||
|
||||
Object.keys(labels).forEach((key) => {
|
||||
if (!hiddenLabels.includes(key)) {
|
||||
recs.push({
|
||||
key,
|
||||
value: labels[key] || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return recs;
|
||||
};
|
||||
|
||||
export const prepareLabels = (
|
||||
recs: ILabelRecord[],
|
||||
alertLabels: Labels | undefined,
|
||||
): Labels => {
|
||||
const labels: Labels = {};
|
||||
|
||||
recs.forEach((rec) => {
|
||||
if (!hiddenLabels.includes(rec.key)) {
|
||||
labels[rec.key] = rec.value;
|
||||
}
|
||||
});
|
||||
if (alertLabels) {
|
||||
Object.keys(alertLabels).forEach((key) => {
|
||||
if (hiddenLabels.includes(key)) {
|
||||
labels[key] = alertLabels[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Button, Card, Col, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Item } = Form;
|
||||
|
||||
export const StyledLeftContainer = styled(Col)`
|
||||
&&& {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const MainFormContainer = styled(Form)``;
|
||||
|
||||
export const ButtonContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionButton = styled(Button)`
|
||||
margin-right: 1rem;
|
||||
`;
|
||||
|
||||
export const QueryButton = styled(Button)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const QueryContainer = styled(Card)`
|
||||
&&& {
|
||||
margin-top: 1rem;
|
||||
min-height: 23.5%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const StepHeading = styled.p`
|
||||
margin-top: 1rem;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
export const InlineSelect = styled(Select)`
|
||||
display: inline-block;
|
||||
width: 10% !important;
|
||||
margin-left: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
`;
|
||||
|
||||
export const SeveritySelect = styled(Select)`
|
||||
width: 25% !important;
|
||||
`;
|
||||
|
||||
export const VerticalLine = styled.div`
|
||||
border-left: 2px solid #e8e8e8; /* Adjust color and thickness as desired */
|
||||
padding-left: 20px; /* Adjust spacing to content as needed */
|
||||
margin-left: 20px; /* Adjust margin as desired */
|
||||
height: 100%; /* Adjust based on your layout needs */
|
||||
`;
|
||||
|
||||
export const InputSmall = styled(Input)`
|
||||
width: 40% !important;
|
||||
`;
|
||||
|
||||
export const FormContainer = styled(Card)`
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 4px;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TextareaMedium = styled(TextArea)`
|
||||
width: 70%;
|
||||
`;
|
||||
|
||||
export const FormItemMedium = styled(Item)`
|
||||
width: 70%;
|
||||
`;
|
||||
|
||||
export const ChannelSelectTip = styled(Typography.Text)`
|
||||
color: hsla(0, 0%, 100%, 0.3);
|
||||
`;
|
||||
|
||||
export const StepContainer = styled.div`
|
||||
margin-top: 2rem;
|
||||
`;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { ThresholdProps } from 'types/api/widgets/threshold';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const THRESHOLD_COLORS_SORTING_ORDER = ['Red', 'Orange', 'Green', 'Blue'];
|
||||
|
||||
export const usePrefillAlertConditions = (
|
||||
stagedQuery: Query | null,
|
||||
): {
|
||||
matchType: string | null;
|
||||
op: string | null;
|
||||
target: number | undefined;
|
||||
targetUnit: string | undefined;
|
||||
} => {
|
||||
const location = useAppLocation();
|
||||
|
||||
// Extract and set match type
|
||||
const reduceTo = useMemo(() => {
|
||||
if (!stagedQuery) {
|
||||
return null;
|
||||
}
|
||||
const isSameTimeAggregation = stagedQuery.builder.queryData.every(
|
||||
(queryData) =>
|
||||
queryData?.reduceTo === stagedQuery.builder.queryData[0]?.reduceTo,
|
||||
);
|
||||
return isSameTimeAggregation
|
||||
? stagedQuery.builder.queryData[0]?.reduceTo
|
||||
: null;
|
||||
}, [stagedQuery]);
|
||||
|
||||
const matchType = useMemo(() => {
|
||||
switch (reduceTo) {
|
||||
case 'avg':
|
||||
return '3';
|
||||
case 'sum':
|
||||
return '4';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [reduceTo]);
|
||||
|
||||
// Extract and set threshold operator, value and unit
|
||||
const threshold = useMemo(() => {
|
||||
const { thresholds } = (location.state as {
|
||||
thresholds: ThresholdProps[];
|
||||
}) || {
|
||||
thresholds: null,
|
||||
};
|
||||
if (!thresholds || thresholds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sortedThresholds = thresholds.sort((a, b) => {
|
||||
const aIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
|
||||
a.thresholdColor || '',
|
||||
);
|
||||
const bIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
|
||||
b.thresholdColor || '',
|
||||
);
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
return sortedThresholds[0];
|
||||
}, [location.state]);
|
||||
|
||||
const thresholdOperator = useMemo(() => {
|
||||
const op = threshold?.thresholdOperator;
|
||||
switch (op) {
|
||||
case '>':
|
||||
case '>=':
|
||||
return '1';
|
||||
case '<':
|
||||
case '<=':
|
||||
return '2';
|
||||
case '=':
|
||||
return '3';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [threshold]);
|
||||
|
||||
const thresholdUnit = useMemo(() => threshold?.thresholdUnit, [threshold]);
|
||||
|
||||
const thresholdValue = useMemo(() => threshold?.thresholdValue, [threshold]);
|
||||
|
||||
return {
|
||||
matchType,
|
||||
op: thresholdOperator,
|
||||
target: thresholdValue,
|
||||
targetUnit: thresholdUnit,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
// Write a test for getUpdatedStepInterval function in src/container/FormAlertRules/utils.ts
|
||||
|
||||
import { getUpdatedStepInterval } from './utils';
|
||||
|
||||
describe('getUpdatedStepInterval', () => {
|
||||
it('should return 60', () => {
|
||||
const result = getUpdatedStepInterval('5m0s');
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
it('should return 60 for 10m0s', () => {
|
||||
const result = getUpdatedStepInterval('10m0s');
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import getStep from 'lib/getStep';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
@@ -10,48 +7,6 @@ import {
|
||||
IPromQLQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// toChartInterval converts eval window to chart selection time interval
|
||||
export const toChartInterval = (evalWindow: string | undefined): Time => {
|
||||
switch (evalWindow) {
|
||||
case '1m0s':
|
||||
return '1m';
|
||||
case '5m0s':
|
||||
return '5m';
|
||||
case '10m0s':
|
||||
return '10m';
|
||||
case '15m0s':
|
||||
return '15m';
|
||||
case '30m0s':
|
||||
return '30m';
|
||||
case '1h0m0s':
|
||||
return '1h';
|
||||
case '3h0m0s':
|
||||
return '3h';
|
||||
case '4h0m0s':
|
||||
return '4h';
|
||||
case '6h0m0s':
|
||||
return '6h';
|
||||
case '12h0m0s':
|
||||
return '12h';
|
||||
case '24h0m0s':
|
||||
return '1d';
|
||||
default:
|
||||
return '5m';
|
||||
}
|
||||
};
|
||||
|
||||
export const getUpdatedStepInterval = (evalWindow?: string): number => {
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval: toChartInterval(evalWindow),
|
||||
});
|
||||
return getStep({
|
||||
start,
|
||||
end,
|
||||
inputFormat: 'ns',
|
||||
});
|
||||
};
|
||||
|
||||
export const getSelectedQueryOptions = (
|
||||
queries:
|
||||
| Array<
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import { Filters } from 'components/AlertDetailsFilters/Filters';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
import AlertHeader from './AlertHeader/AlertHeader';
|
||||
@@ -79,8 +77,6 @@ function AlertDetails(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const isV2Alert = alertRuleDetails?.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
// Show spinner until we have alert data loaded
|
||||
if (isLoading && !alertRuleDetails) {
|
||||
return <Spinner />;
|
||||
@@ -94,9 +90,9 @@ function AlertDetails(): JSX.Element {
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
className="alert-details alert-details-v2"
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
data-schema-version={alertRuleDetails?.schemaVersion}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -5,20 +5,16 @@ import { Divider } from '@signozhq/ui/divider';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Copy, Ellipsis, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
useAlertRuleClone,
|
||||
useAlertRuleDelete,
|
||||
useAlertRuleStatusToggle,
|
||||
useAlertRuleUpdate,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
import { AlertHeaderProps } from '../AlertHeader';
|
||||
import RenameModal from './RenameModal';
|
||||
|
||||
import './ActionButtons.styles.scss';
|
||||
|
||||
@@ -29,45 +25,14 @@ function AlertActionButtons({
|
||||
ruleId: string;
|
||||
alertDetails: AlertHeaderProps['alertDetails'];
|
||||
}): JSX.Element {
|
||||
const { alertRuleState, setAlertRuleState, alertRuleName, setAlertRuleName } =
|
||||
useAlertRule();
|
||||
const [intermediateName, setIntermediateName] = useState<string>(
|
||||
alertDetails.alert,
|
||||
);
|
||||
const [isRenameAlertOpen, setIsRenameAlertOpen] = useState<boolean>(false);
|
||||
const { alertRuleState, setAlertRuleState } = useAlertRule();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
|
||||
const { handleAlertClone } = useAlertRuleClone({ ruleId });
|
||||
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
|
||||
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
|
||||
alertDetails: alertDetails as unknown as AlertDef,
|
||||
setAlertRuleName,
|
||||
intermediateName,
|
||||
});
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
setIsRenameAlertOpen(true);
|
||||
}, []);
|
||||
|
||||
const onNameChangeHandler = useCallback(() => {
|
||||
handleAlertUpdate();
|
||||
setIsRenameAlertOpen(false);
|
||||
}, [handleAlertUpdate]);
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const menuItems: DropdownItemType[] = [];
|
||||
if (!isV2Alert) {
|
||||
menuItems.push({
|
||||
type: 'item',
|
||||
value: 'rename-rule',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleRename,
|
||||
});
|
||||
}
|
||||
menuItems.push(
|
||||
const menuItems: DropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'clone-rule',
|
||||
@@ -83,7 +48,7 @@ function AlertActionButtons({
|
||||
onClick: handleAlertDelete,
|
||||
danger: true,
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
// state for immediate UI feedback rather than waiting for onSuccess of handleAlertStateTiggle to updating the alertRuleState
|
||||
const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState<
|
||||
@@ -97,12 +62,6 @@ function AlertActionButtons({
|
||||
}
|
||||
}, [setAlertRuleState, alertRuleState, alertDetails.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (alertRuleName !== undefined) {
|
||||
setIntermediateName(alertRuleName);
|
||||
}
|
||||
}, [alertRuleName]);
|
||||
|
||||
// on unmount remove the alert state
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => (): void => setAlertRuleState(undefined), []);
|
||||
@@ -113,50 +72,39 @@ function AlertActionButtons({
|
||||
}, [handleAlertStateToggle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
<Divider type="vertical" className="alert-action-buttons__divider" />
|
||||
|
||||
<Tooltip title="More options">
|
||||
<span className="dropdown-trigger-wrapper">
|
||||
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
<Divider type="vertical" className="alert-action-buttons__divider" />
|
||||
|
||||
<Tooltip title="More options">
|
||||
<span className="dropdown-trigger-wrapper">
|
||||
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<RenameModal
|
||||
isOpen={isRenameAlertOpen}
|
||||
setIsOpen={setIsRenameAlertOpen}
|
||||
isLoading={isLoading}
|
||||
onNameChangeHandler={onNameChangeHandler}
|
||||
intermediateName={intermediateName}
|
||||
setIntermediateName={setIntermediateName}
|
||||
/>
|
||||
</>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
.rename-alert {
|
||||
.ant-modal-content {
|
||||
width: 384px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0px;
|
||||
|
||||
.ant-modal-header {
|
||||
height: 52px;
|
||||
padding: 16px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
margin-bottom: 0px;
|
||||
.ant-modal-title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
width: 349px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 16px;
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.name-text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.alert-name-input {
|
||||
display: flex;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: 16px;
|
||||
margin-top: 0px;
|
||||
.alert-rename {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 12px;
|
||||
|
||||
.cancel-btn {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.rename-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-background);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Button, Input, InputRef, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
|
||||
import './RenameModal.styles.scss';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
onNameChangeHandler: () => void;
|
||||
isLoading: boolean;
|
||||
intermediateName: string;
|
||||
setIntermediateName: (name: string) => void;
|
||||
};
|
||||
|
||||
function RenameModal({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
onNameChangeHandler,
|
||||
isLoading,
|
||||
intermediateName,
|
||||
setIntermediateName,
|
||||
}: Props): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = useCallback((): void => setIsOpen(false), [setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (isOpen) {
|
||||
if (e.key === 'Enter') {
|
||||
onNameChangeHandler();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onNameChangeHandler, handleClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
title="Rename Alert"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={handleClose}
|
||||
rootClassName="rename-alert"
|
||||
footer={
|
||||
<div className="alert-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Rename Alert
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="alert-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid="alert-name"
|
||||
className="alert-name-input"
|
||||
value={intermediateName}
|
||||
onChange={(e): void => setIntermediateName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameModal;
|
||||
@@ -7,35 +7,4 @@
|
||||
.alert-header {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 54px;
|
||||
flex: 1;
|
||||
|
||||
.top-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.alert-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.alert-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.08px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.bottom-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import AlertActionButtons from './ActionButtons/ActionButtons';
|
||||
import AlertLabels from './AlertLabels/AlertLabels';
|
||||
import AlertSeverity from './AlertSeverity/AlertSeverity';
|
||||
import AlertState from './AlertState/AlertState';
|
||||
|
||||
import './AlertHeader.styles.scss';
|
||||
|
||||
@@ -19,8 +12,8 @@ export type AlertHeaderProps = {
|
||||
alertDetails: RuletypesRuleDTO | PostableAlertRuleV2;
|
||||
};
|
||||
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
const { state, alert: alertName, labels } = alertDetails;
|
||||
const { alertRuleState, alertRuleName, setAlertRuleName } = useAlertRule();
|
||||
const { alert: alertName } = alertDetails;
|
||||
const { alertRuleName, setAlertRuleName } = useAlertRule();
|
||||
|
||||
useEffect(() => {
|
||||
if (alertRuleName === undefined && alertName) {
|
||||
@@ -30,50 +23,9 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
|
||||
useEffect(() => (): void => setAlertRuleName(undefined), [setAlertRuleName]);
|
||||
|
||||
const displayName = alertRuleName ?? alertName;
|
||||
|
||||
const labelsWithoutSeverity = useMemo(() => {
|
||||
if (labels) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(labels).filter(([key]) => key !== 'severity'),
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}, [labels]);
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const CreateAlertV1Header = (
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState
|
||||
state={alertRuleState ?? state ?? ''}
|
||||
testId="alert-header-state"
|
||||
/>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && (
|
||||
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
|
||||
)}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="alert-info">
|
||||
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
|
||||
<CreateAlertV2Header />
|
||||
<div className="alert-info__action-buttons">
|
||||
<AlertActionButtons
|
||||
alertDetails={alertDetails}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
@mixin severity-styles($background, $text-color) {
|
||||
.alert-severity__icon {
|
||||
background: $background;
|
||||
}
|
||||
.alert-severity__text {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-severity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
overflow: hidden;
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
border-radius: 3.5px;
|
||||
}
|
||||
&__text {
|
||||
color: var(--text-sakura-400);
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&--critical,
|
||||
&--error {
|
||||
@include severity-styles(
|
||||
color-mix(in srgb, var(--bg-sakura-400) 20%, transparent),
|
||||
var(--text-sakura-400)
|
||||
);
|
||||
}
|
||||
&--warning {
|
||||
@include severity-styles(
|
||||
color-mix(in srgb, var(--bg-amber-400) 20%, transparent),
|
||||
var(--text-amber-400)
|
||||
);
|
||||
}
|
||||
&--info {
|
||||
@include severity-styles(
|
||||
color-mix(in srgb, var(--bg-robin-400) 20%, transparent),
|
||||
var(--text-robin-400)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
|
||||
import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
|
||||
import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
|
||||
import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
|
||||
|
||||
import './AlertSeverity.styles.scss';
|
||||
|
||||
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
critical: {
|
||||
text: 'Critical',
|
||||
className: 'alert-severity--critical',
|
||||
icon: <SeverityCriticalIcon />,
|
||||
},
|
||||
error: {
|
||||
text: 'Error',
|
||||
className: 'alert-severity--error',
|
||||
icon: <SeverityErrorIcon />,
|
||||
},
|
||||
warning: {
|
||||
text: 'Warning',
|
||||
className: 'alert-severity--warning',
|
||||
icon: <SeverityWarningIcon />,
|
||||
},
|
||||
info: {
|
||||
text: 'Info',
|
||||
className: 'alert-severity--info',
|
||||
icon: <SeverityInfoIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
testId,
|
||||
}: {
|
||||
severity: string;
|
||||
testId?: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
return severityConfig[severity];
|
||||
}
|
||||
|
||||
Sentry.captureEvent({
|
||||
message: `Received unknown severity on Alert Details: ${severity}`,
|
||||
level: 'error',
|
||||
});
|
||||
|
||||
return {
|
||||
text: severity,
|
||||
className: 'alert-severity--info',
|
||||
icon: <SeverityInfoIcon />,
|
||||
};
|
||||
}, [severity]);
|
||||
return (
|
||||
<div
|
||||
className={`alert-severity ${severityDetails.className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
<div className="alert-severity__text">{severityDetails.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertSeverity.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
@@ -7,11 +7,9 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
cloneRuleByID,
|
||||
deleteRuleByID,
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
useGetRuleHistoryStats,
|
||||
@@ -52,8 +50,7 @@ import EditRules from 'pages/EditRules';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
|
||||
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import APIError from 'types/api/error';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -518,60 +515,6 @@ export const useAlertRuleClone = ({
|
||||
return { handleAlertClone };
|
||||
};
|
||||
|
||||
export const useAlertRuleUpdate = ({
|
||||
alertDetails,
|
||||
setAlertRuleName,
|
||||
intermediateName,
|
||||
}: {
|
||||
alertDetails: AlertDef;
|
||||
setAlertRuleName: (name: string | undefined) => void;
|
||||
intermediateName: string;
|
||||
}): {
|
||||
handleAlertUpdate: () => void;
|
||||
isLoading: boolean;
|
||||
} => {
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: updateAlertRule, isLoading } = useMutation(
|
||||
[REACT_QUERY_KEY.UPDATE_ALERT_RULE, alertDetails.id],
|
||||
(args: { data: AlertDef; id: string }) =>
|
||||
updateRuleByID({ id: args.id }, toPostableRuleDTOFromAlertDef(args.data)),
|
||||
{
|
||||
onMutate: () => setAlertRuleName(intermediateName),
|
||||
onSuccess: () => {
|
||||
const ruleId = alertDetails.id || '';
|
||||
const ruleQueryKey = getGetRuleByIDQueryKey({ id: ruleId });
|
||||
const existingRule = queryClient.getQueryData<GetRuleByID200>(ruleQueryKey);
|
||||
if (existingRule) {
|
||||
queryClient.setQueryData<GetRuleByID200>(ruleQueryKey, {
|
||||
...existingRule,
|
||||
data: { ...existingRule.data, alert: intermediateName },
|
||||
});
|
||||
}
|
||||
void invalidateListRules(queryClient);
|
||||
notifications.success({ message: 'Alert renamed successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
setAlertRuleName(alertDetails.alert);
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleAlertUpdate = (): void => {
|
||||
updateAlertRule({
|
||||
data: { ...alertDetails, alert: intermediateName },
|
||||
id: alertDetails.id || '',
|
||||
});
|
||||
};
|
||||
|
||||
return { handleAlertUpdate, isLoading };
|
||||
};
|
||||
|
||||
export const useAlertRuleDelete = ({
|
||||
ruleId,
|
||||
}: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
@@ -23,11 +23,9 @@ import {
|
||||
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
FIRST_RULE_NAME,
|
||||
type AlertSchema,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
@@ -77,13 +75,6 @@ export const alertHistoryMocks = defineStoryMocks({
|
||||
value: 30,
|
||||
max: 60,
|
||||
}),
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: TIMELINE,
|
||||
description:
|
||||
'Which form the Overview tab opens the rule in. The history tab only shows it in the breadcrumb and the header.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
@@ -142,7 +133,6 @@ export const alertHistoryMocks = defineStoryMocks({
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,13 +11,11 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
RULE_KINDS,
|
||||
RULE_STATE_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
type AlertSchema,
|
||||
type RuleKind,
|
||||
type RuleStateChoice,
|
||||
type SeverityChoice,
|
||||
@@ -41,13 +39,6 @@ type TestNotification = (typeof TEST_NOTIFICATIONS)[number];
|
||||
|
||||
export const alertOverviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`v2` opens the stepper the new alert form uses; `classic` is the single-form page rules written before it still open in.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
ruleKind: choiceControl<RuleKind>('Rule kind', {
|
||||
group: RULE,
|
||||
description:
|
||||
@@ -94,7 +85,6 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
schema: values.alertSchema,
|
||||
kind: values.ruleKind,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -39,11 +39,6 @@ type Story = StoryObj<AlertOverviewArgs>;
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A rule written before the current schema, which opens in the classic form. */
|
||||
export const ClassicSchema: Story = {
|
||||
args: { alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An anomaly rule in the current schema: the condition reads in deviations from
|
||||
* the forecast, and the preview draws the band the rule compares against.
|
||||
@@ -68,11 +63,6 @@ export const AnomalyRuleForecastBand: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** An anomaly rule written before the current schema, still in the classic form. */
|
||||
export const ClassicAnomalyRule: Story = {
|
||||
args: { ruleKind: 'anomaly', alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/** A rule someone turned off: the toggle in the header is what turns it back on. */
|
||||
export const Disabled: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
|
||||
@@ -70,10 +70,6 @@ export const RULE_STATE_CHOICES = ['mixed', ...RULE_STATES] as const;
|
||||
|
||||
export type RuleStateChoice = (typeof RULE_STATE_CHOICES)[number];
|
||||
|
||||
export const ALERT_SCHEMAS = ['v2', 'classic'] as const;
|
||||
|
||||
export type AlertSchema = (typeof ALERT_SCHEMAS)[number];
|
||||
|
||||
export const RULE_KINDS = ['threshold', 'anomaly'] as const;
|
||||
|
||||
export type RuleKind = (typeof RULE_KINDS)[number];
|
||||
@@ -355,7 +351,6 @@ const ruleName = (index: number): string => {
|
||||
export interface RuleShape {
|
||||
severity: SeverityChoice;
|
||||
state: RuleStateChoice;
|
||||
schema?: AlertSchema;
|
||||
kind?: RuleKind;
|
||||
}
|
||||
|
||||
@@ -396,10 +391,7 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
|
||||
updatedAt: ago((index + 1) * HOUR),
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedBy: 'grace@signoz.io',
|
||||
// classic rows predate schemaVersion, which the spec now requires
|
||||
schemaVersion: (shape.schema === 'classic'
|
||||
? undefined
|
||||
: RuletypesRuleDTOSchemaVersion.v2alpha1) as RuletypesRuleDTOSchemaVersion,
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
|
||||
version: 'v5',
|
||||
source: 'http://localhost/alerts',
|
||||
preferredChannels: ['ops-slack'],
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { rest } from 'msw';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -28,8 +28,7 @@ import {
|
||||
/**
|
||||
* Which alert the page is building. The page reads this off the URL, so the
|
||||
* control is a route rather than a response: with no type at all it stays on
|
||||
* the picker, `classic-form` opts back into the classic form, and everything else
|
||||
* opens the current one.
|
||||
* the picker and everything else opens the form.
|
||||
*/
|
||||
const ALERT_MODES = [
|
||||
'select-type',
|
||||
@@ -38,7 +37,6 @@ const ALERT_MODES = [
|
||||
'traces',
|
||||
'exceptions',
|
||||
'anomaly',
|
||||
'classic-form',
|
||||
] as const;
|
||||
|
||||
type AlertMode = (typeof ALERT_MODES)[number];
|
||||
@@ -49,7 +47,6 @@ const ALERT_TYPE_BY_MODE: Partial<Record<AlertMode, AlertTypes>> = {
|
||||
traces: AlertTypes.TRACES_BASED_ALERT,
|
||||
exceptions: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
anomaly: AlertTypes.METRICS_BASED_ALERT,
|
||||
'classic-form': AlertTypes.METRICS_BASED_ALERT,
|
||||
};
|
||||
|
||||
const routeFor = (mode: AlertMode): string => {
|
||||
@@ -63,15 +60,11 @@ const routeFor = (mode: AlertMode): string => {
|
||||
[QueryParams.alertType]: alertType,
|
||||
[QueryParams.ruleType]:
|
||||
mode === 'anomaly'
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
? RuletypesRuleTypeDTO.anomaly_rule
|
||||
: RuletypesRuleTypeDTO.threshold_rule,
|
||||
[QueryParams.relativeTime]: '6h',
|
||||
});
|
||||
|
||||
if (mode === 'classic-form') {
|
||||
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
}
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -81,11 +81,6 @@ export const AnomalyAlertRoutingPolicies: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The classic form, which `showClassicCreateAlertsPage` opts back into. */
|
||||
export const ClassicForm: Story = {
|
||||
args: { alertMode: 'classic-form' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The match-type tooltip on the threshold sentence: a paragraph on what an
|
||||
* aggregated data point is, a worked example over five of them, and the docs
|
||||
|
||||
@@ -11,19 +11,12 @@ import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EditRulesContainer from 'container/EditRules';
|
||||
import EditAlertV2 from 'container/EditAlertV2';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import {
|
||||
fromRuleDTOToAlertDef,
|
||||
fromRuleDTOToPostableRuleV2,
|
||||
} from 'types/api/alerts/convert';
|
||||
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
import './EditRules.styles.scss';
|
||||
|
||||
@@ -96,17 +89,13 @@ function EditRules(): JSX.Element {
|
||||
return <Spinner tip="Loading Rules..." />;
|
||||
}
|
||||
|
||||
let initialV2AlertValue: PostableAlertRuleV2 | null = null;
|
||||
if (ruleData.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
|
||||
initialV2AlertValue = fromRuleDTOToPostableRuleV2(ruleData);
|
||||
}
|
||||
const initialAlert = fromRuleDTOToPostableRuleV2(ruleData);
|
||||
|
||||
return (
|
||||
<div className="edit-rules-container">
|
||||
<EditRulesContainer
|
||||
ruleId={ruleId || ''}
|
||||
initialValue={fromRuleDTOToAlertDef(ruleData)}
|
||||
initialV2AlertValue={initialV2AlertValue}
|
||||
<EditAlertV2
|
||||
initialAlert={initialAlert}
|
||||
alertType={initialAlert.alertType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
alertFieldKeysResponse,
|
||||
alertFieldValuesResponse,
|
||||
alertMetricMetadataResponse,
|
||||
alertMetricsResponse,
|
||||
alertPreviewSeries,
|
||||
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
type AlertSchema,
|
||||
} from '../../AlertList/stories/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
|
||||
const RULE = 'Edit rule · rule';
|
||||
|
||||
export const editRulesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`classic` is the single-form page this route was built for. `v2` throws: the new form reads `CreateAlertProvider`, which only `pages/AlertDetails` mounts, so this route crashes on any rule saved on the current schema.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'classic',
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: RULE,
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
previewSeries: countControl('Preview series', {
|
||||
group: RULE,
|
||||
description: 'Lines the chart above the condition draws.',
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id',
|
||||
response.json((req) =>
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: 'critical',
|
||||
state: 'firing',
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
response.json(() => notificationChannelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
alertFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `${ROUTES.EDIT_ALERTS}?ruleId=${STORY_RULE_ID}&relativeTime=6h`,
|
||||
}),
|
||||
});
|
||||
|
||||
/** The page fetches the rule before it renders the form, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LABEL_INPUT = 'alert-labels-input-v1';
|
||||
|
||||
/** The labels field, scrolled into view once the rule has loaded. */
|
||||
export const findLabelInput = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const input = await within(canvasElement).findByTestId(
|
||||
LABEL_INPUT,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
input.scrollIntoView({ block: 'center' });
|
||||
return input;
|
||||
};
|
||||
|
||||
export const removeLabel = async (
|
||||
canvasElement: HTMLElement,
|
||||
key: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('button', { name: `Remove ${key}` }),
|
||||
);
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
editRulesMocks,
|
||||
findLabelInput,
|
||||
removeLabel,
|
||||
} from './EditRules.stories.mocks';
|
||||
|
||||
import EditRules from '../index';
|
||||
|
||||
type EditRulesArgs = PageStoryArgs<typeof editRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(editRulesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* An existing rule in the builder that created it, loaded from
|
||||
* `/api/v2/rules/:id`.
|
||||
*
|
||||
* Route: `/alerts/edit?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Edit',
|
||||
tags: ['play'],
|
||||
component: EditRules,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<EditRulesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<EditRulesArgs>;
|
||||
|
||||
/**
|
||||
* The alert form on its own route, without the alert-details tabs around it.
|
||||
* Metrics Explorer and the assistant link here when they send someone to a rule.
|
||||
* The rule is on the classic schema, which is the only one this route renders:
|
||||
* see the Alert schema control for what a current-schema rule does here.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The rule id in the URL does not resolve, so the page offers the way back. */
|
||||
export const RuleNotFound: Story = {
|
||||
args: { dataState: 'error' },
|
||||
// The mocked rule request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The preview chart's legend, one tooltip per series carrying the full label a
|
||||
* truncated legend entry falls back to. The Preview series control is turned up
|
||||
* to its maximum so the legend wraps to a second row, which is where a label
|
||||
* gets clipped.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true, previewSeries: 6 },
|
||||
};
|
||||
|
||||
/** The rule's own labels, each with its remove button, above the empty input. */
|
||||
export const Labels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await findLabelInput(canvasElement);
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('team: platform');
|
||||
await canvas.findByText('env: prod');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key typed and confirmed: it waits as its own chip while the input asks for its value. */
|
||||
export const LabelKeyEntered: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await userEvent.type(input, 'region{enter}');
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
'Enter a value for label key(region) then press ENTER.',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and its value confirmed, added after the rule's own labels. */
|
||||
export const LabelAdded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await userEvent.type(input, 'region{enter}');
|
||||
await userEvent.type(input, 'us-east-1{enter}');
|
||||
await within(canvasElement).findByText('region: us-east-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** One of the rule's labels removed, the other left in place. */
|
||||
export const LabelRemoved: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
await removeLabel(canvasElement, 'team');
|
||||
await waitFor(() =>
|
||||
expect(within(canvasElement).queryByText('team: platform')).toBeNull(),
|
||||
);
|
||||
await expect(within(canvasElement).getByText('env: prod')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** Every label removed: the input is back to its prompt and the clear-all button is gone. */
|
||||
export const NoLabels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
await removeLabel(canvasElement, 'team');
|
||||
await removeLabel(canvasElement, 'env');
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
'Click here to enter a label (key value pairs)',
|
||||
);
|
||||
await expect(input.parentElement?.querySelector('button')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** The confirmation the clear-all button opens before it drops every label. */
|
||||
export const ClearLabelsConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
const clearAll = input.parentElement?.querySelector('button');
|
||||
|
||||
if (!clearAll) {
|
||||
throw new Error('Clear-all labels button did not render');
|
||||
}
|
||||
|
||||
await userEvent.click(clearAll);
|
||||
await screen.findByText(
|
||||
'This action will remove all the labels. Do you want to proceed?',
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -59,8 +59,6 @@ export interface PostableAlertRuleV2 {
|
||||
};
|
||||
usePolicy?: boolean;
|
||||
};
|
||||
version?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { AlertTypes } from './alertTypes';
|
||||
import { PostableAlertRuleV2 } from './alertTypesV2';
|
||||
import { ICompositeMetricQuery } from './compositeQuery';
|
||||
import { AlertDef, Labels } from './def';
|
||||
import { Labels } from './def';
|
||||
|
||||
function toRuleTypeDTO(ruleType: string | undefined): RuletypesRuleTypeDTO {
|
||||
switch (ruleType) {
|
||||
@@ -69,7 +69,10 @@ function stripUndefinedLabels(
|
||||
export function toPostableRuleDTO(
|
||||
local: PostableAlertRuleV2,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
const payload: Record<
|
||||
Exclude<keyof RuletypesPostableRuleDTO, 'source' | 'version'>,
|
||||
any
|
||||
> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
@@ -83,49 +86,18 @@ export function toPostableRuleDTO(
|
||||
notificationSettings: local.notificationSettings,
|
||||
evaluation: local.evaluation,
|
||||
schemaVersion: local.schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
}
|
||||
|
||||
export function toPostableRuleDTOFromAlertDef(
|
||||
local: AlertDef,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
condition: local.condition,
|
||||
annotations: local.annotations,
|
||||
labels: stripUndefinedLabels(local.labels),
|
||||
evalWindow: local.evalWindow,
|
||||
frequency: local.frequency,
|
||||
preferredChannels: local.preferredChannels,
|
||||
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.notificationSettings,
|
||||
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
|
||||
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToPostableRuleV2(
|
||||
dto: RuletypesRuleDTO,
|
||||
): PostableAlertRuleV2 {
|
||||
return dto as unknown as PostableAlertRuleV2;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToAlertDef(dto: RuletypesRuleDTO): AlertDef {
|
||||
return dto as unknown as AlertDef;
|
||||
}
|
||||
|
||||
function toEQueryType(queryType: RuletypesQueryTypeDTO): EQueryType {
|
||||
switch (queryType) {
|
||||
case RuletypesQueryTypeDTO.builder:
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
@@ -239,37 +237,6 @@ func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (store *config) GetMatchers(ctx context.Context, orgID string) (map[string][]string, error) {
|
||||
type matcher struct {
|
||||
bun.BaseModel `bun:"table:rule"`
|
||||
ID valuer.UUID `bun:"id,pk"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
matchers := []matcher{}
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Column("id", "data").
|
||||
Model(&matchers).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchersMap := make(map[string][]string)
|
||||
for _, matcher := range matchers {
|
||||
receivers := gjson.Get(matcher.Data, "preferredChannels").Array()
|
||||
for _, receiver := range receivers {
|
||||
matchersMap[matcher.ID.StringValue()] = append(matchersMap[matcher.ID.StringValue()], receiver.String())
|
||||
}
|
||||
}
|
||||
|
||||
return matchersMap, nil
|
||||
}
|
||||
|
||||
func (store *config) wrap(ctx context.Context, fn func(ctx context.Context) error, opts ...alertmanagertypes.StoreOption) error {
|
||||
storeOpts := alertmanagertypes.NewStoreOptions(opts...)
|
||||
|
||||
|
||||
@@ -245,25 +245,13 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchers, err := service.configStore.GetMatchers(ctx, incomingConfig.StoreableConfig().OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := alertmanagertypes.NewConfigFromChannels(service.config.Global, service.config.Route, channels, incomingConfig.StoreableConfig().OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for ruleID, receivers := range matchers {
|
||||
err = config.CreateRuleIDMatcher(ruleID, receivers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if incomingConfig.StoreableConfig().Hash != config.StoreableConfig().Hash {
|
||||
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels and matchers")
|
||||
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels")
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,20 +14,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
|
||||
ID: "ListRules",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
|
||||
Response: make([]*ruletypes.Rule, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
|
||||
@@ -59,11 +59,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
}
|
||||
}
|
||||
if builder.evaluation == nil {
|
||||
evalWindow := rule.EvalWindow
|
||||
if evalWindow.IsZero() {
|
||||
evalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
|
||||
return nil
|
||||
}
|
||||
|
||||
return builder
|
||||
|
||||
@@ -3,10 +3,8 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -360,14 +358,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.editRule)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
|
||||
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v1/explorer/views", am.ViewAccess(aH.Signoz.Handlers.SavedView.List)).Methods(http.MethodGet)
|
||||
@@ -496,166 +486,6 @@ func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rules, err := aH.ruleManager.ListRuleStates(r.Context())
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// todo(amol): need to add sorter
|
||||
|
||||
aH.Respond(w, rules)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ruleResponse, err := aH.ruleManager.GetRule(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, ruleResponse)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for create rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
|
||||
if err != nil {
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, rule)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for edit rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
err = aH.ruleManager.EditRule(r.Context(), string(body), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, "rule successfully edited")
|
||||
}
|
||||
|
||||
func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
err := aH.ruleManager.DeleteRule(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, "rule successfully deleted")
|
||||
}
|
||||
|
||||
func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for patch rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
gettableRule, err := aH.ruleManager.PatchRule(r.Context(), string(body), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, gettableRule)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for test rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
|
||||
if err != nil {
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"alertCount": alertCount,
|
||||
"message": "notification sent",
|
||||
}
|
||||
aH.Respond(w, response)
|
||||
}
|
||||
|
||||
func prepareQuery(r *http.Request) (string, error) {
|
||||
var postData *model.DashboardVars
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ type BaseRule struct {
|
||||
ruleCondition *ruletypes.RuleCondition
|
||||
|
||||
Threshold ruletypes.RuleThreshold
|
||||
// evalWindow is the time window used for evaluating the rule
|
||||
// i.e. each time we lookback from the current time, we look at data for the last
|
||||
// evalWindow duration
|
||||
evalWindow valuer.TextDuration
|
||||
// holdDuration is the duration for which the alert waits before firing
|
||||
holdDuration valuer.TextDuration
|
||||
|
||||
@@ -49,10 +45,7 @@ type BaseRule struct {
|
||||
// these are the same for all alerts created for this rule
|
||||
labels ruletypes.Labels
|
||||
annotations ruletypes.Labels
|
||||
// preferredChannels is the list of channels to send the alert to
|
||||
// if the rule is triggered
|
||||
preferredChannels []string
|
||||
mtx sync.Mutex
|
||||
mtx sync.Mutex
|
||||
// the time it took to evaluate the rule (most recent evaluation)
|
||||
evaluationDuration time.Duration
|
||||
// the timestamp of the last evaluation
|
||||
@@ -151,26 +144,27 @@ func NewBaseRule(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := threshold.Validate(); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
|
||||
}
|
||||
evaluation, err := p.Evaluation.GetEvaluation()
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err)
|
||||
}
|
||||
|
||||
baseRule := &BaseRule{
|
||||
id: id,
|
||||
orgID: orgID,
|
||||
externalURL: externalURL,
|
||||
name: p.AlertName.StringValue(),
|
||||
typ: p.AlertType,
|
||||
ruleCondition: p.RuleCondition,
|
||||
evalWindow: p.EvalWindow,
|
||||
labels: ruletypes.FromMap(p.Labels),
|
||||
annotations: ruletypes.FromMap(p.Annotations),
|
||||
preferredChannels: p.PreferredChannels,
|
||||
health: ruletypes.HealthUnknown,
|
||||
Active: map[uint64]*ruletypes.Alert{},
|
||||
Threshold: threshold,
|
||||
evaluation: evaluation,
|
||||
id: id,
|
||||
orgID: orgID,
|
||||
externalURL: externalURL,
|
||||
name: p.AlertName.StringValue(),
|
||||
typ: p.AlertType,
|
||||
ruleCondition: p.RuleCondition,
|
||||
labels: ruletypes.FromMap(p.Labels),
|
||||
annotations: ruletypes.FromMap(p.Annotations),
|
||||
health: ruletypes.HealthUnknown,
|
||||
Active: map[uint64]*ruletypes.Alert{},
|
||||
Threshold: threshold,
|
||||
evaluation: evaluation,
|
||||
}
|
||||
|
||||
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
|
||||
@@ -178,10 +172,6 @@ func NewBaseRule(
|
||||
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
|
||||
}
|
||||
|
||||
if baseRule.evalWindow.IsZero() {
|
||||
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(baseRule)
|
||||
}
|
||||
@@ -232,10 +222,6 @@ func (r *BaseRule) EvalDelay() valuer.TextDuration {
|
||||
return r.evalDelay
|
||||
}
|
||||
|
||||
func (r *BaseRule) EvalWindow() valuer.TextDuration {
|
||||
return r.evalWindow
|
||||
}
|
||||
|
||||
func (r *BaseRule) HoldDuration() valuer.TextDuration {
|
||||
return r.holdDuration
|
||||
}
|
||||
@@ -246,7 +232,6 @@ func (r *BaseRule) Name() string { return r.name }
|
||||
func (r *BaseRule) Condition() *ruletypes.RuleCondition { return r.ruleCondition }
|
||||
func (r *BaseRule) Labels() ruletypes.Labels { return r.labels }
|
||||
func (r *BaseRule) Annotations() ruletypes.Labels { return r.annotations }
|
||||
func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels }
|
||||
|
||||
func (r *BaseRule) GeneratorURL() string {
|
||||
params := url.Values{}
|
||||
|
||||
@@ -1048,6 +1048,8 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
// a rule stored before selectedQueryName was required resolves it the way evaluation does
|
||||
storedRule.RuleCondition.SelectedQuery = storedRule.RuleCondition.SelectedQueryName()
|
||||
|
||||
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
|
||||
@@ -49,11 +49,10 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
Version: "v5",
|
||||
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Target: &target,
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -108,12 +107,10 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
Version: "v5",
|
||||
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
SelectedQuery: "A",
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Target: &target,
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
PanelType: ruletypes.PanelTypeGraph,
|
||||
@@ -138,7 +135,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
RecoveryTarget: recovery,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Channels: []string{"slack"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
|
||||
type PromRule struct {
|
||||
*BaseRule
|
||||
version string
|
||||
prometheus prometheus.Prometheus
|
||||
}
|
||||
|
||||
@@ -47,7 +46,6 @@ func NewPromRule(
|
||||
|
||||
p := PromRule{
|
||||
BaseRule: baseRule,
|
||||
version: postableRule.Version,
|
||||
prometheus: prometheus,
|
||||
}
|
||||
|
||||
@@ -356,12 +354,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
|
||||
func (r *PromRule) String() string {
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
PreferredChannels: r.preferredChannels,
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -690,13 +690,11 @@ func TestPromRuleEval(t *testing.T) {
|
||||
logger := instrumentationtest.New().Logger()
|
||||
|
||||
for idx, c := range cases {
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -905,11 +903,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
|
||||
|
||||
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1198,11 +1192,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
|
||||
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1259,6 +1249,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
|
||||
func TestPromRule_NoData(t *testing.T) {
|
||||
evalTime := time.Now()
|
||||
target := 200.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName("Test no data"),
|
||||
@@ -1269,8 +1260,6 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -1282,7 +1271,7 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
},
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
|
||||
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data", TargetValue: &target, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1365,10 +1354,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
AlertOnAbsent: true,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -1491,8 +1477,6 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -1562,9 +1546,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
rc := postableRule.RuleCondition
|
||||
|
||||
rc.Target = &targetForNoAlert
|
||||
target := &targetForNoAlert
|
||||
if c.alertCondition {
|
||||
rc.Target = &targetForAlert
|
||||
target = &targetForAlert
|
||||
}
|
||||
rc.RequireMinPoints = c.requireMinPoints
|
||||
rc.RequiredNumPoints = c.requiredNumPoints
|
||||
@@ -1573,9 +1557,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: rc.Target,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
TargetValue: target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,15 +20,12 @@ type Rule interface {
|
||||
Annotations() ruletypes.Labels
|
||||
Condition() *ruletypes.RuleCondition
|
||||
EvalDelay() valuer.TextDuration
|
||||
EvalWindow() valuer.TextDuration
|
||||
HoldDuration() valuer.TextDuration
|
||||
State() ruletypes.AlertState
|
||||
ActiveAlerts() []*ruletypes.Alert
|
||||
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint
|
||||
ActiveAlertsLabelFP() map[uint64]struct{}
|
||||
|
||||
PreferredChannels() []string
|
||||
|
||||
// Eval evaluates the rule at the given timestamp and returns the number of active alerts.
|
||||
Eval(context.Context, time.Time) (int, error)
|
||||
String() string
|
||||
|
||||
@@ -61,16 +61,16 @@ func (r *ThresholdRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request v5",
|
||||
slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
|
||||
slog.Int64("eval_window", end-start),
|
||||
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
@@ -455,12 +455,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
|
||||
func (r *ThresholdRule) String() string {
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
PreferredChannels: r.preferredChannels,
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -63,6 +63,7 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -171,10 +172,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -183,9 +181,10 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -239,10 +238,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
@@ -252,9 +248,10 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -304,10 +301,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
@@ -317,9 +311,10 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -366,10 +361,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -378,9 +370,10 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -470,6 +463,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -653,11 +647,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
ExpectQuery(queryString).
|
||||
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
||||
WillReturnRows(rows)
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -867,11 +857,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
|
||||
|
||||
querier := prepareQuerierForTraces(t, telemetryStore, keysMap)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -975,11 +961,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
|
||||
|
||||
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1083,11 +1065,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
|
||||
|
||||
querier := prepareQuerierForLogs(t, telemetryStore, keysMap)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1256,11 +1234,7 @@ func TestMultipleThresholdRule(t *testing.T) {
|
||||
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1930,8 +1904,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
@@ -2050,7 +2022,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
rc := postableRule.RuleCondition
|
||||
rc.Target = &c.target
|
||||
rc.RequireMinPoints = c.requireMinPoints
|
||||
rc.RequiredNumPoints = c.requiredNumPoints
|
||||
rc.Thresholds = &ruletypes.RuleThresholdData{
|
||||
@@ -2059,8 +2030,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
{
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package ruler
|
||||
import "net/http"
|
||||
|
||||
type Handler interface {
|
||||
ListRules(http.ResponseWriter, *http.Request)
|
||||
ListRulesV3(http.ResponseWriter, *http.Request)
|
||||
GetRuleByID(http.ResponseWriter, *http.Request)
|
||||
CreateRule(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -25,24 +25,6 @@ func NewHandler(ruler ruler.Ruler) ruler.Handler {
|
||||
return &handler{ruler: ruler}
|
||||
}
|
||||
|
||||
func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rules, err := handler.ruler.ListRuleStates(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
|
||||
for _, rule := range rules.Rules {
|
||||
view = append(view, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -271,6 +271,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewRenameSavedViewDeprecatedTraceFieldsFactory(),
|
||||
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddRuleTuplesFactory(sqlstore),
|
||||
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
603
pkg/sqlmigration/144_migrate_rules_v1_to_v2alpha1.go
Normal file
603
pkg/sqlmigration/144_migrate_rules_v1_to_v2alpha1.go
Normal file
@@ -0,0 +1,603 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
const (
|
||||
schemaVersionV2Alpha1 = "v2alpha1"
|
||||
ruleTypeThreshold = "threshold_rule"
|
||||
ruleTypeProm = "promql_rule"
|
||||
ruleTypeAnomaly = "anomaly_rule"
|
||||
queryTypeBuilder = "builder"
|
||||
queryTypePromQL = "promql"
|
||||
queryTypeClickHouseSQL = "clickhouse_sql"
|
||||
panelTypeGraph = "graph"
|
||||
alertTypeMetric = "METRIC_BASED_ALERT"
|
||||
alertTypeTraces = "TRACES_BASED_ALERT"
|
||||
alertTypeLogs = "LOGS_BASED_ALERT"
|
||||
signalLogs = "logs"
|
||||
signalTraces = "traces"
|
||||
compareOperatorBelow = "2"
|
||||
criticalThresholdName = "critical"
|
||||
labelSeverity = "severity"
|
||||
defaultEvalWindow = "5m"
|
||||
defaultFrequency = "1m"
|
||||
)
|
||||
|
||||
// The values the write path accepts; a migrated rule outside them is written
|
||||
// disabled, for the user to fix.
|
||||
var (
|
||||
supportedCompareOperators = []string{"1", "above", ">", "2", "below", "<", "3", "equal", "eq", "=", "4", "not_equal", "not_eq", "!=", "5", "above_or_equal", "above_or_eq", ">=", "6", "below_or_equal", "below_or_eq", "<=", "7", "outside_bounds"}
|
||||
supportedMatchTypes = []string{"1", "at_least_once", "2", "all_the_times", "3", "on_average", "avg", "4", "in_total", "sum", "5", "last"}
|
||||
)
|
||||
|
||||
// The values the write path accepts; a migrated row outside them cannot be
|
||||
// edited until the field is supplied, so the migration derives them.
|
||||
var (
|
||||
supportedAlertTypes = []string{"METRIC_BASED_ALERT", "TRACES_BASED_ALERT", "LOGS_BASED_ALERT", "EXCEPTIONS_BASED_ALERT", "AI_TRACES_BASED_ALERT"}
|
||||
supportedQueryTypes = []string{"builder", "clickhouse_sql", "promql"}
|
||||
supportedPanelTypes = []string{"value", "table", "graph"}
|
||||
)
|
||||
|
||||
type migrateRulesV1ToV2Alpha1 struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMigrateRulesV1ToV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("migrate_rules_v1_to_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &migrateRulesV1ToV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV1ToV2Alpha1) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up rewrites every v1 rule in every org as v2alpha1 in place. A row that does
|
||||
// not decode, or a v1 rule that cannot be rewritten, is logged and left as is;
|
||||
// a failed write aborts the run and rolls back every org.
|
||||
func (migration *migrateRulesV1ToV2Alpha1) Up(ctx context.Context, _ *bun.DB) error {
|
||||
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
var orgIDs []string
|
||||
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Table("organizations").Column("id").Scan(ctx, &orgIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
if err := migration.convertOrg(ctx, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV1ToV2Alpha1) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV1ToV2Alpha1) convertOrg(ctx context.Context, orgID string) error {
|
||||
db := migration.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
type ruleRow struct {
|
||||
bun.BaseModel `bun:"table:rule"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
allRules := make([]*ruleRow, 0)
|
||||
if err := db.NewSelect().Model(&allRules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allRoutePolicies := make([]*routePolicyRow, 0)
|
||||
if err := db.NewSelect().Model(&allRoutePolicies).Where("org_id = ?", orgID).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// the name is not unique; a v1 rule has one threshold so one row, and any
|
||||
// duplicate is as good as another
|
||||
routePolicyNameToRoute := make(map[string]*routePolicyRow)
|
||||
for _, route := range allRoutePolicies {
|
||||
routePolicyNameToRoute[route.Name] = route
|
||||
}
|
||||
|
||||
var migrated, disabled, skipped, undecodable, unmigratable int
|
||||
for _, storedRule := range allRules {
|
||||
storedRuleData := new(postableRule)
|
||||
if err := json.Unmarshal([]byte(storedRule.Data), storedRuleData); err != nil {
|
||||
undecodable++
|
||||
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if storedRuleData.SchemaVersion == schemaVersionV2Alpha1 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
storedRoute := routePolicyNameToRoute[storedRule.ID]
|
||||
if err := validateRoute(storedRoute, storedRuleData.PreferredChannels); err != nil {
|
||||
unmigratable++
|
||||
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
convertedRule, convertedRoute, err := convertRule(storedRule.ID, storedRuleData, storedRoute)
|
||||
if err != nil {
|
||||
unmigratable++
|
||||
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if cause := convertedRule.disableIfNeverFired(); cause != "" {
|
||||
disabled++
|
||||
migration.logger.WarnContext(ctx, "migrating rule as disabled, it never fired in v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), slog.String("cause", cause))
|
||||
}
|
||||
|
||||
data, err := marshalUnescaped(convertedRule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storedRule.Data = string(data)
|
||||
if _, err := db.NewUpdate().Model(storedRule).Column("data").WherePK().Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if convertedRoute != nil && convertedRoute.Expression != storedRoute.Expression {
|
||||
if _, err := db.NewUpdate().Model(convertedRoute).Column("expression").WherePK().Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
migration.logger.InfoContext(ctx, "migrated rules from v1 to v2alpha1", slog.String("org_id", orgID), slog.Int("total", len(allRules)), slog.Int("migrated", migrated), slog.Int("disabled", disabled), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable), slog.Int("unmigratable", unmigratable))
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRoute checks the rule's route_policy row, named after the rule. A
|
||||
// rule that never had channels got no row from migration 049 and migrates with
|
||||
// none; a rule with preferred channels but no row would lose them, so it is
|
||||
// left at v1.
|
||||
func validateRoute(route *routePolicyRow, preferredChannels []string) error {
|
||||
if route == nil && len(preferredChannels) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no route_policy row for a rule with %d preferred channels", len(preferredChannels))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertRule returns a v2alpha1 copy of storedRuleData, derived in the order
|
||||
// the v1 decode derived the same values, with the rule's route_policy row as it
|
||||
// must be stored alongside. Version and source are left as stored. A nil
|
||||
// storedRoute is a rule that never had channels.
|
||||
func convertRule(ruleID string, storedRuleData *postableRule, storedRoute *routePolicyRow) (*postableRule, *routePolicyRow, error) {
|
||||
if storedRuleData.RuleCondition == nil {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition in stored rule data is null")
|
||||
}
|
||||
channels := []string{}
|
||||
if storedRoute != nil {
|
||||
channels = storedRoute.Channels
|
||||
}
|
||||
|
||||
rule := storedRuleData.clone()
|
||||
rule.fillDurationDefaults()
|
||||
rule.deriveAlertType()
|
||||
rule.deriveQueryType()
|
||||
rule.repairPanelType()
|
||||
rule.disableUnmetMinPoints()
|
||||
rule.deriveRuleType()
|
||||
rule.negateAnomalyBelowTarget()
|
||||
convertedRoute := rule.repairEmptySeverity(ruleID, storedRoute)
|
||||
rule.relocateThreshold(channels)
|
||||
rule.relocateEvaluation()
|
||||
rule.synthesizeNotificationSettings()
|
||||
rule.SchemaVersion = schemaVersionV2Alpha1
|
||||
rule.clearV1Fields()
|
||||
|
||||
return rule, convertedRoute, nil
|
||||
}
|
||||
|
||||
type routePolicyRow struct {
|
||||
bun.BaseModel `bun:"table:route_policy"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Name string `bun:"name"`
|
||||
Expression string `bun:"expression"`
|
||||
Channels []string `bun:"channels,type:jsonb"`
|
||||
}
|
||||
|
||||
// postableRule mirrors PostableRule key for key, in its order. Keys the
|
||||
// crosswalk reads or writes are typed; the rest pass through as stored.
|
||||
type postableRule struct {
|
||||
AlertName json.RawMessage `json:"alert,omitempty"`
|
||||
AlertType string `json:"alertType"`
|
||||
Description json.RawMessage `json:"description,omitempty"`
|
||||
RuleType string `json:"ruleType,omitempty"`
|
||||
EvalWindow textDuration `json:"evalWindow,omitzero"`
|
||||
Frequency textDuration `json:"frequency,omitzero"`
|
||||
RuleCondition *ruleCondition `json:"condition"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Annotations json.RawMessage `json:"annotations,omitempty"`
|
||||
Disabled json.RawMessage `json:"disabled,omitempty"`
|
||||
Source json.RawMessage `json:"source,omitempty"`
|
||||
PreferredChannels []string `json:"preferredChannels,omitempty"`
|
||||
Version json.RawMessage `json:"version,omitempty"`
|
||||
Evaluation *evaluationEnvelope `json:"evaluation,omitempty"`
|
||||
SchemaVersion string `json:"schemaVersion,omitempty"`
|
||||
NotificationSettings *notificationSettings `json:"notificationSettings,omitempty"`
|
||||
}
|
||||
|
||||
type ruleCondition struct {
|
||||
CompositeQuery *compositeQuery `json:"compositeQuery"`
|
||||
CompareOperator string `json:"op,omitempty"`
|
||||
Target *float64 `json:"target,omitempty"`
|
||||
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
|
||||
AbsentFor json.RawMessage `json:"absentFor,omitempty"`
|
||||
MatchType string `json:"matchType,omitempty"`
|
||||
TargetUnit string `json:"targetUnit,omitempty"`
|
||||
Algorithm json.RawMessage `json:"algorithm,omitempty"`
|
||||
Seasonality string `json:"seasonality,omitempty"`
|
||||
SelectedQuery json.RawMessage `json:"selectedQueryName,omitempty"`
|
||||
RequireMinPoints json.RawMessage `json:"requireMinPoints,omitempty"`
|
||||
RequiredNumPoints json.RawMessage `json:"requiredNumPoints,omitempty"`
|
||||
Thresholds *ruleThresholds `json:"thresholds,omitempty"`
|
||||
}
|
||||
|
||||
type compositeQuery struct {
|
||||
Queries json.RawMessage `json:"queries,omitempty"`
|
||||
PanelType string `json:"panelType"`
|
||||
QueryType string `json:"queryType"`
|
||||
Unit json.RawMessage `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// ruleQueryEnvelope reads the two envelope keys the derivations need.
|
||||
type ruleQueryEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Spec struct {
|
||||
Signal string `json:"signal"`
|
||||
} `json:"spec"`
|
||||
}
|
||||
|
||||
type ruleThresholds struct {
|
||||
Kind string `json:"kind"`
|
||||
Spec []basicThreshold `json:"spec"`
|
||||
}
|
||||
|
||||
type basicThreshold struct {
|
||||
Name string `json:"name"`
|
||||
TargetValue *float64 `json:"target"`
|
||||
TargetUnit string `json:"targetUnit"`
|
||||
RecoveryTarget *float64 `json:"recoveryTarget"`
|
||||
MatchType string `json:"matchType"`
|
||||
CompareOperator string `json:"op"`
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
type evaluationEnvelope struct {
|
||||
Kind string `json:"kind"`
|
||||
Spec rollingWindow `json:"spec"`
|
||||
}
|
||||
|
||||
type rollingWindow struct {
|
||||
EvalWindow textDuration `json:"evalWindow"`
|
||||
Frequency textDuration `json:"frequency"`
|
||||
}
|
||||
|
||||
type notificationSettings struct {
|
||||
Renotify *renotify `json:"renotify,omitempty"`
|
||||
UsePolicy bool `json:"usePolicy"`
|
||||
}
|
||||
|
||||
type renotify struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ReNotifyInterval textDuration `json:"interval,omitzero"`
|
||||
AlertStates []string `json:"alertStates,omitzero"`
|
||||
}
|
||||
|
||||
// clone copies what the conversion writes to: the rule, its condition, the
|
||||
// composite query and the labels. Raw keys share their bytes.
|
||||
func (rule *postableRule) clone() *postableRule {
|
||||
cloned := *rule
|
||||
if rule.RuleCondition != nil {
|
||||
condition := *rule.RuleCondition
|
||||
if condition.CompositeQuery != nil {
|
||||
query := *condition.CompositeQuery
|
||||
condition.CompositeQuery = &query
|
||||
}
|
||||
cloned.RuleCondition = &condition
|
||||
}
|
||||
cloned.Labels = maps.Clone(rule.Labels)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// disableIfNeverFired disables a rule v1 stored but could never fire: a
|
||||
// negative evalWindow or frequency, which is reset to its default since no
|
||||
// read of a rolling window accepts it, a null compositeQuery or one with no
|
||||
// queries, or a threshold with a null target or an op or matchType outside the
|
||||
// supported sets, which the v2 write path rejects. It reads the relocated
|
||||
// fields, so it runs after convertRule, and returns the offending fields, or
|
||||
// "" when the rule stays enabled.
|
||||
func (rule *postableRule) disableIfNeverFired() string {
|
||||
var causes []string
|
||||
window := &rule.Evaluation.Spec
|
||||
if !window.EvalWindow.IsPositive() {
|
||||
causes = append(causes, fmt.Sprintf("evalWindow %s is negative", window.EvalWindow))
|
||||
window.EvalWindow = mustParseTextDuration(defaultEvalWindow)
|
||||
}
|
||||
if !window.Frequency.IsPositive() {
|
||||
causes = append(causes, fmt.Sprintf("frequency %s is negative", window.Frequency))
|
||||
window.Frequency = mustParseTextDuration(defaultFrequency)
|
||||
}
|
||||
switch {
|
||||
case rule.RuleCondition.CompositeQuery == nil:
|
||||
causes = append(causes, "condition.compositeQuery is null")
|
||||
case len(rule.decodeQueryEnvelopes()) == 0:
|
||||
causes = append(causes, "condition.compositeQuery.queries is empty")
|
||||
}
|
||||
threshold := rule.RuleCondition.Thresholds.Spec[0]
|
||||
switch {
|
||||
case threshold.TargetValue == nil:
|
||||
causes = append(causes, "condition.target is null")
|
||||
case !slices.Contains(supportedCompareOperators, threshold.CompareOperator):
|
||||
causes = append(causes, fmt.Sprintf("condition.op: unsupported value %q", threshold.CompareOperator))
|
||||
case !slices.Contains(supportedMatchTypes, threshold.MatchType):
|
||||
causes = append(causes, fmt.Sprintf("condition.matchType: unsupported value %q", threshold.MatchType))
|
||||
}
|
||||
if len(causes) == 0 {
|
||||
return ""
|
||||
}
|
||||
rule.Disabled = json.RawMessage("true")
|
||||
return strings.Join(causes, ", ")
|
||||
}
|
||||
|
||||
func (rule *postableRule) fillDurationDefaults() {
|
||||
if rule.EvalWindow.IsZero() {
|
||||
rule.EvalWindow = mustParseTextDuration(defaultEvalWindow)
|
||||
}
|
||||
if rule.Frequency.IsZero() {
|
||||
rule.Frequency = mustParseTextDuration(defaultFrequency)
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) deriveAlertType() {
|
||||
if slices.Contains(supportedAlertTypes, rule.AlertType) {
|
||||
return
|
||||
}
|
||||
// promql, clickhouse and formula queries carry no signal; they alert on metrics
|
||||
rule.AlertType = alertTypeMetric
|
||||
for _, envelope := range rule.decodeQueryEnvelopes() {
|
||||
switch envelope.Spec.Signal {
|
||||
case signalLogs:
|
||||
rule.AlertType = alertTypeLogs
|
||||
return
|
||||
case signalTraces:
|
||||
rule.AlertType = alertTypeTraces
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) deriveQueryType() {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
if query == nil || slices.Contains(supportedQueryTypes, query.QueryType) {
|
||||
return
|
||||
}
|
||||
envelopes := rule.decodeQueryEnvelopes()
|
||||
if len(envelopes) == 0 {
|
||||
return
|
||||
}
|
||||
// a promql or clickhouse_sql envelope spells its type like the composite query does; every other envelope is a builder one
|
||||
switch envelopes[0].Type {
|
||||
case queryTypePromQL, queryTypeClickHouseSQL:
|
||||
query.QueryType = envelopes[0].Type
|
||||
default:
|
||||
query.QueryType = queryTypeBuilder
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) repairPanelType() {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
// nothing reads panelType; both alert forms write graph
|
||||
if query != nil && !slices.Contains(supportedPanelTypes, query.PanelType) {
|
||||
query.PanelType = panelTypeGraph
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) disableUnmetMinPoints() {
|
||||
condition := rule.RuleCondition
|
||||
if string(condition.RequireMinPoints) != "true" {
|
||||
return
|
||||
}
|
||||
points := 0.0
|
||||
if len(condition.RequiredNumPoints) > 0 {
|
||||
if err := json.Unmarshal(condition.RequiredNumPoints, &points); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if points > 0 {
|
||||
return
|
||||
}
|
||||
// zero required points is a check every series passes
|
||||
condition.RequireMinPoints = json.RawMessage("false")
|
||||
}
|
||||
|
||||
func (rule *postableRule) decodeQueryEnvelopes() []ruleQueryEnvelope {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
if query == nil {
|
||||
return nil
|
||||
}
|
||||
var envelopes []ruleQueryEnvelope
|
||||
if err := json.Unmarshal(query.Queries, &envelopes); err != nil {
|
||||
return nil
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
func (rule *postableRule) deriveRuleType() {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
if query == nil {
|
||||
return
|
||||
}
|
||||
switch query.QueryType {
|
||||
case queryTypeBuilder, queryTypeClickHouseSQL:
|
||||
if rule.RuleType == "" {
|
||||
rule.RuleType = ruleTypeThreshold
|
||||
}
|
||||
case queryTypePromQL:
|
||||
rule.RuleType = ruleTypeProm
|
||||
}
|
||||
}
|
||||
|
||||
// negateAnomalyBelowTarget stores "below 3" as -3, since anomaly scores are
|
||||
// signed. Only the raw "2" negates; "below" and "<" keep the sign the rule
|
||||
// evaluates with today.
|
||||
func (rule *postableRule) negateAnomalyBelowTarget() {
|
||||
condition := rule.RuleCondition
|
||||
if rule.RuleType != ruleTypeAnomaly || condition.CompareOperator != compareOperatorBelow || condition.Target == nil {
|
||||
return
|
||||
}
|
||||
negated := -*condition.Target
|
||||
condition.Target = &negated
|
||||
}
|
||||
|
||||
// repairEmptySeverity rewrites a severity of "" as critical in labels and in
|
||||
// the route expression that matches alerts on it, since a threshold named ""
|
||||
// fails every later read. It returns storedRoute, or a copy with the new
|
||||
// expression.
|
||||
func (rule *postableRule) repairEmptySeverity(ruleID string, storedRoute *routePolicyRow) *routePolicyRow {
|
||||
if severity, ok := rule.Labels[labelSeverity]; !ok || severity != "" {
|
||||
return storedRoute
|
||||
}
|
||||
rule.Labels[labelSeverity] = criticalThresholdName
|
||||
if storedRoute == nil {
|
||||
return nil
|
||||
}
|
||||
route := *storedRoute
|
||||
route.Expression = fmt.Sprintf(`threshold.name == "%s" && ruleId == "%s"`, criticalThresholdName, ruleID)
|
||||
return &route
|
||||
}
|
||||
|
||||
func (rule *postableRule) relocateThreshold(channels []string) {
|
||||
thresholdName := criticalThresholdName
|
||||
if severity, ok := rule.Labels[labelSeverity]; ok {
|
||||
thresholdName = severity
|
||||
}
|
||||
|
||||
condition := rule.RuleCondition
|
||||
condition.Thresholds = &ruleThresholds{
|
||||
Kind: "basic",
|
||||
Spec: []basicThreshold{{
|
||||
Name: thresholdName,
|
||||
TargetValue: condition.Target,
|
||||
TargetUnit: condition.TargetUnit,
|
||||
MatchType: condition.MatchType,
|
||||
CompareOperator: condition.CompareOperator,
|
||||
Channels: channels,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) relocateEvaluation() {
|
||||
rule.Evaluation = &evaluationEnvelope{
|
||||
Kind: "rolling",
|
||||
Spec: rollingWindow{EvalWindow: rule.EvalWindow, Frequency: rule.Frequency},
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) synthesizeNotificationSettings() {
|
||||
alertStates := []string{"firing"}
|
||||
if rule.RuleCondition.AlertOnAbsent {
|
||||
alertStates = append(alertStates, "nodata")
|
||||
}
|
||||
rule.NotificationSettings = ¬ificationSettings{
|
||||
Renotify: &renotify{
|
||||
Enabled: true,
|
||||
ReNotifyInterval: mustParseTextDuration("4h"),
|
||||
AlertStates: alertStates,
|
||||
},
|
||||
UsePolicy: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) clearV1Fields() {
|
||||
rule.EvalWindow = textDuration{}
|
||||
rule.Frequency = textDuration{}
|
||||
rule.PreferredChannels = nil
|
||||
rule.RuleCondition.Target = nil
|
||||
rule.RuleCondition.CompareOperator = ""
|
||||
rule.RuleCondition.MatchType = ""
|
||||
rule.RuleCondition.TargetUnit = ""
|
||||
}
|
||||
|
||||
// textDuration decodes a duration the way the app's TextDuration does, a
|
||||
// string or a number of nanoseconds, and writes a string back in its stored
|
||||
// spelling.
|
||||
type textDuration struct {
|
||||
text string
|
||||
value time.Duration
|
||||
}
|
||||
|
||||
func mustParseTextDuration(text string) textDuration {
|
||||
value, err := time.ParseDuration(text)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return textDuration{text: text, value: value}
|
||||
}
|
||||
|
||||
func (d textDuration) IsZero() bool {
|
||||
return d.value == 0
|
||||
}
|
||||
|
||||
func (d textDuration) IsPositive() bool {
|
||||
return d.value > 0
|
||||
}
|
||||
|
||||
func (d textDuration) String() string {
|
||||
if d.text != "" {
|
||||
return d.text
|
||||
}
|
||||
return d.value.String()
|
||||
}
|
||||
|
||||
func (d textDuration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.String())
|
||||
}
|
||||
|
||||
func (d *textDuration) UnmarshalJSON(data []byte) error {
|
||||
var raw any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case float64:
|
||||
*d = textDuration{value: time.Duration(value)}
|
||||
return nil
|
||||
case string:
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = textDuration{text: value, value: parsed}
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid duration %s", data)
|
||||
}
|
||||
}
|
||||
@@ -619,10 +619,6 @@ type ConfigStore interface {
|
||||
|
||||
// ListAllChannels returns the list of channels for all organizations.
|
||||
ListAllChannels(context.Context) ([]*Channel, error)
|
||||
|
||||
// GetMatchers gets a list of matchers per organization.
|
||||
// Matchers is an array of ruleId to receiver names.
|
||||
GetMatchers(context.Context, string) (map[string][]string, error)
|
||||
}
|
||||
|
||||
// MarshalSecretValue if set to true will expose Secret type
|
||||
|
||||
@@ -109,18 +109,14 @@ type AlertCompositeQuery struct {
|
||||
|
||||
type RuleCondition struct {
|
||||
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true" description:"The queries whose result is compared against the thresholds."`
|
||||
CompareOperator CompareOperator `json:"op,omitzero" description:"schemaVersion v1 only: how the value is compared with target. Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal or outside_bounds, their symbols, or the legacy codes as strings, '1' to '7'. Required for v1; v2alpha1 sets op on each threshold."`
|
||||
Target *float64 `json:"target,omitempty" format:"double" description:"schemaVersion v1 only: the value compared against, in targetUnit. Required for v1; v2alpha1 sets target on each threshold."`
|
||||
AlertOnAbsent bool `json:"alertOnAbsent,omitempty" description:"Whether to fire a nodata alert when the query returns no data. Defaults to false."`
|
||||
AbsentFor uint64 `json:"absentFor,omitempty" description:"How many minutes without data pass before the nodata alert fires. Ignored unless alertOnAbsent is true."`
|
||||
MatchType MatchType `json:"matchType,omitzero" description:"schemaVersion v1 only: which points in the window must satisfy op. Accepted as at_least_once, all_the_times, on_average, in_total or last, their aliases avg and sum, or the legacy codes as strings, '1' to '5'. Required for v1; v2alpha1 sets matchType on each threshold."`
|
||||
TargetUnit string `json:"targetUnit,omitempty" description:"schemaVersion v1 only: the unit of target, such as percent or ms. Converted to compositeQuery.unit before comparison when the two differ."`
|
||||
Algorithm string `json:"algorithm,omitempty" description:"anomaly_rule only: the anomaly detection algorithm. The supported value is standard."`
|
||||
Seasonality Seasonality `json:"seasonality,omitzero" description:"anomaly_rule only: the seasonal period the history is compared against, hourly, daily or weekly."`
|
||||
SelectedQuery string `json:"selectedQueryName,omitempty" required:"true" description:"The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery."`
|
||||
SelectedQuery string `json:"selectedQueryName" required:"true" description:"The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery."`
|
||||
RequireMinPoints bool `json:"requireMinPoints,omitempty" description:"Whether a series is skipped until it has at least requiredNumPoints points in the window. Defaults to false."`
|
||||
RequiredNumPoints int `json:"requiredNumPoints,omitempty" description:"The minimum number of points a series needs before it is evaluated. Must be greater than 0 when requireMinPoints is true."`
|
||||
Thresholds *RuleThresholdData `json:"thresholds,omitempty" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
|
||||
Thresholds *RuleThresholdData `json:"thresholds" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
|
||||
}
|
||||
|
||||
func (rc *RuleCondition) SelectedQueryName() string {
|
||||
|
||||
@@ -49,7 +49,6 @@ func (t AlertType) BuilderQueryType() qbtypes.QueryType {
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultSchemaVersion = "v1"
|
||||
SchemaVersionV2Alpha1 = "v2alpha1"
|
||||
)
|
||||
|
||||
@@ -61,12 +60,10 @@ const (
|
||||
|
||||
// PostableRule is used to create alerting rule from HTTP api.
|
||||
type PostableRule struct {
|
||||
AlertName valuer.DisplayName `json:"alert" required:"true" description:"The display name of the alert rule."`
|
||||
AlertType AlertType `json:"alertType" required:"true" description:"The signal the rule evaluates: METRIC_BASED_ALERT, TRACES_BASED_ALERT, LOGS_BASED_ALERT, EXCEPTIONS_BASED_ALERT or AI_TRACES_BASED_ALERT."`
|
||||
Description string `json:"description,omitempty" description:"A human readable description of what the rule monitors."`
|
||||
RuleType RuleType `json:"ruleType" required:"true" description:"The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries."`
|
||||
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero" description:"The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead."`
|
||||
Frequency valuer.TextDuration `json:"frequency,omitzero" description:"How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead."`
|
||||
AlertName valuer.DisplayName `json:"alert" required:"true" description:"The display name of the alert rule."`
|
||||
AlertType AlertType `json:"alertType" required:"true" description:"The signal the rule evaluates: METRIC_BASED_ALERT, TRACES_BASED_ALERT, LOGS_BASED_ALERT, EXCEPTIONS_BASED_ALERT or AI_TRACES_BASED_ALERT."`
|
||||
Description string `json:"description,omitempty" description:"A human readable description of what the rule monitors."`
|
||||
RuleType RuleType `json:"ruleType" required:"true" description:"The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries."`
|
||||
|
||||
RuleCondition *RuleCondition `json:"condition" required:"true" description:"The query to evaluate and the thresholds applied to its result."`
|
||||
Labels map[string]string `json:"labels,omitempty" description:"Labels attached to every alert the rule fires. Keys match [a-zA-Z_.][a-zA-Z0-9_.]*; values may use Go templates such as {{$labels.service_name}}. severity is the conventional routing label."`
|
||||
@@ -76,14 +73,12 @@ type PostableRule struct {
|
||||
|
||||
Source string `json:"source,omitempty" description:"The URL of the page the rule was created from. Informational; it is not used for evaluation."`
|
||||
|
||||
PreferredChannels []string `json:"preferredChannels,omitempty" description:"The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels."`
|
||||
|
||||
Version string `json:"version" description:"The query payload version. Nothing reads it."`
|
||||
|
||||
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty" required:"true" description:"The evaluation window, as a kind and spec."`
|
||||
SchemaVersion string `json:"schemaVersion,omitempty" required:"true" enum:"v2alpha1" description:"The rule payload schema."`
|
||||
Evaluation *EvaluationEnvelope `json:"evaluation" required:"true" description:"The evaluation window, as a kind and spec."`
|
||||
SchemaVersion string `json:"schemaVersion" required:"true" enum:"v2alpha1" description:"The rule payload schema."`
|
||||
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty" required:"true" description:"Grouping, re-notification and routing behaviour."`
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings" required:"true" description:"Grouping, re-notification and routing behaviour."`
|
||||
}
|
||||
|
||||
type NotificationSettings struct {
|
||||
@@ -228,27 +223,6 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
|
||||
// processRuleDefaults applies the default values
|
||||
// for the rule options that are blank or unset.
|
||||
func (r *PostableRule) processRuleDefaults() {
|
||||
if r.SchemaVersion == "" {
|
||||
r.SchemaVersion = DefaultSchemaVersion
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): remove as this is now a legacy field
|
||||
if r.Version == "" {
|
||||
r.Version = "v5"
|
||||
}
|
||||
|
||||
// v2alpha1 uses the Evaluation envelope for window/frequency;
|
||||
// only default top-level fields for v1.
|
||||
if r.SchemaVersion != SchemaVersionV2Alpha1 {
|
||||
if r.EvalWindow.IsZero() {
|
||||
r.EvalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
|
||||
if r.Frequency.IsZero() {
|
||||
r.Frequency = valuer.MustParseTextDuration("1m")
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
|
||||
switch r.RuleCondition.CompositeQuery.QueryType {
|
||||
case QueryTypeBuilder:
|
||||
@@ -258,71 +232,6 @@ func (r *PostableRule) processRuleDefaults() {
|
||||
case QueryTypePromQL:
|
||||
r.RuleType = RuleTypeProm
|
||||
}
|
||||
|
||||
if r.SchemaVersion == DefaultSchemaVersion {
|
||||
thresholdName := CriticalThresholdName
|
||||
if r.Labels != nil {
|
||||
if severity, ok := r.Labels["severity"]; ok {
|
||||
thresholdName = severity
|
||||
}
|
||||
}
|
||||
|
||||
// For anomaly detection with ValueIsBelow, negate the target
|
||||
targetValue := r.RuleCondition.Target
|
||||
if r.RuleType == RuleTypeAnomaly && r.RuleCondition.CompareOperator == ValueIsBelow && targetValue != nil {
|
||||
negated := -1 * *targetValue
|
||||
targetValue = &negated
|
||||
}
|
||||
|
||||
thresholdData := RuleThresholdData{
|
||||
Kind: BasicThresholdKind,
|
||||
Spec: BasicRuleThresholds{{
|
||||
Name: thresholdName,
|
||||
TargetUnit: r.RuleCondition.TargetUnit,
|
||||
TargetValue: targetValue,
|
||||
MatchType: r.RuleCondition.MatchType,
|
||||
CompareOperator: r.RuleCondition.CompareOperator,
|
||||
Channels: r.PreferredChannels,
|
||||
}},
|
||||
}
|
||||
r.RuleCondition.Thresholds = &thresholdData
|
||||
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
|
||||
r.NotificationSettings = &NotificationSettings{
|
||||
Renotify: &Renotify{
|
||||
Enabled: true,
|
||||
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
|
||||
AlertStates: []AlertState{StateFiring},
|
||||
},
|
||||
}
|
||||
if r.RuleCondition.AlertOnAbsent {
|
||||
r.NotificationSettings.Renotify.AlertStates = append(r.NotificationSettings.Renotify.AlertStates, StateNoData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PostableRule) MarshalJSON() ([]byte, error) {
|
||||
type Alias PostableRule
|
||||
|
||||
switch r.SchemaVersion {
|
||||
case DefaultSchemaVersion:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
if aux.RuleCondition != nil {
|
||||
aux.RuleCondition.Thresholds = nil
|
||||
}
|
||||
aux.Evaluation = nil
|
||||
aux.SchemaVersion = ""
|
||||
aux.NotificationSettings = nil
|
||||
return json.Marshal(aux)
|
||||
case SchemaVersionV2Alpha1:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
default:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,10 +285,6 @@ func (r *PostableRule) validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
|
||||
}
|
||||
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
for k, v := range r.Labels {
|
||||
if !isValidLabelName(k) {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
|
||||
@@ -423,10 +328,6 @@ func (r *PostableRule) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
|
||||
}
|
||||
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
if r.AlertType != "" {
|
||||
switch r.AlertType {
|
||||
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
|
||||
@@ -462,7 +363,9 @@ func (r *PostableRule) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
|
||||
if r.RuleCondition.SelectedQuery == "" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.selectedQueryName: field is required"))
|
||||
} else if r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
|
||||
found := false
|
||||
for _, query := range r.RuleCondition.CompositeQuery.Queries {
|
||||
if query.GetQueryName() == r.RuleCondition.SelectedQuery {
|
||||
@@ -514,69 +417,26 @@ func (r *PostableRule) Validate() error {
|
||||
|
||||
func (r *PostableRule) validateSchemaVersion() []error {
|
||||
switch r.SchemaVersion {
|
||||
case DefaultSchemaVersion:
|
||||
return r.validateV1()
|
||||
case SchemaVersionV2Alpha1:
|
||||
return r.validateV2Alpha1()
|
||||
default:
|
||||
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"schemaVersion: unsupported value %q; must be one of %q, %q",
|
||||
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
|
||||
"schemaVersion: unsupported value %q; must be %q",
|
||||
r.SchemaVersion, SchemaVersionV2Alpha1)}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateV1() []error {
|
||||
var errs []error
|
||||
|
||||
if r.RuleCondition.Target == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
}
|
||||
if r.RuleCondition.CompareOperator.IsZero() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if r.RuleCondition.MatchType.IsZero() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
|
||||
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateV2Alpha1() []error {
|
||||
var errs []error
|
||||
|
||||
// TODO(srikanthccv): reject v1-only fields?
|
||||
// if r.RuleCondition.Target != nil {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if !r.RuleCondition.CompareOperator.IsZero() {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if !r.RuleCondition.MatchType.IsZero() {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
// if len(r.PreferredChannels) > 0 {
|
||||
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
|
||||
// SchemaVersionV2Alpha1))
|
||||
// }
|
||||
|
||||
// Require v2alpha1-specific fields
|
||||
if r.RuleCondition.Thresholds == nil {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"condition.thresholds: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
|
||||
} else if threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold(); err == nil {
|
||||
if err := threshold.Validate(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.Evaluation == nil {
|
||||
@@ -656,31 +516,6 @@ type GettableRule struct {
|
||||
UpdatedBy *string `json:"updateBy" nullable:"true" description:"The identifier of the user who last updated the rule."`
|
||||
}
|
||||
|
||||
func (g *GettableRule) MarshalJSON() ([]byte, error) {
|
||||
type Alias GettableRule
|
||||
|
||||
switch g.SchemaVersion {
|
||||
case DefaultSchemaVersion:
|
||||
copyStruct := *g
|
||||
aux := Alias(copyStruct)
|
||||
if aux.RuleCondition != nil {
|
||||
aux.RuleCondition.Thresholds = nil
|
||||
}
|
||||
aux.Evaluation = nil
|
||||
aux.SchemaVersion = ""
|
||||
aux.NotificationSettings = nil
|
||||
return json.Marshal(aux)
|
||||
case SchemaVersionV2Alpha1:
|
||||
copyStruct := *g
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
default:
|
||||
copyStruct := *g
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule is the v2 API read model for an alerting rule. It aligns audit fields
|
||||
// with the canonical types.TimeAuditable / types.UserAuditable shape used by
|
||||
// PlannedMaintenance and other entities. v1 handlers keep serializing
|
||||
@@ -709,28 +544,3 @@ func NewRule(g *GettableRule) *Rule {
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Rule) MarshalJSON() ([]byte, error) {
|
||||
type Alias Rule
|
||||
|
||||
switch r.SchemaVersion {
|
||||
case DefaultSchemaVersion:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
if aux.RuleCondition != nil {
|
||||
aux.RuleCondition.Thresholds = nil
|
||||
}
|
||||
aux.Evaluation = nil
|
||||
aux.SchemaVersion = ""
|
||||
aux.NotificationSettings = nil
|
||||
return json.Marshal(aux)
|
||||
case SchemaVersionV2Alpha1:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
default:
|
||||
copyStruct := *r
|
||||
aux := Alias(copyStruct)
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ package ruletypes
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -29,8 +27,7 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Test description",
|
||||
"ruleType": "threshold_rule",
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -45,11 +42,11 @@ func TestParseIntoRule(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
@@ -63,46 +60,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
if rule.RuleCondition.Thresholds.Kind.IsZero() {
|
||||
t.Error("Expected thresholds to be populated")
|
||||
}
|
||||
if rule.RuleCondition.Target == nil {
|
||||
t.Error("Expected target to be populated")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rule with default values applied",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "DefaultsRule",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 5.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.EvalWindow.Duration() != 5*time.Minute {
|
||||
t.Errorf("Expected default eval window '5m', got '%v'", rule.EvalWindow)
|
||||
}
|
||||
if rule.Frequency.Duration() != time.Minute {
|
||||
t.Errorf("Expected default frequency '1m', got '%v'", rule.Frequency)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -110,6 +67,7 @@ func TestParseIntoRule(t *testing.T) {
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "PromQLRule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -123,11 +81,11 @@ func TestParseIntoRule(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
@@ -138,9 +96,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
if rule.RuleCondition.Thresholds.Kind.IsZero() {
|
||||
t.Error("Expected thresholds to be populated")
|
||||
}
|
||||
if rule.RuleCondition.Target == nil {
|
||||
t.Error("Expected target to be populated")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -177,233 +132,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
expectValidateError bool // Validate() error (write path only)
|
||||
validate func(*testing.T, *PostableRule)
|
||||
}{
|
||||
{
|
||||
name: "schema v1 - threshold name from severity label",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "SeverityLabelTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}],
|
||||
"unit": "percent"
|
||||
},
|
||||
"target": 85.0,
|
||||
"targetUnit": "%",
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
},
|
||||
"labels": {
|
||||
"severity": "warning",
|
||||
"team": "platform"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.RuleCondition.Thresholds == nil {
|
||||
t.Fatal("Expected Thresholds to be populated for v1")
|
||||
}
|
||||
|
||||
threshold := rule.RuleCondition.Thresholds
|
||||
if threshold.Kind != BasicThresholdKind {
|
||||
t.Errorf("Expected BasicThresholdKind, got %s", threshold.Kind)
|
||||
}
|
||||
|
||||
specs, ok := threshold.Spec.(BasicRuleThresholds)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BasicRuleThresholds, got %T", threshold.Spec)
|
||||
}
|
||||
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
|
||||
}
|
||||
|
||||
spec := specs[0]
|
||||
if spec.Name != "warning" {
|
||||
t.Errorf("Expected threshold name 'warning' from severity label, got '%s'", spec.Name)
|
||||
}
|
||||
|
||||
if spec.TargetUnit != "%" {
|
||||
t.Errorf("Expected TargetUnit '%%', got '%s'", spec.TargetUnit)
|
||||
}
|
||||
if *spec.TargetValue != 85.0 {
|
||||
t.Errorf("Expected TargetValue 85.0, got %v", *spec.TargetValue)
|
||||
}
|
||||
if spec.MatchType != rule.RuleCondition.MatchType {
|
||||
t.Error("Expected MatchType to be copied from RuleCondition")
|
||||
}
|
||||
if spec.CompareOperator != rule.RuleCondition.CompareOperator {
|
||||
t.Error("Expected CompareOp to be copied from RuleCondition")
|
||||
}
|
||||
|
||||
// Verify evaluation envelope is populated
|
||||
if rule.Evaluation == nil {
|
||||
t.Fatal("Expected Evaluation to be populated for v1")
|
||||
}
|
||||
if rule.Evaluation.Kind != RollingEvaluation {
|
||||
t.Errorf("Expected RollingEvaluation, got %s", rule.Evaluation.Kind)
|
||||
}
|
||||
|
||||
// Verify evaluation window matches rule settings
|
||||
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
|
||||
if !window.EvalWindow.Equal(rule.EvalWindow) {
|
||||
t.Errorf("Expected Evaluation EvalWindow %v, got %v", rule.EvalWindow, window.EvalWindow)
|
||||
}
|
||||
if !window.Frequency.Equal(rule.Frequency) {
|
||||
t.Errorf("Expected Evaluation Frequency %v, got %v", rule.Frequency, window.Frequency)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema v1 - uses critical threshold when no labels",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "NoLabelsTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 90.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.RuleCondition.Thresholds == nil {
|
||||
t.Fatal("Expected Thresholds to be populated")
|
||||
}
|
||||
|
||||
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
|
||||
}
|
||||
spec := specs[0]
|
||||
// Should default to CriticalThresholdName when no severity label
|
||||
if spec.Name != CriticalThresholdName {
|
||||
t.Errorf("Expected threshold name '%s', got '%s'", CriticalThresholdName, spec.Name)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema v1 - overwrites existing thresholds and evaluation",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "OverwriteTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}],
|
||||
"unit": "percent"
|
||||
},
|
||||
"target": 80.0,
|
||||
"targetUnit": "%",
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{
|
||||
"name": "existing_threshold",
|
||||
"target": 50.0,
|
||||
"targetUnit": "MB",
|
||||
"ruleUnit": "bytes",
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "10m",
|
||||
"frequency": "2m"
|
||||
}
|
||||
},
|
||||
"frequency":"7m",
|
||||
"evalWindow":"11m",
|
||||
"labels": {
|
||||
"severity": "critical"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.RuleCondition.Thresholds == nil {
|
||||
t.Fatal("Expected Thresholds to be populated")
|
||||
}
|
||||
|
||||
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
|
||||
}
|
||||
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
|
||||
}
|
||||
|
||||
spec := specs[0]
|
||||
if spec.Name != "critical" {
|
||||
t.Errorf("Expected threshold name 'critical' (overwritten), got '%s'", spec.Name)
|
||||
}
|
||||
|
||||
if *spec.TargetValue != 80.0 {
|
||||
t.Errorf("Expected TargetValue 80.0 (overwritten), got %v", *spec.TargetValue)
|
||||
}
|
||||
if spec.TargetUnit != "%" {
|
||||
t.Errorf("Expected TargetUnit '%%' (overwritten), got '%s'", spec.TargetUnit)
|
||||
}
|
||||
|
||||
if rule.Evaluation == nil {
|
||||
t.Fatal("Expected Evaluation to be populated")
|
||||
}
|
||||
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
|
||||
if !window.EvalWindow.Equal(rule.EvalWindow) {
|
||||
t.Errorf("Expected Evaluation EvalWindow to be overwritten to %v, got %v", rule.EvalWindow, window.EvalWindow)
|
||||
}
|
||||
if !window.Frequency.Equal(rule.Frequency) {
|
||||
t.Errorf("Expected Evaluation Frequency to be overwritten to %v, got %v", rule.Frequency, window.Frequency)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
|
||||
initRule: PostableRule{},
|
||||
@@ -645,19 +373,19 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 100.0, "matchType": "1", "op": "1"}]}
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "default schema version - defaults to v1 behavior",
|
||||
name: "missing schema version - rejected on write",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "DefaultSchemaTest",
|
||||
"alert": "MissingSchemaTest",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -672,24 +400,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 75.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 75.0, "matchType": "1", "op": "1"}]}
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.SchemaVersion != DefaultSchemaVersion {
|
||||
t.Errorf("Expected default schemaVersion '%s', got '%s'", DefaultSchemaVersion, rule.SchemaVersion)
|
||||
}
|
||||
if rule.RuleCondition.Thresholds == nil {
|
||||
t.Error("Expected Thresholds to be populated for default schema version")
|
||||
}
|
||||
if rule.Evaluation == nil {
|
||||
t.Error("Expected Evaluation to be populated for default schema version")
|
||||
}
|
||||
},
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -737,11 +454,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A",
|
||||
"targetUnit": "ms",
|
||||
"selectedQueryName": "A",
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
@@ -906,449 +619,3 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 1, len(vector))
|
||||
}
|
||||
|
||||
func TestAnomalyNegationEval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ruleJSON []byte
|
||||
series *qbtypes.TimeSeries
|
||||
shouldAlert bool
|
||||
expectedValue float64
|
||||
}{
|
||||
{
|
||||
name: "anomaly rule with ValueIsBelow - should alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 2.0,
|
||||
"matchType": "1",
|
||||
"op": "2",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: -2.1}, // below & at least once, should alert
|
||||
{Timestamp: 2000, Value: -2.3},
|
||||
},
|
||||
},
|
||||
shouldAlert: true,
|
||||
expectedValue: -2.1,
|
||||
},
|
||||
{
|
||||
name: "anomaly rule with ValueIsBelow; should not alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 2.0,
|
||||
"matchType": "1",
|
||||
"op": "2",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`), // below & at least once, no value below -2.0
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: -1.9},
|
||||
{Timestamp: 2000, Value: -1.8},
|
||||
},
|
||||
},
|
||||
shouldAlert: false,
|
||||
},
|
||||
{
|
||||
name: "anomaly rule with ValueIsAbove; should alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyAboveTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 2.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`), // above & at least once, should alert
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: 2.1}, // above 2.0, should alert
|
||||
{Timestamp: 2000, Value: 2.2},
|
||||
},
|
||||
},
|
||||
shouldAlert: true,
|
||||
expectedValue: 2.1,
|
||||
},
|
||||
{
|
||||
name: "anomaly rule with ValueIsAbove; should not alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyAboveTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 2.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: 1.1},
|
||||
{Timestamp: 2000, Value: 1.2},
|
||||
},
|
||||
},
|
||||
shouldAlert: false,
|
||||
},
|
||||
{
|
||||
name: "anomaly rule with ValueIsBelow and AllTheTimes; should alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowAllTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 2.0,
|
||||
"matchType": "2",
|
||||
"op": "2",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`), // below and all the times
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: -2.1}, // all below -2
|
||||
{Timestamp: 2000, Value: -2.2},
|
||||
{Timestamp: 3000, Value: -2.5},
|
||||
},
|
||||
},
|
||||
shouldAlert: true,
|
||||
expectedValue: -2.1, // max value when all are below threshold
|
||||
},
|
||||
{
|
||||
name: "anomaly rule with ValueIsBelow and AllTheTimes; should not alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowAllTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 2.0,
|
||||
"matchType": "2",
|
||||
"op": "2",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: -3.0},
|
||||
{Timestamp: 2000, Value: -1.0}, // above -2, breaks condition
|
||||
{Timestamp: 3000, Value: -2.5},
|
||||
},
|
||||
},
|
||||
shouldAlert: false,
|
||||
},
|
||||
{
|
||||
name: "anomaly rule with ValueOutsideBounds; should alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyOutOfBoundsTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 7.0,
|
||||
"matchType": "1",
|
||||
"op": "7",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: -8.0}, // abs(−8) >= 7, alert
|
||||
{Timestamp: 2000, Value: 5.0},
|
||||
},
|
||||
},
|
||||
shouldAlert: true,
|
||||
expectedValue: -8.0,
|
||||
},
|
||||
{
|
||||
name: "non-anomaly threshold rule with ValueIsBelow; should alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "ThresholdTest",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 90.0,
|
||||
"matchType": "1",
|
||||
"op": "2",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: 80.0}, // below 90, should alert
|
||||
{Timestamp: 2000, Value: 85.0},
|
||||
},
|
||||
},
|
||||
shouldAlert: true,
|
||||
expectedValue: 80.0,
|
||||
},
|
||||
{
|
||||
name: "non-anomaly rule with ValueIsBelow - should not alert",
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "ThresholdTest",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"queries": [{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
|
||||
"stepInterval": "5m"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"target": 50.0,
|
||||
"matchType": "1",
|
||||
"op": "2",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
series: &qbtypes.TimeSeries{
|
||||
Labels: []*qbtypes.Label{
|
||||
{
|
||||
Key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "host",
|
||||
},
|
||||
Value: "server1",
|
||||
},
|
||||
},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1000, Value: 60.0}, // below, should alert
|
||||
{Timestamp: 2000, Value: 90.0},
|
||||
},
|
||||
},
|
||||
shouldAlert: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rule := PostableRule{}
|
||||
err := json.Unmarshal(tt.ruleJSON, &rule)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal rule: %v", err)
|
||||
}
|
||||
|
||||
ruleThreshold, err := rule.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from GetRuleThreshold: %v", err)
|
||||
}
|
||||
|
||||
resultVector, err := ruleThreshold.Eval(tt.series, "", EvalData{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from Eval: %v", err)
|
||||
}
|
||||
|
||||
shouldAlert := len(resultVector) > 0
|
||||
|
||||
if shouldAlert != tt.shouldAlert {
|
||||
t.Errorf("Expected shouldAlert=%v, got %v. %s",
|
||||
tt.shouldAlert, shouldAlert, tt.name)
|
||||
}
|
||||
|
||||
if tt.shouldAlert && len(resultVector) > 0 {
|
||||
sample := resultVector[0]
|
||||
if sample.V != tt.expectedValue {
|
||||
t.Errorf("Expected alert value=%.2f, got %.2f. %s",
|
||||
tt.expectedValue, sample.V, tt.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionDefaultsToV5(t *testing.T) {
|
||||
content := `{
|
||||
"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": "1", "op": "1"}]}
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
"notificationSettings": {"usePolicy": false}
|
||||
}`
|
||||
|
||||
rule := PostableRule{}
|
||||
require.NoError(t, json.Unmarshal([]byte(content), &rule))
|
||||
assert.Equal(t, "v5", rule.Version)
|
||||
assert.NoError(t, rule.Validate())
|
||||
}
|
||||
|
||||
@@ -328,6 +328,9 @@ func (e *EvaluationEnvelope) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
func (e *EvaluationEnvelope) GetEvaluation() (Evaluation, error) {
|
||||
if e == nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is nil")
|
||||
}
|
||||
if e.Kind.IsZero() {
|
||||
e.Kind = RollingEvaluation
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestV2MinimalReadShape(t *testing.T) {
|
||||
|
||||
body := readBody(t, stored)
|
||||
|
||||
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
|
||||
for _, field := range []string{"labels", "annotations", "description"} {
|
||||
assert.NotContains(t, body, field)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestV2MinimalReadShape(t *testing.T) {
|
||||
assert.JSONEq(t, `false`, string(ns["usePolicy"]))
|
||||
|
||||
assert.JSONEq(t, `false`, string(body["disabled"]))
|
||||
assert.JSONEq(t, `"v5"`, string(body["version"]))
|
||||
assert.JSONEq(t, `""`, string(body["version"]))
|
||||
|
||||
var condition struct {
|
||||
Thresholds struct {
|
||||
@@ -297,6 +297,7 @@ func TestPatchMergePreservesUnpatchedFields(t *testing.T) {
|
||||
"panelType": "graph",
|
||||
"queryType": "promql"
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
|
||||
|
||||
@@ -85,9 +85,6 @@ func (r *RuleThresholdData) UnmarshalJSON(data []byte) error {
|
||||
if err := json.Unmarshal(raw["spec"], &basicThresholds); err != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal rule threshold spec: %v", err)
|
||||
}
|
||||
if err := basicThresholds.Validate(); err != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
|
||||
}
|
||||
r.Spec = basicThresholds
|
||||
|
||||
default:
|
||||
@@ -130,6 +127,9 @@ type RuleThreshold interface {
|
||||
// using the given EvalData and returns the matching series
|
||||
Eval(series *qbtypes.TimeSeries, unit string, evalData EvalData) (Vector, error)
|
||||
GetRuleReceivers() []RuleReceivers
|
||||
// Validate runs on the write path and before a task is built; a stored
|
||||
// rule may hold a threshold that fails it and still decode.
|
||||
Validate() error
|
||||
}
|
||||
|
||||
type BasicRuleThreshold struct {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -252,7 +252,7 @@ has arrived, the same way `drawer.open` retries a swallowed click.
|
||||
|
||||
## The evaluation window popover ignores Escape
|
||||
|
||||
`alerts/create` TC-08 timed out in one full pass and in two of five repeats. The
|
||||
`alerts/create` TC-07 timed out in one full pass and in two of five repeats. The
|
||||
evaluation settings popover is an antd `Popover` with `trigger="click"`, and
|
||||
neither antd 5.11 nor `@rc-component/trigger` closes it on Escape, so
|
||||
`selectEvaluationTimeframe` left it open. The next click outside usually closed
|
||||
|
||||
2
tests/e2e/drivers/alerts/constants.ts
generated
2
tests/e2e/drivers/alerts/constants.ts
generated
@@ -71,7 +71,7 @@ export const AlertType = {
|
||||
|
||||
export type AlertTypeValue = (typeof AlertType)[keyof typeof AlertType];
|
||||
|
||||
/** `AlertDetectionTypes` (`frontend/src/container/FormAlertRules/index.tsx:78-81`). */
|
||||
/** `RuletypesRuleTypeDTO` (`frontend/src/api/generated/services/sigNoz.schemas.ts`). */
|
||||
export const RuleType = {
|
||||
THRESHOLD: 'threshold_rule',
|
||||
ANOMALY: 'anomaly_rule',
|
||||
|
||||
30
tests/e2e/drivers/alerts/driver.ts
generated
30
tests/e2e/drivers/alerts/driver.ts
generated
@@ -129,8 +129,7 @@ export interface AlertForm {
|
||||
hasAnomalyCard(): Promise<boolean>;
|
||||
expectTypeCardSet(): Promise<void>;
|
||||
url(options?: CreateAlertUrlOptions): string;
|
||||
gotoV2(options?: Omit<CreateAlertUrlOptions, 'classic'>): Promise<void>;
|
||||
gotoV1(options?: Omit<CreateAlertUrlOptions, 'classic'>): Promise<void>;
|
||||
gotoV2(options?: CreateAlertUrlOptions): Promise<void>;
|
||||
|
||||
name(): Locator;
|
||||
saveButton(): Locator;
|
||||
@@ -148,7 +147,6 @@ export interface AlertForm {
|
||||
selectThresholdChannel(index: number, channelName: string): Promise<void>;
|
||||
addLabel(key: string, value: string): Promise<void>;
|
||||
labelPill(key: string, value: string): Locator;
|
||||
v1SaveButton(): Locator;
|
||||
|
||||
advancedOptionToggle(containerTestId: string): Locator;
|
||||
expandAdvancedOptions(): Promise<void>;
|
||||
@@ -170,7 +168,6 @@ export interface AlertForm {
|
||||
elementAtPointClassName(x: number, y: number): Promise<string>;
|
||||
|
||||
typeSelectionTitle(): Locator;
|
||||
switchToClassicButton(): Locator;
|
||||
/** The "New Alert Rule" chip, which the create header shows and edit hides. */
|
||||
newRuleChip(): Locator;
|
||||
addThresholdButton(): Locator;
|
||||
@@ -189,7 +186,7 @@ export interface AlertForm {
|
||||
missingDataToleranceInput(): Locator;
|
||||
minimumDatapointsInput(): Locator;
|
||||
customScheduleEditor(): Locator;
|
||||
/** The shared modal every save error lands in, for both forms. */
|
||||
/** The shared modal every save error lands in. */
|
||||
errorModal(): Locator;
|
||||
errorModalCloseButton(): Locator;
|
||||
}
|
||||
@@ -205,8 +202,6 @@ export interface ChannelDropdown {
|
||||
export interface CreateAlertUrlOptions {
|
||||
alertType?: AlertTypeValue;
|
||||
ruleType?: string;
|
||||
/** Sets `showClassicCreateAlertsPage=true` ⇒ the v1 classic form. */
|
||||
classic?: boolean;
|
||||
/** Merged in last, so it can override anything above. */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
@@ -233,10 +228,6 @@ export interface AlertDetails {
|
||||
historyBetaBadge(): Locator;
|
||||
enableToggle(): Locator;
|
||||
copyLinkButton(): Locator;
|
||||
/** The static v1 header; v2 renders `nameInput()` instead. */
|
||||
headerField(field: 'title' | 'state' | 'severity' | 'labels'): Locator;
|
||||
renameInput(): Locator;
|
||||
renameConfirmButton(): Locator;
|
||||
/** Found by label, so it also proves the History tab renders none. */
|
||||
footerSaveButton(): Locator;
|
||||
notFound(): Locator;
|
||||
@@ -460,13 +451,9 @@ export function alertsDriver(page: Page): Alerts {
|
||||
url: ({
|
||||
alertType = AlertType.LOGS,
|
||||
ruleType = RuleType.THRESHOLD,
|
||||
classic = false,
|
||||
params = {},
|
||||
}: CreateAlertUrlOptions = {}) => {
|
||||
const search = new URLSearchParams({ alertType, ruleType });
|
||||
if (classic) {
|
||||
search.set('showClassicCreateAlertsPage', 'true');
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
search.set(key, value);
|
||||
}
|
||||
@@ -474,23 +461,17 @@ export function alertsDriver(page: Page): Alerts {
|
||||
},
|
||||
|
||||
gotoV2: async (options = {}) => {
|
||||
await page.goto(form.url({ ...options, classic: false }));
|
||||
await page.goto(form.url(options));
|
||||
await expect(form.name()).toBeVisible();
|
||||
// `useShareBuilderUrl` serialises the default query shortly after mount;
|
||||
// asserting on the URL before that races its rewrite.
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
},
|
||||
|
||||
gotoV1: async (options = {}) => {
|
||||
await page.goto(form.url({ ...options, classic: true }));
|
||||
await expect(form.v1SaveButton()).toBeVisible();
|
||||
},
|
||||
|
||||
name: () => page.getByTestId('alert-name-input'),
|
||||
saveButton: () => page.getByTestId('save-alert-rule-button'),
|
||||
testButton: () => page.getByTestId('test-notification-button'),
|
||||
discardButton: () => page.getByTestId('discard-alert-rule-button'),
|
||||
v1SaveButton: () => page.getByTestId('alert-save-button'),
|
||||
|
||||
saveTooltip: async () => {
|
||||
await form.saveButton().hover();
|
||||
@@ -642,8 +623,6 @@ export function alertsDriver(page: Page): Alerts {
|
||||
},
|
||||
|
||||
typeSelectionTitle: () => page.getByText('Choose a type for the alert'),
|
||||
switchToClassicButton: () =>
|
||||
page.getByRole('button', { name: 'Switch to Classic Experience' }),
|
||||
newRuleChip: () => page.getByText('New Alert Rule'),
|
||||
addThresholdButton: () => page.getByTestId('add-threshold-button'),
|
||||
thresholdName: (index = 0) =>
|
||||
@@ -737,9 +716,6 @@ export function alertsDriver(page: Page): Alerts {
|
||||
historyBetaBadge: () => details.sectionTab('history').getByText('Beta'),
|
||||
enableToggle: () => page.getByTestId('alert-actions-toggle'),
|
||||
copyLinkButton: () => page.getByRole('button', { name: 'Copy link' }),
|
||||
headerField: (field) => page.getByTestId(`alert-header-${field}`),
|
||||
renameInput: () => page.getByTestId('alert-name'),
|
||||
renameConfirmButton: () => page.getByRole('button', { name: 'Rename Alert' }),
|
||||
footerSaveButton: () => page.getByRole('button', { name: 'Save Alert Rule' }),
|
||||
notFound: () => page.locator('.alert-not-found'),
|
||||
notFoundMessage: () =>
|
||||
|
||||
1
tests/e2e/drivers/alerts/index.ts
generated
1
tests/e2e/drivers/alerts/index.ts
generated
@@ -41,4 +41,3 @@ export { alertsDriver, HISTORY_ENDPOINTS, withAlerts } from './driver';
|
||||
export type { Alerts, HistoryEndpoint } from './driver';
|
||||
export * from './constants';
|
||||
export type { TimelineItem, TimelineResponse } from './types';
|
||||
export { V1_RULE_TEAM_LABEL } from './seeds';
|
||||
|
||||
62
tests/e2e/drivers/alerts/payloads.ts
generated
62
tests/e2e/drivers/alerts/payloads.ts
generated
@@ -9,7 +9,7 @@ const ANNOTATIONS = {
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
};
|
||||
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1) threshold rule
|
||||
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
|
||||
// CreateAlertV2 UI posts to POST /api/v2/rules.
|
||||
export function buildThresholdRulePayload({
|
||||
@@ -43,9 +43,7 @@ export function buildThresholdRulePayload({
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
...(labels ? { labels } : {}),
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: {
|
||||
@@ -234,9 +232,7 @@ export function v2RulePayload({
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: { kind: 'rolling', spec: { evalWindow, frequency } },
|
||||
@@ -266,59 +262,3 @@ export function v2RulePayload({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy schema: `evalWindow`/`frequency` sit at the top level, channels are
|
||||
// `preferredChannels`, and `condition.{op,target,matchType}` are the numeric
|
||||
// enum forms the v1 validator requires. `labels.severity` becomes the history
|
||||
// `threshold.name`.
|
||||
export function v1RulePayload({
|
||||
name,
|
||||
alertType,
|
||||
compositeQuery,
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
target = 0,
|
||||
op = '1',
|
||||
matchType = '1',
|
||||
}: {
|
||||
name: string;
|
||||
alertType: string;
|
||||
compositeQuery: Record<string, unknown>;
|
||||
channels: string[];
|
||||
severity: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow: string;
|
||||
frequency: string;
|
||||
extraCondition?: Record<string, unknown>;
|
||||
target?: number;
|
||||
op?: string;
|
||||
matchType?: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
disabled: false,
|
||||
source: '',
|
||||
evalWindow,
|
||||
frequency,
|
||||
preferredChannels: channels,
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
// Defaults match the history seeds' original shape — `target 0 / op above /
|
||||
// matchType at_least_once` fires on the first evaluation that sees data — so
|
||||
// overriding them is opt-in and cannot change what those seeds do.
|
||||
op,
|
||||
target,
|
||||
matchType,
|
||||
compositeQuery,
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
57
tests/e2e/drivers/alerts/seed.ts
generated
57
tests/e2e/drivers/alerts/seed.ts
generated
@@ -11,12 +11,10 @@ import {
|
||||
logsCompositeQuery,
|
||||
metricsCompositeQuery,
|
||||
tracesCompositeQuery,
|
||||
v1RulePayload,
|
||||
v2RulePayload,
|
||||
} from './payloads';
|
||||
import type {
|
||||
AlertRulesSeedOptions,
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
LogsSeedOptions,
|
||||
MetricAlertSeed,
|
||||
@@ -66,11 +64,8 @@ export interface AlertSeeder {
|
||||
export function alertSeeder(page: Page): AlertSeeder {
|
||||
const api = apiDriver(page);
|
||||
|
||||
const postRule = (
|
||||
schema: AlertSchema,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<string> =>
|
||||
api.create(schema === 'v1' ? '/api/v1/rules' : '/api/v2/rules', payload);
|
||||
const postRule = (payload: Record<string, unknown>): Promise<string> =>
|
||||
api.create('/api/v2/rules', payload);
|
||||
|
||||
const postTelemetry = (path: TelemetryPath, data: unknown[]): Promise<void> =>
|
||||
postToSeeder(page.request, path, data);
|
||||
@@ -97,39 +92,29 @@ export function alertSeeder(page: Page): AlertSeeder {
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
schema = 'v2',
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
severity = schema === 'v1' ? 'warning' : 'critical',
|
||||
severity = 'critical',
|
||||
extraLabels,
|
||||
alertOnAbsent,
|
||||
absentFor,
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
}) => {
|
||||
const args = {
|
||||
name,
|
||||
alertType: 'LOGS_BASED_ALERT',
|
||||
compositeQuery: logsCompositeQuery(marker),
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition:
|
||||
alertOnAbsent === undefined
|
||||
? undefined
|
||||
: { alertOnAbsent, absentFor: absentFor ?? 1 },
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
};
|
||||
return postRule(
|
||||
schema,
|
||||
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
|
||||
);
|
||||
},
|
||||
}) =>
|
||||
postRule(
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'LOGS_BASED_ALERT',
|
||||
compositeQuery: logsCompositeQuery(marker),
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition:
|
||||
alertOnAbsent === undefined
|
||||
? undefined
|
||||
: { alertOnAbsent, absentFor: absentFor ?? 1 },
|
||||
}),
|
||||
),
|
||||
|
||||
tracesRule: ({
|
||||
name,
|
||||
@@ -139,7 +124,6 @@ export function alertSeeder(page: Page): AlertSeeder {
|
||||
frequency = '15s',
|
||||
}) =>
|
||||
postRule(
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'TRACES_BASED_ALERT',
|
||||
@@ -160,7 +144,6 @@ export function alertSeeder(page: Page): AlertSeeder {
|
||||
frequency = '15s',
|
||||
}) =>
|
||||
postRule(
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
|
||||
29
tests/e2e/drivers/alerts/seeds.ts
generated
29
tests/e2e/drivers/alerts/seeds.ts
generated
@@ -111,14 +111,11 @@ export const withAlertList: WorkerLayer<{ alertList: AlertListSeed }> = seeded(
|
||||
export interface AlertHistorySeed extends Owned {
|
||||
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
|
||||
ruleId: string;
|
||||
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
|
||||
ruleIdV1: string;
|
||||
channelName: string;
|
||||
/** The `body CONTAINS` marker both rules match. */
|
||||
/** The `body CONTAINS` marker the rule matches. */
|
||||
marker: string;
|
||||
services: string[];
|
||||
total: number;
|
||||
totalV1: number;
|
||||
}
|
||||
|
||||
/** 25 services yields a multi-page timeline, which is what pagination needs. */
|
||||
@@ -129,9 +126,6 @@ const RESOLVED_HISTORY_SERVICES = 3;
|
||||
const METRICS_HISTORY_HOSTS = ['host-0', 'host-1'];
|
||||
const TRACES_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Team label for the v1 rule, so its header labels row is non-empty. */
|
||||
export const V1_RULE_TEAM_LABEL = 'e2e-platform';
|
||||
|
||||
export const withAlertHistory: WorkerLayer<{ alertHistory: AlertHistorySeed }> =
|
||||
seeded(
|
||||
'alertHistory',
|
||||
@@ -151,20 +145,10 @@ export const withAlertHistory: WorkerLayer<{ alertHistory: AlertHistorySeed }> =
|
||||
name: `e2e-ah-rule-v2-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v2',
|
||||
});
|
||||
const ruleIdV1 = await seed.logsRule({
|
||||
name: `e2e-ah-rule-v1-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v1',
|
||||
extraLabels: { team: V1_RULE_TEAM_LABEL },
|
||||
});
|
||||
|
||||
await history.waitForEntries(ruleId, { min: LOGS_HISTORY_SERVICES });
|
||||
await history.waitForEntries(ruleIdV1, { min: LOGS_HISTORY_SERVICES });
|
||||
await seed.setDisabled(ruleId, true);
|
||||
await seed.setDisabled(ruleIdV1, true);
|
||||
|
||||
const total = await history.readTotal(ruleId);
|
||||
if (total !== LOGS_HISTORY_SERVICES) {
|
||||
@@ -175,13 +159,11 @@ export const withAlertHistory: WorkerLayer<{ alertHistory: AlertHistorySeed }> =
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
ruleIdV1,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total,
|
||||
totalV1: await history.readTotal(ruleIdV1),
|
||||
ruleIds: [ruleId, ruleIdV1],
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
},
|
||||
@@ -412,14 +394,10 @@ export interface OwnedRules {
|
||||
*/
|
||||
logs(options: {
|
||||
name: string;
|
||||
schema?: 'v1' | 'v2';
|
||||
marker?: string;
|
||||
severity?: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow?: string;
|
||||
target?: number;
|
||||
op?: string;
|
||||
matchType?: string;
|
||||
}): Promise<string>;
|
||||
/**
|
||||
* Track a rule the *app* created (Clone) so teardown removes it
|
||||
@@ -465,13 +443,12 @@ export const withOwnedRules: Layer<{ ownedRules: OwnedRules }> = ((
|
||||
}),
|
||||
),
|
||||
|
||||
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
|
||||
logs: ({ name, marker, ...overrides }) =>
|
||||
create((page) =>
|
||||
alertsDriver(page).seed.logsRule({
|
||||
name,
|
||||
marker: marker ?? `e2e alert never seeded ${name}`,
|
||||
channels: [alertChannel.name],
|
||||
schema,
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
6
tests/e2e/drivers/alerts/timeouts.ts
generated
6
tests/e2e/drivers/alerts/timeouts.ts
generated
@@ -44,10 +44,10 @@ export const WAIT_NODATA_TIMELINE_TIMEOUT = 180_000;
|
||||
export const FIXTURE_ALERT_LIST_TIMEOUT = 120_000;
|
||||
|
||||
/**
|
||||
* alertHistory: seeds 25 logs + 2 rules, waits for ruler evaluation.
|
||||
* alertHistory: seeds 25 logs + 1 rule, waits for ruler evaluation.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedLogs(10s) + 2×createRule(4s) +
|
||||
* 2×waitForEntries(70s) + 2×disableRule(4s) = ~90s.
|
||||
* Breakdown: createChannel(2s) + seedLogs(10s) + createRule(2s) +
|
||||
* waitForEntries(35s) + disableRule(2s) = ~51s.
|
||||
* Timeout: 240s (~2.5x headroom).
|
||||
*/
|
||||
export const FIXTURE_ALERT_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
22
tests/e2e/drivers/alerts/types.ts
generated
22
tests/e2e/drivers/alerts/types.ts
generated
@@ -65,24 +65,19 @@ export interface ThresholdSeedSpec {
|
||||
recoveryTarget?: number | null;
|
||||
}
|
||||
|
||||
/** Rule schema flavour. `v1` is the legacy payload posted to `/api/v1/rules`. */
|
||||
export type AlertSchema = 'v1' | 'v2';
|
||||
|
||||
export interface LogsAlertSeed {
|
||||
name: string;
|
||||
/** Substring the rule matches on (`body CONTAINS '<marker>'`). */
|
||||
marker: string;
|
||||
/** Channel *names* (not ids) — the API validates the reference. */
|
||||
channels: string[];
|
||||
schema?: AlertSchema;
|
||||
/** Go duration, e.g. `5m0s`. Shrink it to make the rule resolve fast. */
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
/** Becomes the history `threshold.name` for v1 rules (`processRuleDefaults`). */
|
||||
/** Becomes the threshold name, i.e. the history `threshold.name`. */
|
||||
severity?: string;
|
||||
/**
|
||||
* Extra rule labels merged alongside `severity`. They show up in the details
|
||||
* header's labels row (which renders `labels` minus `severity`) *and* as extra
|
||||
* Extra rule labels merged alongside `severity`. They show up as extra
|
||||
* history `filter_keys`, so add them only where a scenario needs them.
|
||||
*/
|
||||
extraLabels?: Record<string, string>;
|
||||
@@ -90,19 +85,6 @@ export interface LogsAlertSeed {
|
||||
alertOnAbsent?: boolean;
|
||||
/** `condition.absentFor`, in minutes. */
|
||||
absentFor?: number;
|
||||
|
||||
// ── SEED-RV1 extras ─────────────────────────────────────────────────────
|
||||
// v1 only. Same reasoning as SEED-RV2's block: an `EV1-*` prefill assertion
|
||||
// against the value the create form would have produced anyway proves nothing,
|
||||
// so each of these exists to differ from `alertDefaults`
|
||||
// (`container/CreateAlertRule/defaults.ts`).
|
||||
|
||||
/** `condition.target`. The v1 default is *absent*, so any number differs. */
|
||||
target?: number;
|
||||
/** `condition.op` as the legacy numeric string. `1` above, `2` below, … */
|
||||
op?: string;
|
||||
/** `condition.matchType`, same encoding. `1` at-least-once, `2` all-the-times. */
|
||||
matchType?: string;
|
||||
}
|
||||
|
||||
export interface TracesAlertSeed {
|
||||
|
||||
@@ -122,30 +122,7 @@ test.describe('Alert create', () => {
|
||||
expect(params.get('search')).toBeNull();
|
||||
});
|
||||
|
||||
test('TC-06 the classic experience replaces history rather than stacking it', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
}) => {
|
||||
await alerts.form.gotoV1({ alertType: AlertType.METRICS });
|
||||
await expect(alerts.form.v1SaveButton()).toBeVisible();
|
||||
// The clearest v1/v2 discriminator: the v2 header input is not there.
|
||||
await expect(alerts.form.name()).toBeHidden();
|
||||
|
||||
await alerts.form.gotoV2({ alertType: AlertType.METRICS });
|
||||
await alerts.form.switchToClassicButton().click();
|
||||
|
||||
await expect(alerts.form.v1SaveButton()).toBeVisible();
|
||||
expect(
|
||||
new URL(page.url()).searchParams.get('showClassicCreateAlertsPage'),
|
||||
).toBe('true');
|
||||
|
||||
// `safeNavigate(url, { replace: true })` — going back must skip the v2 entry
|
||||
// rather than bouncing between the two experiences.
|
||||
await page.goBack();
|
||||
await expect(alerts.form.name()).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-07 a save posts the v2 shape, keeps its labels and lands on the list', async ({
|
||||
test('TC-06 a save posts the v2 shape, keeps its labels and lands on the list', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
alertChannel,
|
||||
@@ -182,7 +159,6 @@ test.describe('Alert create', () => {
|
||||
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.schemaVersion).toBe('v2alpha1');
|
||||
expect(body.version).toBe('v5');
|
||||
expect(body.alert).toBe(name);
|
||||
// The label is asserted on the request rather than on the pill: the pill
|
||||
// only proves local state, and the defect worth guarding is a label that
|
||||
@@ -213,7 +189,7 @@ test.describe('Alert create', () => {
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts');
|
||||
});
|
||||
|
||||
test('TC-08 the evaluation window and cadence reach the payload', async ({
|
||||
test('TC-07 the evaluation window and cadence reach the payload', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
alertChannel,
|
||||
@@ -247,7 +223,7 @@ test.describe('Alert create', () => {
|
||||
expect(evaluation.spec.frequency).toBe('5m');
|
||||
});
|
||||
|
||||
test('TC-09 a test notification reports that a non-firing rule matched nothing', async ({
|
||||
test('TC-08 a test notification reports that a non-firing rule matched nothing', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
alertChannel,
|
||||
@@ -276,7 +252,7 @@ test.describe('Alert create', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-10 with no channels, routing policies are the only way to save', async ({
|
||||
test('TC-09 with no channels, routing policies are the only way to save', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
}) => {
|
||||
@@ -309,7 +285,7 @@ test.describe('Alert create', () => {
|
||||
expect(url.searchParams.get('subTab')).toBe('routing-policies');
|
||||
});
|
||||
|
||||
test('TC-11 Discard leaves without posting and resets the form', async ({
|
||||
test('TC-10 Discard leaves without posting and resets the form', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
alertChannel,
|
||||
@@ -334,7 +310,7 @@ test.describe('Alert create', () => {
|
||||
await expect(alerts.form.thresholdRows()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('TC-12 a rejected save opens the error modal and keeps the draft', async ({
|
||||
test('TC-11 a rejected save opens the error modal and keeps the draft', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
}) => {
|
||||
@@ -359,7 +335,7 @@ test.describe('Alert create', () => {
|
||||
]);
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
// Both forms funnel every save error into the shared error modal.
|
||||
// Every save error goes to the shared error modal.
|
||||
await expect(alerts.form.errorModal()).toBeVisible();
|
||||
await expect(alerts.shell.message(/do not exist/)).toBeVisible();
|
||||
|
||||
@@ -370,30 +346,17 @@ test.describe('Alert create', () => {
|
||||
await expect(alerts.form.name()).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('TC-13 none of the four builder mounts logs a console error', async ({
|
||||
test('TC-12 neither the create nor the edit builder logs a console error', async ({
|
||||
alerts,
|
||||
noise,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await alerts.form.gotoV2({ alertType: AlertType.LOGS });
|
||||
// Metrics-based v1 on purpose: the only alert type whose classic form
|
||||
// renders the detection-method step and the PromQL tab, i.e. the most code.
|
||||
await alerts.form.gotoV1({ alertType: AlertType.METRICS });
|
||||
|
||||
await alerts.details.gotoOverview(
|
||||
await ownedRules.threshold(`e2e-create-mount-v2-${Date.now()}`),
|
||||
);
|
||||
|
||||
// The v1 edit form renders no `threshold-value-input`, so the shell-level
|
||||
// wait is used and the classic form is asserted directly.
|
||||
await alerts.details.goto(
|
||||
await ownedRules.logs({
|
||||
name: `e2e-create-mount-v1-${Date.now()}`,
|
||||
schema: 'v1',
|
||||
}),
|
||||
);
|
||||
await expect(alerts.form.v1SaveButton()).toBeVisible();
|
||||
|
||||
expect(noise.errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -410,7 +373,7 @@ test.describe('Alert create', () => {
|
||||
// `.nav-item-data`, and a real click fails with "intercepts pointer events".
|
||||
// `{ force: true }` does not help. Only `dispatchEvent` gets through, which
|
||||
// proves the handler is fine and the defect is pointer delivery.
|
||||
test.skip('TC-14 the v2 Discard button is clickable', async ({
|
||||
test.skip('TC-13 the v2 Discard button is clickable', async ({
|
||||
authedPage: page,
|
||||
alerts,
|
||||
}) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user