mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-29 12:30:42 +00:00
Compare commits
14 Commits
fix/error-
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae7123c39 | ||
|
|
7faf122e93 | ||
|
|
d7435a9748 | ||
|
|
450ceed5fd | ||
|
|
63d0470894 | ||
|
|
c69b77e030 | ||
|
|
4daf425a19 | ||
|
|
6f2ee41dcb | ||
|
|
8ab8c63bea | ||
|
|
0513ab7f1f | ||
|
|
83b4754eaa | ||
|
|
120da85b49 | ||
|
|
b57e5e8e09 | ||
|
|
ba155067e7 |
@@ -93,9 +93,9 @@ tests/
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
│ ├── auth.ts
|
||||
│ └── dashboards.ts
|
||||
│ └── logs-explorer.ts
|
||||
├── testdata/ # static data files (JSON) used by helpers and tests
|
||||
│ └── apm-metrics.json # (example)
|
||||
│ └── traces/large-trace.json # (example)
|
||||
├── tests/ # Playwright .spec.ts files, one dir per feature area
|
||||
│ └── alerts/
|
||||
│ └── alerts.spec.ts # (example)
|
||||
@@ -110,8 +110,8 @@ tests/
|
||||
These two folders look similar but mean different things:
|
||||
|
||||
- **`fixtures/`** holds *Playwright test fixtures* (created via `test.extend({...})`). By the canonical definition, a fixture is "a consistent, predefined set of data, objects, or environmental conditions used to ensure tests run in a stable state" — i.e. setup/teardown that runs *automatically* around each test or worker. `auth.ts` matches: it extends Playwright's `test` with an `authedPage` that's logged-in before every test runs and torn down after. If the only thing in this folder ever is `auth.ts`, that's fine — fixtures are a deliberately small surface.
|
||||
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoDashboardsList(page)`) and the constants those helpers and the tests both refer to (e.g. `SEARCH_PLACEHOLDER`). Constants live next to the helpers that use them so a single import line in a test covers both.
|
||||
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `apm-metrics.json`, a real dashboard payload uploaded through the UI by an importer helper.
|
||||
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoLogsExplorer(page)`) and the constants those helpers and the tests both refer to (e.g. `LINKED_ROW_CLASS`). Constants live next to the helpers that use them so a single import line in a test covers both.
|
||||
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `traces/large-trace.json`, a real trace the trace-details helpers seed.
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
@@ -128,7 +128,7 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
@@ -187,7 +187,7 @@ Each spec follows these principles:
|
||||
});
|
||||
```
|
||||
This is faster and more reliable than a multi-step UI seed. Reach for the UI flow only when the test's *purpose* is asserting that flow.
|
||||
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `apm-metrics.json` is a real dashboard payload that `importApmMetricsDashboardViaUI` (in `helpers/dashboards.ts`) uploads through the actual Import JSON UI flow to seed a richly-tagged dashboard for search/list tests.
|
||||
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `traces/large-trace.json` is a real trace that `loadLargeTrace` (in `helpers/trace-details.ts`) seeds for the trace-details tests.
|
||||
|
||||
## How to write an E2E test?
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ const preview: Preview = {
|
||||
'Detail',
|
||||
[
|
||||
'Overview',
|
||||
'Panels',
|
||||
'Settings',
|
||||
'Variables',
|
||||
'New Variable',
|
||||
|
||||
@@ -97,7 +97,7 @@ interface ToggleAction {
|
||||
isOnlyOrAllClicked?: boolean;
|
||||
previousState?: CheckedState;
|
||||
sectionType?: SectionType;
|
||||
source?: QuickFiltersSource;
|
||||
quickFilterSource?: QuickFiltersSource;
|
||||
attributeValues?: string[];
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
|
||||
currentQuery: buildQuery(initialItems, initialExpression),
|
||||
activeQueryIndex: 0,
|
||||
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
|
||||
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
|
||||
quickFilterSource:
|
||||
c.action.quickFilterSource ?? QuickFiltersSource.LOGS_EXPLORER,
|
||||
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
|
||||
value: c.action.value,
|
||||
checked: c.action.checked,
|
||||
@@ -162,7 +163,7 @@ const TOGGLE_CASES: ToggleCase[] = [
|
||||
action: {
|
||||
value: 'a',
|
||||
checked: false,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
// `nin` is what the source asks for, but re-deriving the expression
|
||||
// normalises it. Nothing observes the difference: both infra pages send
|
||||
@@ -313,7 +314,7 @@ const TOGGLE_CASES: ToggleCase[] = [
|
||||
action: {
|
||||
value: 'b',
|
||||
checked: false,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
|
||||
|
||||
@@ -46,14 +46,18 @@ export function removeManagedClauses(expression: string, key: string): string {
|
||||
}
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
const SOURCES_WITH_SHORT_OPERATORS: QuickFiltersSource[] = [
|
||||
QuickFiltersSource.INFRA_MONITORING,
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
export function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
export function getNotInOperator(
|
||||
quickFilterSource: QuickFiltersSource,
|
||||
): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(quickFilterSource)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
@@ -134,7 +138,7 @@ export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
quickFilterSource,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
@@ -145,7 +149,7 @@ export function applyCheckboxToggle({
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
value: string;
|
||||
checked: boolean;
|
||||
@@ -240,7 +244,7 @@ export function applyCheckboxToggle({
|
||||
if (sectionType === SectionType.RELATED) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
op: getNotInOperator(quickFilterSource),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
@@ -380,7 +384,7 @@ export function applyCheckboxToggle({
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
op: getNotInOperator(quickFilterSource),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
@@ -404,7 +408,9 @@ export function applyCheckboxToggle({
|
||||
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
|
||||
op: checked
|
||||
? getOperatorValue(OPERATORS.IN)
|
||||
: getNotInOperator(quickFilterSource),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { SectionType } from './v2/itemRules';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
onFilterChange?: ((query: Query) => void) | null;
|
||||
@@ -38,7 +38,7 @@ interface UseCheckboxFilterActionsReturn {
|
||||
*/
|
||||
function useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
quickFilterSource,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
@@ -65,7 +65,7 @@ function useCheckboxFilterActions({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
quickFilterSource,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
@@ -21,7 +20,6 @@ export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
};
|
||||
|
||||
@@ -158,7 +156,7 @@ export function renderWithFilter(
|
||||
return render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
|
||||
@@ -33,7 +33,7 @@ import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
@@ -42,13 +42,18 @@ interface CheckboxFilterV2Props {
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const {
|
||||
quickFilterSource,
|
||||
filter,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
useFieldApis,
|
||||
} = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -75,7 +80,7 @@ export default function CheckboxFilterV2(
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
source,
|
||||
quickFilterSource,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
@@ -104,7 +109,7 @@ export default function CheckboxFilterV2(
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
quickFilterSource,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -44,7 +44,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -59,6 +59,8 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
expect(params.get('endUnixMilli')).toBe(
|
||||
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
|
||||
);
|
||||
expect(params.get('signal')).toBeNull();
|
||||
expect(params.get('source')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps non-AI sources on the signal-wide endpoint', async () => {
|
||||
@@ -70,7 +72,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'custom.query = "value"',
|
||||
@@ -82,7 +82,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: null,
|
||||
@@ -116,7 +116,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -154,7 +154,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -185,7 +185,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -222,7 +222,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -264,7 +264,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - field APIs', () => {
|
||||
it('sends the signal and source of the quick filter source to the signal-wide endpoint', async () => {
|
||||
const requests: URLSearchParams[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { values: { stringValues: ['ingest'] } },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.METER_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('ingest')).resolves.toBeInTheDocument();
|
||||
expect(requests[0].get('signal')).toBe(TelemetrytypesSignalDTO.metrics);
|
||||
expect(requests[0].get('source')).toBe(TelemetrytypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -101,7 +101,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -119,7 +119,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -173,7 +173,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -240,7 +240,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -288,7 +288,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -330,7 +330,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -394,7 +394,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -440,7 +440,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -468,7 +468,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -490,7 +490,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -516,7 +516,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -568,7 +568,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -601,7 +601,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -627,7 +627,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -652,7 +652,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -691,7 +691,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -714,7 +714,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
@@ -761,7 +761,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
@@ -837,7 +837,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
@@ -971,7 +971,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -55,7 +55,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -106,7 +106,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -140,7 +140,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -185,7 +185,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -221,7 +221,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -273,7 +273,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -321,7 +321,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -373,7 +373,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -424,7 +424,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -466,7 +466,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -521,7 +521,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -47,7 +47,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={closedFilter}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -104,7 +104,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -133,7 +133,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -152,7 +152,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -172,7 +172,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -195,7 +195,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldValuesConfig } from 'api/querySuggestions/types';
|
||||
import { QUICK_FILTER_SOURCE_TO_SIGNAL } from 'components/QuickFilters/constants';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
source?: QuickFiltersSource;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
@@ -38,12 +38,13 @@ export function useFieldValues({
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source,
|
||||
quickFilterSource,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
|
||||
const isAIObservability =
|
||||
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
@@ -59,14 +60,12 @@ export function useFieldValues({
|
||||
endUnixMilli,
|
||||
}
|
||||
: {
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[quickFilterSource],
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
|
||||
source: QUICK_FILTERS_SOURCE_TO_SOURCE[quickFilterSource],
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
|
||||
@@ -31,11 +31,11 @@ export type { FilterType };
|
||||
function Duration({
|
||||
filter,
|
||||
onFilterChange,
|
||||
source,
|
||||
quickFilterSource,
|
||||
}: {
|
||||
filter: IQuickFiltersConfig;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
source: QuickFiltersSource;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
}): JSX.Element {
|
||||
const [selectedFilters, setSelectedFilters] =
|
||||
useState<
|
||||
@@ -52,7 +52,7 @@ function Duration({
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const syncSelectedFilters = useMemo((): FilterType => {
|
||||
|
||||
@@ -49,15 +49,18 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
className,
|
||||
config,
|
||||
handleFilterVisibilityChange,
|
||||
source,
|
||||
quickFilterSource,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
signal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
savedViewsHeader,
|
||||
} = props;
|
||||
const savedFiltersSource =
|
||||
quickFilterSource === QuickFiltersSource.INFRA_MONITORING
|
||||
? undefined
|
||||
: quickFilterSource;
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const showIP = params.showIP ?? true;
|
||||
@@ -68,7 +71,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
customFilters,
|
||||
refetchCustomFilters,
|
||||
isCustomFiltersLoading,
|
||||
} = useFilterConfig({ signal, config });
|
||||
} = useFilterConfig({ savedFiltersSource, config });
|
||||
const {
|
||||
deniedPermissions: deniedSettingsPermissions,
|
||||
isLoading: isSettingsChecking,
|
||||
@@ -112,15 +115,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
// Show dropdown in ListView only for TRACES_EXPLORER source
|
||||
const shouldShowDropdownInListView =
|
||||
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
|
||||
isListView && quickFilterSource === QuickFiltersSource.TRACES_EXPLORER;
|
||||
|
||||
// AI observability builds a single query in the row-level views, so there is
|
||||
// no query for the selector to switch between.
|
||||
const isAIObservabilityRowView =
|
||||
source === QuickFiltersSource.AI_OBSERVABILITY &&
|
||||
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY &&
|
||||
(isListView || panelType === PANEL_TYPES.TRACE);
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
|
||||
|
||||
// clear all the filters for the query which is in sync with filters
|
||||
const handleReset = (): void => {
|
||||
@@ -290,7 +293,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderContent = (): JSX.Element => (
|
||||
<>
|
||||
{source === QuickFiltersSource.API_MONITORING && (
|
||||
{quickFilterSource === QuickFiltersSource.API_MONITORING && (
|
||||
<div className="api-quick-filters-header">
|
||||
<Typography.Text>Show IP addresses</Typography.Text>
|
||||
<Switch
|
||||
@@ -313,7 +316,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
quickFilterSource={quickFilterSource}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
@@ -324,7 +327,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
quickFilterSource={quickFilterSource}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
@@ -363,14 +366,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
<div className="quick-filters-container">
|
||||
<div className="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
{quickFilterSource !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
<section className="header">
|
||||
{renderLeftActions()}
|
||||
{renderRightActions()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{signal ? (
|
||||
{savedFiltersSource ? (
|
||||
<AuthZGuardContent
|
||||
checks={[QuickFilterReadPermission]}
|
||||
fallbackOnLoading={filtersSkeleton}
|
||||
@@ -391,9 +394,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isSettingsOpen && (
|
||||
{isSettingsOpen && savedFiltersSource && (
|
||||
<QuickFiltersSettings
|
||||
signal={signal}
|
||||
savedFiltersSource={savedFiltersSource}
|
||||
setIsSettingsOpen={setIsSettingsOpen}
|
||||
customFilters={customFilters}
|
||||
refetchCustomFilters={refetchCustomFilters}
|
||||
@@ -407,7 +410,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
QuickFilters.defaultProps = {
|
||||
onFilterChange: null,
|
||||
signal: '',
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
|
||||
@@ -34,17 +34,17 @@ jest.mock('container/SavedViews/SavedViewsRestore', () => ({
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
source,
|
||||
quickFilterSource,
|
||||
savedViewsHeader,
|
||||
handleFilterVisibilityChange,
|
||||
}: {
|
||||
source: string;
|
||||
quickFilterSource: string;
|
||||
savedViewsHeader?: React.ReactNode;
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source}
|
||||
{quickFilterSource}
|
||||
{handleFilterVisibilityChange && (
|
||||
<Button
|
||||
size="md"
|
||||
@@ -62,7 +62,7 @@ jest.mock('../../QuickFilters', () => ({
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
|
||||
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldKeysConfig } from 'api/querySuggestions/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { QUICK_FILTER_SOURCE_TO_SIGNAL } from 'components/QuickFilters/constants';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import {
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
|
||||
|
||||
function OtherFiltersSkeleton(): JSX.Element {
|
||||
return (
|
||||
@@ -32,18 +33,20 @@ function OtherFiltersSkeleton(): JSX.Element {
|
||||
}
|
||||
|
||||
function OtherFilters({
|
||||
signal,
|
||||
savedFiltersSource,
|
||||
inputValue,
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO;
|
||||
inputValue: string;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
|
||||
const isMeterDataSource =
|
||||
savedFiltersSource === QuickfiltertypesSourceDTO.meter;
|
||||
const isAIObservability =
|
||||
savedFiltersSource === QuickfiltertypesSourceDTO.ai_observability;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
@@ -53,9 +56,7 @@ function OtherFilters({
|
||||
? { searchText: inputValue }
|
||||
: {
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[savedFiltersSource],
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SignalType } from '../types';
|
||||
import AddedFilters from './AddedFilters';
|
||||
import useQuickFilterSettings from './hooks/useQuickFilterSettings';
|
||||
import OtherFilters from './OtherFilters';
|
||||
@@ -12,12 +12,12 @@ import OtherFilters from './OtherFilters';
|
||||
import './QuickFiltersSettings.styles.scss';
|
||||
|
||||
function QuickFiltersSettings({
|
||||
signal,
|
||||
savedFiltersSource,
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
refetchCustomFilters,
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
@@ -37,7 +37,7 @@ function QuickFiltersSettings({
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
refetchCustomFilters,
|
||||
signal,
|
||||
savedFiltersSource,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -73,7 +73,7 @@ function QuickFiltersSettings({
|
||||
setAddedFilters={setAddedFilters}
|
||||
/>
|
||||
<OtherFilters
|
||||
signal={signal}
|
||||
savedFiltersSource={savedFiltersSource}
|
||||
inputValue={debouncedInputValue}
|
||||
addedFilters={addedFilters}
|
||||
setAddedFilters={setAddedFilters}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SignalType } from '../../types';
|
||||
import OtherFilters from '../OtherFilters';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
@@ -43,10 +43,12 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
function renderOtherFilters(signal: SignalType): void {
|
||||
function renderOtherFilters(
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO,
|
||||
): void {
|
||||
render(
|
||||
<OtherFilters
|
||||
signal={signal}
|
||||
savedFiltersSource={savedFiltersSource}
|
||||
inputValue=""
|
||||
addedFilters={[]}
|
||||
setAddedFilters={jest.fn()}
|
||||
@@ -55,7 +57,7 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
}
|
||||
|
||||
it('reads AI observability keys from their own endpoint', async () => {
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
|
||||
|
||||
await expect(
|
||||
screen.findByText('gen_ai.request.model'),
|
||||
@@ -64,7 +66,7 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
});
|
||||
|
||||
it('does not narrow the AI keys by fieldContext', async () => {
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
|
||||
|
||||
// A `trace` context would return only the computed per-trace aggregates,
|
||||
// which cannot be filtered on.
|
||||
@@ -73,7 +75,7 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
});
|
||||
|
||||
it('keeps other signals on the signal-wide keys endpoint', async () => {
|
||||
renderOtherFilters(SignalType.TRACES);
|
||||
renderOtherFilters(QuickfiltertypesSourceDTO.traces);
|
||||
|
||||
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
|
||||
await waitFor(() => expect(aiKeysParams).toBeUndefined());
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export const SIGNAL_DATA_SOURCE_MAP = {
|
||||
[SignalType.LOGS]: DataSource.LOGS,
|
||||
[SignalType.TRACES]: DataSource.TRACES,
|
||||
[SignalType.EXCEPTIONS]: DataSource.TRACES,
|
||||
[SignalType.API_MONITORING]: DataSource.TRACES,
|
||||
[SignalType.METER_EXPLORER]: DataSource.METRICS,
|
||||
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
|
||||
};
|
||||
@@ -4,8 +4,8 @@ import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
QuickfiltertypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
@@ -16,7 +16,7 @@ interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO;
|
||||
}
|
||||
|
||||
interface UseQuickFilterSettingsReturn {
|
||||
@@ -37,7 +37,7 @@ const useQuickFilterSettings = ({
|
||||
customFilters,
|
||||
setIsSettingsOpen,
|
||||
refetchCustomFilters,
|
||||
signal,
|
||||
savedFiltersSource,
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
|
||||
@@ -113,21 +113,19 @@ const useQuickFilterSettings = ({
|
||||
);
|
||||
|
||||
const handleSaveChanges = useCallback((): void => {
|
||||
if (signal) {
|
||||
updateCustomFilters({
|
||||
pathParams: { source: signal },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [addedFilters, signal, updateCustomFilters]);
|
||||
updateCustomFilters({
|
||||
pathParams: { source: savedFiltersSource },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}, [addedFilters, savedFiltersSource, updateCustomFilters]);
|
||||
|
||||
return {
|
||||
handleSettingsClose,
|
||||
|
||||
16
frontend/src/components/QuickFilters/constants.ts
Normal file
16
frontend/src/components/QuickFilters/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { QuickFiltersSource } from './types';
|
||||
|
||||
export const QUICK_FILTER_SOURCE_TO_SIGNAL: Record<
|
||||
QuickFiltersSource,
|
||||
TelemetrytypesSignalDTO
|
||||
> = {
|
||||
[QuickFiltersSource.LOGS_EXPLORER]: TelemetrytypesSignalDTO.logs,
|
||||
[QuickFiltersSource.TRACES_EXPLORER]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.EXCEPTIONS]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.API_MONITORING]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.AI_OBSERVABILITY]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.METER_EXPLORER]: TelemetrytypesSignalDTO.metrics,
|
||||
[QuickFiltersSource.INFRA_MONITORING]: TelemetrytypesSignalDTO.metrics,
|
||||
};
|
||||
@@ -10,26 +10,26 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
|
||||
* mode track the last focused query.
|
||||
*/
|
||||
function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
function useActiveQueryIndex(quickFilterSource: QuickFiltersSource): number {
|
||||
const { lastUsedQuery, panelType } = useQueryBuilder();
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
// AI observability builds a single query in the row-level views, so its
|
||||
// filters always drive the first one there.
|
||||
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
|
||||
if (quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY) {
|
||||
return isListView || panelType === PANEL_TYPES.TRACE
|
||||
? 0
|
||||
: lastUsedQuery || 0;
|
||||
}
|
||||
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
return quickFilterSource === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, panelType, source, lastUsedQuery]);
|
||||
}, [isListView, panelType, quickFilterSource, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { IQuickFiltersConfig } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
|
||||
interface UseFilterConfigProps {
|
||||
signal?: SignalType;
|
||||
savedFiltersSource?: QuickfiltertypesSourceDTO;
|
||||
config: IQuickFiltersConfig[];
|
||||
}
|
||||
interface UseFilterConfigReturn {
|
||||
@@ -18,7 +19,7 @@ interface UseFilterConfigReturn {
|
||||
}
|
||||
|
||||
const useFilterConfig = ({
|
||||
signal,
|
||||
savedFiltersSource,
|
||||
config,
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const {
|
||||
@@ -26,8 +27,8 @@ const useFilterConfig = ({
|
||||
data,
|
||||
refetch,
|
||||
} = useGetQuickFilters(
|
||||
{ source: signal ?? '' },
|
||||
{ query: { enabled: !!signal } },
|
||||
{ source: savedFiltersSource ?? '' },
|
||||
{ query: { enabled: !!savedFiltersSource } },
|
||||
);
|
||||
|
||||
const customFilters = useMemo<TelemetryFieldKey[]>(
|
||||
@@ -35,11 +36,11 @@ const useFilterConfig = ({
|
||||
[data],
|
||||
);
|
||||
|
||||
const isDynamicFilters = !!signal;
|
||||
const isDynamicFilters = !!savedFiltersSource;
|
||||
|
||||
const filterConfig = useMemo(
|
||||
() => getFilterConfig(signal, customFilters, config),
|
||||
[config, customFilters, signal],
|
||||
() => getFilterConfig(customFilters, config),
|
||||
[config, customFilters],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -89,6 +89,15 @@ export const handlers = [
|
||||
fieldValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
];
|
||||
|
||||
export const noSavedFiltersHandler = rest.get(
|
||||
'http://localhost/api/v2/quick_filters/logs',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.logs, [])),
|
||||
),
|
||||
);
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
loadingFiltersHandlers,
|
||||
queryBuilder,
|
||||
selectedServiceQueryBuilder,
|
||||
noSavedFiltersHandler,
|
||||
} from './QuickFilters.stories.mocks';
|
||||
import { QuickFiltersSource, SignalType } from '../types';
|
||||
import { QuickFiltersSource } from '../types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Quick Filters',
|
||||
@@ -33,8 +34,7 @@ const meta = {
|
||||
args: {
|
||||
config: checkboxConfig,
|
||||
handleFilterVisibilityChange: (): void => undefined,
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
quickFilterSource: QuickFiltersSource.LOGS_EXPLORER,
|
||||
useFieldApis: fieldApis,
|
||||
},
|
||||
parameters: {
|
||||
@@ -94,13 +94,16 @@ export const LoadingFilters: Story = {
|
||||
|
||||
/** Empty: a loaded quick-filter configuration with no filters has no result rows. */
|
||||
export const NoResults: Story = {
|
||||
args: { config: [], signal: undefined },
|
||||
args: { config: [] },
|
||||
parameters: {
|
||||
msw: { handlers: [noSavedFiltersHandler, ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
/** Selection: an expanded checkbox shows the actual selected service values. */
|
||||
export const SelectedExpandedCheckbox: Story = {
|
||||
args: { signal: undefined },
|
||||
parameters: {
|
||||
msw: { handlers: [noSavedFiltersHandler, ...handlers] },
|
||||
signoz: {
|
||||
queryBuilder: selectedServiceQueryBuilder,
|
||||
},
|
||||
@@ -111,12 +114,18 @@ export const SelectedExpandedCheckbox: Story = {
|
||||
* Every tooltip the panel renders, held open: the reveal on each truncated
|
||||
* filter value. Nothing bounds those values, so the panel is answered with
|
||||
* service names long enough to be cut. The Service name filter carries them, so
|
||||
* the signal that would add the workspace's own dynamic filters is left off.
|
||||
* the workspace's own saved filter list is answered empty.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { signal: undefined, tooltipsOpen: true },
|
||||
args: { tooltipsOpen: true },
|
||||
parameters: {
|
||||
msw: { handlers: [fieldValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
msw: {
|
||||
handlers: [
|
||||
noSavedFiltersHandler,
|
||||
fieldValuesHandler(LONG_FILTER_VALUES),
|
||||
...handlers,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from '../types';
|
||||
import { QuickFiltersSource } from '../types';
|
||||
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@@ -35,8 +35,7 @@ jest.mock('container/ApiMonitoring/queryParams');
|
||||
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/logs`;
|
||||
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -81,11 +80,10 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderWithSignal(): void {
|
||||
function renderWithSavedList(): void {
|
||||
render(
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
signal={SIGNAL}
|
||||
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
/>,
|
||||
@@ -95,7 +93,7 @@ function renderWithSignal(): void {
|
||||
function renderStaticConfig(): void {
|
||||
render(
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={QuickFiltersConfig}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
@@ -108,7 +106,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
it('shows the inline denial instead of the filters, header stays', async () => {
|
||||
server.use(setupAuthzDeny(QuickFilterReadPermission));
|
||||
|
||||
renderWithSignal();
|
||||
renderWithSavedList();
|
||||
|
||||
await expect(
|
||||
screen.findByText(NOT_AUTHORIZED_TEXT),
|
||||
@@ -133,7 +131,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
it('renders the filters but disables the settings trigger', async () => {
|
||||
server.use(setupAuthzDeny(QuickFilterUpdatePermission));
|
||||
|
||||
renderWithSignal();
|
||||
renderWithSavedList();
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -158,7 +156,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
it('renders the filters and opens settings from the trigger', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
renderWithSignal();
|
||||
renderWithSavedList();
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -176,7 +174,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('static config pages (no signal)', () => {
|
||||
describe('static config pages (no saved list)', () => {
|
||||
it('is not gated even when every permission is denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
@@ -195,7 +193,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
);
|
||||
|
||||
renderWithSignal();
|
||||
renderWithSavedList();
|
||||
|
||||
await waitFor(() =>
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import {
|
||||
ApiMonitoringParams,
|
||||
useApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { getHostsQuickFiltersConfig } from 'container/InfraMonitoringHostsV2/utils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
@@ -16,7 +19,7 @@ import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
|
||||
import { IQuickFiltersConfig, QuickFiltersSource } from '../types';
|
||||
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@@ -33,9 +36,9 @@ const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
|
||||
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/logs`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/logs`;
|
||||
const anyQuickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/:source`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -54,6 +57,9 @@ const setupServer = (): void => {
|
||||
rest.get(quickFiltersListURL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
|
||||
),
|
||||
rest.get(anyQuickFiltersListURL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: { filters: [] } })),
|
||||
),
|
||||
rest.get(quickFiltersSuggestionsURL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(otherFiltersResponse)),
|
||||
),
|
||||
@@ -68,48 +74,24 @@ const setupServer = (): void => {
|
||||
};
|
||||
|
||||
function TestQuickFilters({
|
||||
signal = SignalType.LOGS,
|
||||
quickFilterSource = QuickFiltersSource.EXCEPTIONS,
|
||||
config = QuickFiltersConfig,
|
||||
}: {
|
||||
signal?: SignalType;
|
||||
quickFilterSource?: QuickFiltersSource;
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
quickFilterSource={quickFilterSource}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TestQuickFilters.defaultProps = {
|
||||
signal: '',
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
function TestQuickFiltersApiMonitoring({
|
||||
signal = SignalType.LOGS,
|
||||
config = QuickFiltersConfig,
|
||||
}: {
|
||||
signal?: SignalType;
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TestQuickFiltersApiMonitoring.defaultProps = {
|
||||
signal: '',
|
||||
quickFilterSource: QuickFiltersSource.EXCEPTIONS,
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
@@ -287,10 +269,14 @@ describe('Quick Filters', () => {
|
||||
it('toggles Show IP addresses and updates API Monitoring params', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFiltersApiMonitoring />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.API_MONITORING} />,
|
||||
);
|
||||
|
||||
// Switch should be rendered and initially checked
|
||||
expect(screen.getByText('Show IP addresses')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Show IP addresses'),
|
||||
).resolves.toBeInTheDocument();
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
@@ -308,7 +294,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('loads the custom filters correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Filters for')).toBeInTheDocument();
|
||||
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
|
||||
@@ -368,7 +356,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -396,7 +386,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -417,7 +409,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -446,7 +440,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('restores original filter state on Discard', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -488,7 +484,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('saves the updated filters by calling PUT with correct payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -525,7 +523,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
const { getByTestId } = render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||
|
||||
@@ -579,7 +579,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
describe('Quick Filters refetch behavior', () => {
|
||||
it('fetches custom filters on every mount when signal is provided', async () => {
|
||||
it('fetches custom filters on every mount for a page with a saved list', async () => {
|
||||
let getCalls = 0;
|
||||
|
||||
server.use(
|
||||
@@ -589,14 +589,18 @@ describe('Quick Filters refetch behavior', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
const { unmount } = render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -604,19 +608,24 @@ describe('Quick Filters refetch behavior', () => {
|
||||
expect(getCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('does not fetch custom filters when signal is undefined', async () => {
|
||||
it('does not fetch custom filters for a page without a saved list', async () => {
|
||||
let getCalls = 0;
|
||||
|
||||
server.use(
|
||||
rest.get(quickFiltersListURL, (_req, res, ctx) => {
|
||||
rest.get(anyQuickFiltersListURL, (_req, res, ctx) => {
|
||||
getCalls += 1;
|
||||
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={undefined} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.INFRA_MONITORING} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getCalls).toBe(0));
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(getCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('refetches custom filters after saving settings', async () => {
|
||||
@@ -635,7 +644,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -687,7 +698,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -718,10 +731,47 @@ describe('Quick Filters refetch behavior', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
|
||||
render(
|
||||
<TestQuickFilters
|
||||
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
config={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('No filters found'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick Filters field APIs', () => {
|
||||
it('sends the page signal for every filter, the infra Environment filter included', async () => {
|
||||
const requests: URLSearchParams[] = [];
|
||||
server.use(
|
||||
rest.get(fieldsValuesURL, (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<QuickFilters
|
||||
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig()}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requests.map((params) => params.get('name'))).toContain(
|
||||
INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
|
||||
),
|
||||
);
|
||||
const environment = requests.find(
|
||||
(params) =>
|
||||
params.get('name') === INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
|
||||
);
|
||||
expect(environment?.get('signal')).toBe(TelemetrytypesSignalDTO.metrics);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export enum FiltersType {
|
||||
SLIDER = 'SLIDER',
|
||||
@@ -19,15 +19,6 @@ export enum SpecficFilterOperations {
|
||||
ONLY = 'ONLY',
|
||||
}
|
||||
|
||||
export enum SignalType {
|
||||
TRACES = 'traces',
|
||||
LOGS = 'logs',
|
||||
API_MONITORING = 'api_monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai_observability',
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
|
||||
*/
|
||||
@@ -39,7 +30,6 @@ export interface IQuickFiltersConfig {
|
||||
attributeKey: BaseAutocompleteData;
|
||||
aggregateOperator?: string;
|
||||
aggregateAttribute?: string;
|
||||
dataSource?: DataSource;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
defaultOpen: boolean;
|
||||
}
|
||||
@@ -53,11 +43,9 @@ export interface QuickFilterChangeEventData {
|
||||
export interface IQuickFiltersProps {
|
||||
config: IQuickFiltersConfig[];
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
source: QuickFiltersSource;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
/** Pass to fetch quick filters for this signal; omit to use `config` as-is */
|
||||
signal?: SignalType;
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
@@ -65,15 +53,19 @@ export interface IQuickFiltersProps {
|
||||
savedViewsHeader?: ReactNode;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
LOGS_EXPLORER = 'logs-explorer',
|
||||
INFRA_MONITORING = 'infra-monitoring',
|
||||
TRACES_EXPLORER = 'traces-explorer',
|
||||
API_MONITORING = 'api-monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai-observability',
|
||||
}
|
||||
export const QuickFiltersSource = {
|
||||
LOGS_EXPLORER: QuickfiltertypesSourceDTO.logs,
|
||||
TRACES_EXPLORER: QuickfiltertypesSourceDTO.traces,
|
||||
EXCEPTIONS: QuickfiltertypesSourceDTO.exceptions,
|
||||
API_MONITORING: QuickfiltertypesSourceDTO.api_monitoring,
|
||||
METER_EXPLORER: QuickfiltertypesSourceDTO.meter,
|
||||
AI_OBSERVABILITY: QuickfiltertypesSourceDTO.ai_observability,
|
||||
/** No saved filter list: renders `config` as given. */
|
||||
INFRA_MONITORING: 'infra-monitoring',
|
||||
} as const;
|
||||
|
||||
export type QuickFiltersSource =
|
||||
(typeof QuickFiltersSource)[keyof typeof QuickFiltersSource];
|
||||
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: number;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
|
||||
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
|
||||
import { FiltersType, IQuickFiltersConfig } from './types';
|
||||
|
||||
const FILTER_TITLE_MAP: Record<string, string> = {
|
||||
duration_nano: 'Duration',
|
||||
@@ -50,11 +49,10 @@ const getFilterType = (att: TelemetryFieldKey): FiltersType => {
|
||||
};
|
||||
|
||||
export const getFilterConfig = (
|
||||
signal?: SignalType,
|
||||
customFilters?: TelemetryFieldKey[],
|
||||
config?: IQuickFiltersConfig[],
|
||||
): IQuickFiltersConfig[] => {
|
||||
if (!customFilters?.length || !signal) {
|
||||
if (!customFilters?.length) {
|
||||
return config || [];
|
||||
}
|
||||
|
||||
@@ -63,7 +61,6 @@ export const getFilterConfig = (
|
||||
({
|
||||
type: getFilterType(att),
|
||||
title: getFilterName(att.name),
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
|
||||
attributeKey: {
|
||||
id: att.name,
|
||||
key: att.name,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -334,6 +334,7 @@ function AnomalyAlertEvaluationView({
|
||||
<Checkbox
|
||||
color="primary"
|
||||
key={seriesKey}
|
||||
testId="anomaly-series-option"
|
||||
name="series"
|
||||
value={selectedSeries === seriesKey}
|
||||
onChange={(): void => handleSeriesChange(seriesKey)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
@@ -24,8 +24,7 @@ function Explorer(): JSX.Element {
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
quickFilterSource: QuickFiltersSource.API_MONITORING,
|
||||
showQueryName: false,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
|
||||
@@ -91,7 +91,6 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -102,7 +101,6 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -113,7 +111,6 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,148 +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.mock('react-router-dom-v5-compat', () => ({
|
||||
...jest.requireActual('react-router-dom-v5-compat'),
|
||||
useNavigationType: jest.fn(() => 'PUSH'),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/alerts/new',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
})),
|
||||
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
|
||||
}));
|
||||
|
||||
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,84 +0,0 @@
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
|
||||
search: 'ruleType=anomaly_rule',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom-v5-compat', () => ({
|
||||
...jest.requireActual('react-router-dom-v5-compat'),
|
||||
useNavigationType: jest.fn(() => 'PUSH'),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/alerts/new',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
hash: '',
|
||||
state: null,
|
||||
})),
|
||||
useSearchParams: jest.fn(() => [new URLSearchParams(), jest.fn()]),
|
||||
}));
|
||||
|
||||
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', () => {
|
||||
const { getByRole } = render(
|
||||
<MemoryRouter initialEntries={['/alerts/new']}>
|
||||
<Route path={ROUTES.ALERTS_NEW}>
|
||||
<CreateAlertPage />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import {
|
||||
LicensePlatform,
|
||||
LicenseResModel,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -8,6 +11,10 @@ import SelectAlertType from '..';
|
||||
|
||||
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
|
||||
|
||||
const CLOUD_LICENSE = {
|
||||
platform: LicensePlatform.CLOUD,
|
||||
} as LicenseResModel;
|
||||
|
||||
describe('SelectAlertType', () => {
|
||||
const mockOnSelect = jest.fn();
|
||||
|
||||
@@ -19,15 +26,7 @@ describe('SelectAlertType', () => {
|
||||
it('should render all alert type options when anomaly detection is enabled', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
activeLicense: CLOUD_LICENSE,
|
||||
});
|
||||
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
@@ -39,6 +38,19 @@ describe('SelectAlertType', () => {
|
||||
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should offer the anomaly based alert on an enterprise self-hosted license', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
activeLicense: {
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
} as LicenseResModel,
|
||||
});
|
||||
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
|
||||
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all alert type options except anomaly based alert when anomaly detection is disabled', () => {
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
|
||||
@@ -62,15 +74,7 @@ describe('SelectAlertType', () => {
|
||||
it('should call onSelect with anomaly based alert type', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
activeLicense: CLOUD_LICENSE,
|
||||
});
|
||||
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
|
||||
@@ -5,8 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -17,11 +16,8 @@ import { OptionType } from './types';
|
||||
|
||||
function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
|
||||
const { t } = useTranslation(['alerts']);
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
?.active || false;
|
||||
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
|
||||
|
||||
const optionList = getOptionList(t, isAnomalyDetectionEnabled);
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import {
|
||||
LicensePlatform,
|
||||
LicenseResModel,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
@@ -32,25 +35,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({
|
||||
@@ -75,13 +59,16 @@ const useUrlQuerySpy = jest.spyOn(useUrlQueryHooks, 'default');
|
||||
const useSafeNavigateSpy = jest.spyOn(navigateHooks, 'useSafeNavigate');
|
||||
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
|
||||
|
||||
const CLOUD_LICENSE = {
|
||||
platform: LicensePlatform.CLOUD,
|
||||
} as LicenseResModel;
|
||||
|
||||
const mockSetUrlQuery = jest.fn();
|
||||
const mockToString = jest.fn();
|
||||
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', () => {
|
||||
@@ -100,20 +87,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) {
|
||||
@@ -129,7 +102,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();
|
||||
});
|
||||
|
||||
@@ -152,19 +124,16 @@ describe('CreateAlertRule', () => {
|
||||
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
|
||||
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';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
@@ -272,15 +241,7 @@ describe('CreateAlertRule', () => {
|
||||
it('should navigate with anomaly detection params for anomaly alert', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
activeLicense: CLOUD_LICENSE,
|
||||
});
|
||||
|
||||
render(<CreateAlertRule />);
|
||||
@@ -296,27 +257,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,37 +15,32 @@ 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;
|
||||
|
||||
useEffect(() => {
|
||||
if (isTypeSelectionMode) {
|
||||
logEvent('Alert: New alert data source selection page visited', {});
|
||||
void logEvent('Alert: New alert data source selection page visited', {});
|
||||
}
|
||||
}, [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,29 +82,8 @@ function CreateRules(): JSX.Element {
|
||||
if (isTypeSelectionMode) {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
if (
|
||||
showClassicCreateAlertsPageFlag ||
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
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'] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
:global(.ant-tooltip).tabTooltip {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.switchNote {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
@@ -1,20 +1,30 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
import { Activity, ChartLine } from '@signozhq/icons';
|
||||
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
|
||||
import Stepper from '../Stepper';
|
||||
import { useIsAnomalyDetectionAvailable } from '../useIsAnomalyDetectionAvailable';
|
||||
import AlertThreshold from './AlertThreshold';
|
||||
import AnomalyThreshold from './AnomalyThreshold';
|
||||
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
|
||||
import {
|
||||
ANOMALY_TAB_SWITCH_NOTE,
|
||||
ANOMALY_TAB_TOOLTIP,
|
||||
THRESHOLD_TAB_SWITCH_NOTE,
|
||||
THRESHOLD_TAB_TOOLTIP,
|
||||
} from './constants';
|
||||
import { useAnomalyFunctionSync } from './useAnomalyFunctionSync';
|
||||
|
||||
import styles from './AlertCondition.module.scss';
|
||||
import './styles.scss';
|
||||
|
||||
function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
useAnomalyFunctionSync();
|
||||
|
||||
const {
|
||||
data: channels,
|
||||
@@ -23,9 +33,12 @@ function AlertCondition(): JSX.Element {
|
||||
refetch: refreshChannels,
|
||||
} = useChannelOptions();
|
||||
|
||||
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
|
||||
|
||||
// Anomaly rules stay editable even without a cloud or enterprise license.
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -33,16 +46,15 @@ function AlertCondition(): JSX.Element {
|
||||
icon: <ChartLine size={14} data-testid="threshold-view" />,
|
||||
value: AlertTypes.METRICS_BASED_ALERT,
|
||||
},
|
||||
// Hide anomaly tab for now
|
||||
// ...(showMultipleTabs
|
||||
// ? [
|
||||
// {
|
||||
// label: 'Anomaly',
|
||||
// icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
// value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
...(showMultipleTabs
|
||||
? [
|
||||
{
|
||||
label: 'Anomaly',
|
||||
icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleAlertTypeChange = (value: AlertTypes): void => {
|
||||
@@ -52,11 +64,23 @@ function AlertCondition(): JSX.Element {
|
||||
setAlertType(value);
|
||||
};
|
||||
|
||||
const getTabTooltip = (tab: { value: AlertTypes }): string => {
|
||||
if (tab.value === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
return ANOMALY_TAB_TOOLTIP;
|
||||
const getTabTooltip = (tab: { value: AlertTypes }): ReactNode => {
|
||||
const isAnomalyTab = tab.value === AlertTypes.ANOMALY_BASED_ALERT;
|
||||
const description = isAnomalyTab
|
||||
? ANOMALY_TAB_TOOLTIP
|
||||
: THRESHOLD_TAB_TOOLTIP;
|
||||
if (tab.value === alertType) {
|
||||
return description;
|
||||
}
|
||||
return THRESHOLD_TAB_TOOLTIP;
|
||||
const switchNote = isAnomalyTab
|
||||
? ANOMALY_TAB_SWITCH_NOTE
|
||||
: THRESHOLD_TAB_SWITCH_NOTE;
|
||||
return (
|
||||
<>
|
||||
<div>{description}</div>
|
||||
<div className={styles.switchNote}>{switchNote}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -65,7 +89,11 @@ function AlertCondition(): JSX.Element {
|
||||
<div className="alert-condition">
|
||||
<div className="alert-condition-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<Tooltip key={tab.value} title={getTabTooltip(tab)}>
|
||||
<Tooltip
|
||||
key={tab.value}
|
||||
title={getTabTooltip(tab)}
|
||||
overlayClassName={styles.tabTooltip}
|
||||
>
|
||||
<Button
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': alertType === tab.value,
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Select, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_INFO_THRESHOLD,
|
||||
INITIAL_RANDOM_THRESHOLD,
|
||||
INITIAL_WARNING_THRESHOLD,
|
||||
THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
THRESHOLD_OPERATOR_OPTIONS,
|
||||
} from '../context/constants';
|
||||
@@ -23,7 +19,8 @@ import {
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { normalizeMatchType, normalizeOperator } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
||||
import { AnomalyAndThresholdProps } from './types';
|
||||
import { useThresholdList } from './useThresholdList';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
@@ -71,42 +68,7 @@ function AlertThreshold({
|
||||
selectedCategory || '',
|
||||
);
|
||||
|
||||
const addThreshold = (): void => {
|
||||
let newThreshold;
|
||||
if (thresholdState.thresholds.length === 1) {
|
||||
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
|
||||
} else if (thresholdState.thresholds.length === 2) {
|
||||
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
|
||||
} else {
|
||||
newThreshold = {
|
||||
...INITIAL_RANDOM_THRESHOLD,
|
||||
id: v4(),
|
||||
color: getRandomColor(),
|
||||
};
|
||||
}
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [...thresholdState.thresholds, newThreshold],
|
||||
});
|
||||
};
|
||||
|
||||
const removeThreshold = (id: string): void => {
|
||||
if (thresholdState.thresholds.length > 1) {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholdState.thresholds.filter((t) => t.id !== id),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateThreshold: UpdateThreshold = (id, field, value) => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholdState.thresholds.map((t) =>
|
||||
t.id === id ? { ...t, [field]: value } : t,
|
||||
),
|
||||
});
|
||||
};
|
||||
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
|
||||
|
||||
const onTooltipOpenChange = (open: boolean): void => {
|
||||
// Stop propagation of click events on tooltip text to dropdown
|
||||
@@ -294,11 +256,12 @@ function AlertThreshold({
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addThreshold}
|
||||
className="add-threshold-btn"
|
||||
data-testid="add-threshold-button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => addThreshold()}
|
||||
testId="add-threshold-button"
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.querySelect {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.algorithmSelect,
|
||||
.seasonalitySelect {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.operatorSelect,
|
||||
.matchTypeSelect {
|
||||
width: 180px;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
@@ -10,19 +10,20 @@ import {
|
||||
ANOMALY_SEASONALITY_OPTIONS,
|
||||
ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
|
||||
ANOMALY_TIME_DURATION_OPTIONS,
|
||||
INITIAL_ANOMALY_THRESHOLD,
|
||||
} from '../context/constants';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from '../context/types';
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { normalizeMatchType, normalizeOperator } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { AnomalyAndThresholdProps } from './types';
|
||||
import {
|
||||
getQueryNames,
|
||||
NotificationChannelsNotFoundContent,
|
||||
RoutingPolicyBanner,
|
||||
} from './utils';
|
||||
import { useThresholdList } from './useThresholdList';
|
||||
import { getQueryNames, RoutingPolicyBanner } from './utils';
|
||||
|
||||
import styles from './AnomalyThreshold.module.scss';
|
||||
|
||||
function AnomalyThreshold({
|
||||
channels,
|
||||
@@ -30,7 +31,6 @@ function AnomalyThreshold({
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
}: AnomalyAndThresholdProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const {
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
@@ -42,23 +42,13 @@ function AnomalyThreshold({
|
||||
|
||||
const queryNames = getQueryNames(currentQuery);
|
||||
|
||||
const deviationOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
|
||||
|
||||
const updateThreshold = (
|
||||
id: string,
|
||||
field: string,
|
||||
value: string | string[],
|
||||
): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholdState.thresholds.map((t) =>
|
||||
t.id === id ? { ...t, [field]: value } : t,
|
||||
const handleAddThreshold = (): void => {
|
||||
addThreshold({
|
||||
thresholdValue: Math.max(
|
||||
1,
|
||||
INITIAL_ANOMALY_THRESHOLD.thresholdValue - thresholdState.thresholds.length,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -69,11 +59,12 @@ function AnomalyThreshold({
|
||||
{/* Sentence 1 */}
|
||||
<div className="alert-condition-sentence">
|
||||
<Typography.Text data-testid="notification-text" className="sentence-text">
|
||||
Send notification when the observed value for
|
||||
Send a notification when
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.selectedQuery}
|
||||
data-testid="query-select"
|
||||
className={styles.querySelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
@@ -82,50 +73,16 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={queryNames}
|
||||
/>
|
||||
<Typography.Text
|
||||
data-testid="evaluation-window-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
during the last
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.evaluationWindow}
|
||||
data-testid="evaluation-window-select"
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_EVALUATION_WINDOW',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
options={ANOMALY_TIME_DURATION_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="alert-condition-sentence">
|
||||
{/* Sentence 2 */}
|
||||
<Typography.Text data-testid="threshold-text" className="sentence-text">
|
||||
is
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.thresholds[0].thresholdValue}
|
||||
data-testid="threshold-value-select"
|
||||
onChange={(value): void => {
|
||||
updateThreshold(
|
||||
thresholdState.thresholds[0].id,
|
||||
'thresholdValue',
|
||||
value.toString(),
|
||||
);
|
||||
}}
|
||||
options={deviationOptions}
|
||||
/>
|
||||
<Typography.Text data-testid="deviations-text" className="sentence-text">
|
||||
deviations
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={
|
||||
(normalizeOperator(thresholdState.operator) ??
|
||||
thresholdState.operator) as AlertThresholdOperator
|
||||
}
|
||||
data-testid="operator-select"
|
||||
className={styles.operatorSelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_OPERATOR',
|
||||
@@ -138,7 +95,7 @@ function AnomalyThreshold({
|
||||
data-testid="predicted-data-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
the predicted data
|
||||
the prediction
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={
|
||||
@@ -146,6 +103,7 @@ function AnomalyThreshold({
|
||||
thresholdState.matchType) as AlertThresholdMatchType
|
||||
}
|
||||
data-testid="match-type-select"
|
||||
className={styles.matchTypeSelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
@@ -154,15 +112,22 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
/>
|
||||
<Typography.Text
|
||||
data-testid="evaluation-window-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
during the <EvaluationSettings rollingOnly />
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{/* Sentence 3 */}
|
||||
<div className="alert-condition-sentence">
|
||||
{/* Sentence 2 */}
|
||||
<Typography.Text data-testid="using-the-text" className="sentence-text">
|
||||
using the
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.algorithm}
|
||||
data-testid="algorithm-select"
|
||||
className={styles.algorithmSelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_ALGORITHM',
|
||||
@@ -180,6 +145,7 @@ function AnomalyThreshold({
|
||||
<Select
|
||||
value={thresholdState.seasonality}
|
||||
data-testid="seasonality-select"
|
||||
className={styles.seasonalitySelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_SEASONALITY',
|
||||
@@ -188,52 +154,39 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={ANOMALY_SEASONALITY_OPTIONS}
|
||||
/>
|
||||
{notificationSettings.routingPolicies ? (
|
||||
<>
|
||||
<Typography.Text
|
||||
data-testid="seasonality-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
seasonality to
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.thresholds[0].channels}
|
||||
onChange={(value): void =>
|
||||
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
|
||||
}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
showSearch
|
||||
maxTagCount={2}
|
||||
maxTagPlaceholder={(omittedValues): string =>
|
||||
`+${omittedValues.length} more`
|
||||
}
|
||||
maxTagTextLength={10}
|
||||
filterOption={(input, option): boolean =>
|
||||
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
|
||||
}
|
||||
status={isErrorChannels ? 'error' : undefined}
|
||||
disabled={isLoadingChannels}
|
||||
notFoundContent={
|
||||
<NotificationChannelsNotFoundContent
|
||||
user={user}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text data-testid="seasonality-text" className="sentence-text">
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text data-testid="seasonality-text" className="sentence-text">
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sentence 3 */}
|
||||
<div className="thresholds-section">
|
||||
{thresholdState.thresholds.map((threshold, index) => (
|
||||
<ThresholdItem
|
||||
key={threshold.id}
|
||||
threshold={threshold}
|
||||
updateThreshold={updateThreshold}
|
||||
removeThreshold={removeThreshold}
|
||||
showRemoveButton={index !== 0 && thresholdState.thresholds.length > 1}
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
units={[]}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
isAnomaly
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleAddThreshold}
|
||||
testId="add-threshold-button"
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
</div>
|
||||
<RoutingPolicyBanner
|
||||
notificationSettings={notificationSettings}
|
||||
setNotificationSettings={setNotificationSettings}
|
||||
|
||||
@@ -21,6 +21,7 @@ function ThresholdItem({
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
isLoadingChannels,
|
||||
isAnomaly = false,
|
||||
}: ThresholdItemProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { thresholdState, notificationSettings } = useCreateAlertState();
|
||||
@@ -104,10 +105,16 @@ function ThresholdItem({
|
||||
style={{ width: 200 }}
|
||||
data-testid="threshold-name-input"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
{isAnomaly ? (
|
||||
<Typography.Text className="sentence-text">at</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
@@ -118,7 +125,11 @@ function ThresholdItem({
|
||||
type="number"
|
||||
data-testid="threshold-value-input"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
{isAnomaly ? (
|
||||
<Typography.Text className="sentence-text">deviations</Typography.Text>
|
||||
) : (
|
||||
yAxisUnitSelect
|
||||
)}
|
||||
{!notificationSettings.routingPolicies && (
|
||||
<>
|
||||
<Typography.Text className="sentence-text">send to</Typography.Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertCondition from '../AlertCondition';
|
||||
@@ -65,54 +65,55 @@ jest.mock('../AnomalyThreshold', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHandleSetQueryData: jest.MockedFunction<
|
||||
QueryBuilderContextType['handleSetQueryData']
|
||||
> = jest.fn();
|
||||
const mockRedirectWithQueryBuilderData: jest.MockedFunction<
|
||||
QueryBuilderContextType['redirectWithQueryBuilderData']
|
||||
> = jest.fn();
|
||||
const mockCurrentQuery = {
|
||||
dataSource: 'METRICS',
|
||||
queryName: 'A',
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'METRICS' }],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
currentQuery: {
|
||||
builder: { queryData: unknown[]; queryFormulas: unknown[] };
|
||||
dataSource: string;
|
||||
queryName: string;
|
||||
};
|
||||
redirectWithQueryBuilderData: () => void;
|
||||
} => ({
|
||||
currentQuery: {
|
||||
dataSource: 'METRICS',
|
||||
queryName: 'A',
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'METRICS' }],
|
||||
queryFormulas: [],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: mockCurrentQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
}),
|
||||
}));
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderAlertCondition = (
|
||||
alertType?: string,
|
||||
): ReturnType<typeof render> => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const initialEntries = alertType ? [`/?alertType=${alertType}`] : undefined;
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: alertType ? `/?alertType=${alertType}` : '/',
|
||||
appContextOverrides,
|
||||
},
|
||||
);
|
||||
|
||||
const clickText = async (text: string): Promise<void> => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByText(text));
|
||||
};
|
||||
|
||||
describe('AlertCondition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the stepper with correct step number and label', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
|
||||
@@ -125,10 +126,9 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(
|
||||
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
// ).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify threshold tab is active by default
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -136,8 +136,7 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders threshold tab by default', () => {
|
||||
@@ -152,8 +151,7 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
it('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
@@ -167,36 +165,47 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
it('warns in the tab tooltip that switching changes what gets saved', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAlertCondition();
|
||||
|
||||
await user.hover(screen.getByText(ANOMALY_TAB_TEXT));
|
||||
await expect(
|
||||
screen.findByText(/saves this as an anomaly-based alert/),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText(ANOMALY_TAB_TEXT));
|
||||
await user.hover(screen.getByText(THRESHOLD_TAB_TEXT));
|
||||
await expect(
|
||||
screen.findByText(/saves this as a metrics-based alert/),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AnomalyThreshold component when alert type is anomaly based', async () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Click on anomaly tab to switch to anomaly-based alert
|
||||
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
|
||||
fireEvent.click(anomalyTab);
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
|
||||
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('switches between threshold and anomaly tabs', () => {
|
||||
it('switches between threshold and anomaly tabs', async () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Initially shows threshold component
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
// Click anomaly tab
|
||||
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
|
||||
fireEvent.click(anomalyTab);
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
|
||||
// Should show anomaly component
|
||||
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
// Click threshold tab
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
fireEvent.click(thresholdTab);
|
||||
await clickText(THRESHOLD_TAB_TEXT);
|
||||
|
||||
// Should show threshold component again
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
@@ -205,8 +214,40 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('applies active tab styling correctly', () => {
|
||||
it('adds the anomaly function with the default z-score when switching to anomaly', async () => {
|
||||
renderAlertCondition();
|
||||
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
|
||||
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
functions: [
|
||||
{
|
||||
name: 'anomaly',
|
||||
args: [],
|
||||
namedArgs: { z_score_threshold: 3, seasonality: 'hourly' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the current query when switching between threshold and anomaly', async () => {
|
||||
renderAlertCondition();
|
||||
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
mockCurrentQuery,
|
||||
{ alertType: AlertTypes.ANOMALY_BASED_ALERT, ruleType: 'anomaly_rule' },
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies active tab styling correctly', async () => {
|
||||
renderAlertCondition();
|
||||
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -217,7 +258,7 @@ describe('AlertCondition', () => {
|
||||
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
|
||||
|
||||
// Click anomaly tab
|
||||
fireEvent.click(anomalyTab);
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
|
||||
// Anomaly tab should be active now
|
||||
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
|
||||
@@ -227,11 +268,17 @@ describe('AlertCondition', () => {
|
||||
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
|
||||
renderAlertCondition('METRIC_BASED_ALERT');
|
||||
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the anomaly tab for METRICS_BASED_ALERT on the community edition', () => {
|
||||
renderAlertCondition('METRIC_BASED_ALERT', { activeLicense: null });
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
|
||||
@@ -239,9 +286,8 @@ describe('AlertCondition', () => {
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { render, screen, userEvent, within } from 'tests/test-utils';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
import * as context from '../../context';
|
||||
import { AlertThresholdMatchType } from '../../context/types';
|
||||
import AnomalyThreshold from '../AnomalyThreshold';
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@@ -22,12 +20,11 @@ jest.mock('uplot', () => {
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
}),
|
||||
);
|
||||
const mockContextState = createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
});
|
||||
const useCreateAlertStateSpy = jest.spyOn(context, 'useCreateAlertState');
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@@ -52,10 +49,12 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const channels: ChannelOption[] = [{ id: '1', name: 'slack-alerts' }];
|
||||
|
||||
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<AnomalyThreshold
|
||||
channels={[]}
|
||||
channels={channels}
|
||||
isLoadingChannels={false}
|
||||
isErrorChannels={false}
|
||||
refreshChannels={jest.fn()}
|
||||
@@ -65,31 +64,117 @@ const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
describe('AnomalyThreshold', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useCreateAlertStateSpy.mockReturnValue(mockContextState);
|
||||
});
|
||||
|
||||
it('renders the first condition sentence', () => {
|
||||
renderAnomalyThreshold();
|
||||
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('evaluation-window-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('evaluation-window-select')).toBeInTheDocument();
|
||||
});
|
||||
const pickOption = async (testId: string, title: string): Promise<void> => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(within(screen.getByTestId(testId)).getByRole('combobox'));
|
||||
await user.click(await screen.findByTitle(title));
|
||||
};
|
||||
|
||||
it('renders the second condition sentence', () => {
|
||||
it('renders the condition sentence in the same order as threshold alerts', () => {
|
||||
renderAnomalyThreshold();
|
||||
expect(screen.getByTestId('threshold-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-value-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('deviations-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-text')).toHaveTextContent(
|
||||
'Send a notification when',
|
||||
);
|
||||
expect(screen.getByTestId('query-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('operator-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('predicted-data-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('predicted-data-text')).toHaveTextContent(
|
||||
'the prediction',
|
||||
);
|
||||
expect(screen.getByTestId('match-type-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('evaluation-settings-button')).toHaveTextContent(
|
||||
'Rolling',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the third condition sentence', () => {
|
||||
it('renders the algorithm and seasonality sentence', () => {
|
||||
renderAnomalyThreshold();
|
||||
expect(screen.getByTestId('using-the-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('algorithm-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('algorithm-with-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('seasonality-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('seasonality-text')).toHaveTextContent(
|
||||
'seasonality',
|
||||
);
|
||||
});
|
||||
|
||||
it('renames the severity from its threshold row', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAnomalyThreshold();
|
||||
await user.type(screen.getByTestId('threshold-name-input'), 'x');
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ label: 'criticalx' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a typed deviation', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
useCreateAlertStateSpy.mockReturnValue({
|
||||
...mockContextState,
|
||||
thresholdState: {
|
||||
...mockContextState.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...mockContextState.thresholdState.thresholds[0],
|
||||
thresholdValue: '' as unknown as number,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
renderAnomalyThreshold();
|
||||
await user.type(screen.getByTestId('threshold-value-input'), '4');
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ thresholdValue: '4' })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ON AVERAGE', AlertThresholdMatchType.ON_AVERAGE],
|
||||
['LAST', AlertThresholdMatchType.LAST],
|
||||
])('offers the %s match type', async (title, matchType) => {
|
||||
renderAnomalyThreshold();
|
||||
await pickOption('match-type-select', title);
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: matchType,
|
||||
});
|
||||
});
|
||||
|
||||
it('picks channels by name when routing policies are off', async () => {
|
||||
renderAnomalyThreshold();
|
||||
await pickOption('threshold-notification-channel-select', 'slack-alerts');
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ channels: ['slack-alerts'] })],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the next severity one deviation lower', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAnomalyThreshold();
|
||||
await user.click(screen.getByTestId('add-threshold-button'));
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [
|
||||
mockContextState.thresholdState.thresholds[0],
|
||||
expect.objectContaining({ label: 'warning', thresholdValue: 2 }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the channel picker when routing policies are on', () => {
|
||||
useCreateAlertStateSpy.mockReturnValue({
|
||||
...mockContextState,
|
||||
notificationSettings: {
|
||||
...mockContextState.notificationSettings,
|
||||
routingPolicies: true,
|
||||
},
|
||||
});
|
||||
renderAnomalyThreshold();
|
||||
expect(
|
||||
screen.queryByTestId('threshold-notification-channel-select'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,14 @@ describe('ThresholdItem', () => {
|
||||
expect(valueInput).toHaveValue(100);
|
||||
});
|
||||
|
||||
it('asks for deviations instead of a unit for anomaly alerts', () => {
|
||||
renderThresholdItem({ isAnomaly: true });
|
||||
|
||||
expect(screen.getByText('deviations')).toBeInTheDocument();
|
||||
expect(screen.queryByText('on value')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-unit-select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders unit selector with correct value', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
|
||||
import { INITIAL_CRITICAL_THRESHOLD } from '../../context/constants';
|
||||
import { Threshold } from '../../context/types';
|
||||
|
||||
import { getSmallestDeviation, syncAnomalyFunction } from '../utils';
|
||||
|
||||
const timeShift: QueryFunction = { name: 'timeShift', args: [{ value: 3600 }] };
|
||||
const anomaly = (
|
||||
zScoreThreshold: number,
|
||||
seasonality: string,
|
||||
): QueryFunction => ({
|
||||
name: 'anomaly',
|
||||
args: [],
|
||||
namedArgs: { z_score_threshold: zScoreThreshold, seasonality },
|
||||
});
|
||||
|
||||
describe('syncAnomalyFunction', () => {
|
||||
it('adds the anomaly function after the existing functions', () => {
|
||||
expect(
|
||||
syncAnomalyFunction([timeShift], {
|
||||
z_score_threshold: 3,
|
||||
seasonality: 'daily',
|
||||
}),
|
||||
).toStrictEqual([timeShift, anomaly(3, 'daily')]);
|
||||
});
|
||||
|
||||
it('replaces stale args and gives the same result when run again', () => {
|
||||
const namedArgs = { z_score_threshold: 5, seasonality: 'weekly' };
|
||||
const synced = syncAnomalyFunction(
|
||||
[anomaly(3, 'hourly'), timeShift],
|
||||
namedArgs,
|
||||
);
|
||||
expect(synced).toStrictEqual([timeShift, anomaly(5, 'weekly')]);
|
||||
expect(syncAnomalyFunction(synced, namedArgs)).toStrictEqual(synced);
|
||||
});
|
||||
|
||||
it('removes the anomaly function when no args are given', () => {
|
||||
expect(
|
||||
syncAnomalyFunction([anomaly(3, 'daily'), timeShift], undefined),
|
||||
).toStrictEqual([timeShift]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSmallestDeviation', () => {
|
||||
it.each([
|
||||
[[3, 2], 2],
|
||||
[['2.5', 4], 2.5],
|
||||
[[3, '', 0], 3],
|
||||
[[''], 0],
|
||||
])('picks the smallest valid deviation from %p', (values, expected) => {
|
||||
const thresholds = values.map(
|
||||
(thresholdValue) =>
|
||||
({ ...INITIAL_CRITICAL_THRESHOLD, thresholdValue }) as Threshold,
|
||||
);
|
||||
expect(getSmallestDeviation(thresholds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,11 @@ export const THRESHOLD_TAB_TOOLTIP =
|
||||
export const ANOMALY_TAB_TOOLTIP =
|
||||
'An alert is triggered whenever the metric deviates from an expected pattern.';
|
||||
|
||||
export const THRESHOLD_TAB_SWITCH_NOTE =
|
||||
'Switching keeps your query but saves this as a metrics-based alert, not an anomaly alert.';
|
||||
|
||||
export const ANOMALY_TAB_SWITCH_NOTE =
|
||||
'Switching keeps your query but saves this as an anomaly-based alert. Anomaly detection works on Query Builder metric queries only.';
|
||||
|
||||
export const ROUTING_POLICIES_ROUTE =
|
||||
'/alerts?tab=Configuration&subTab=routing-policies';
|
||||
|
||||
@@ -90,8 +90,6 @@
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 240px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
@@ -255,28 +253,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-threshold-btn {
|
||||
margin-top: 8px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.routing-policies-info-banner {
|
||||
@@ -314,14 +290,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.anomaly-threshold-container {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-alert-threshold-container,
|
||||
.condensed-anomaly-threshold-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -26,6 +26,13 @@ export interface ThresholdItemProps {
|
||||
units: DefaultOptionType[];
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
isAnomaly?: boolean;
|
||||
}
|
||||
|
||||
export interface ThresholdList {
|
||||
addThreshold: (overrides?: Partial<Threshold>) => void;
|
||||
removeThreshold: (thresholdId: string) => void;
|
||||
updateThreshold: UpdateThreshold;
|
||||
}
|
||||
|
||||
export interface AnomalyAndThresholdProps {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { getSmallestDeviation, syncAnomalyFunction } from './utils';
|
||||
|
||||
// These args only drive the preview; the rule reads the threshold target and condition.seasonality.
|
||||
export function useAnomalyFunctionSync(): void {
|
||||
const { alertType, thresholdState } = useCreateAlertState();
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
const isAnomalyAlert = alertType === AlertTypes.ANOMALY_BASED_ALERT;
|
||||
const zScoreThreshold = getSmallestDeviation(thresholdState.thresholds);
|
||||
const { seasonality } = thresholdState;
|
||||
|
||||
useEffect(() => {
|
||||
const namedArgs = isAnomalyAlert
|
||||
? { z_score_threshold: zScoreThreshold, seasonality }
|
||||
: undefined;
|
||||
currentQuery.builder.queryData.forEach((queryData, index) => {
|
||||
const currentFunctions = queryData.functions || [];
|
||||
const functions = syncAnomalyFunction(currentFunctions, namedArgs);
|
||||
if (!isEqual(functions, currentFunctions)) {
|
||||
handleSetQueryData(index, { ...queryData, functions });
|
||||
}
|
||||
});
|
||||
}, [
|
||||
currentQuery.builder.queryData,
|
||||
isAnomalyAlert,
|
||||
zScoreThreshold,
|
||||
seasonality,
|
||||
handleSetQueryData,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
INITIAL_INFO_THRESHOLD,
|
||||
INITIAL_RANDOM_THRESHOLD,
|
||||
INITIAL_WARNING_THRESHOLD,
|
||||
} from '../context/constants';
|
||||
import { Threshold } from '../context/types';
|
||||
import { ThresholdList, UpdateThreshold } from './types';
|
||||
|
||||
export function useThresholdList(): ThresholdList {
|
||||
const { thresholdState, setThresholdState } = useCreateAlertState();
|
||||
const { thresholds } = thresholdState;
|
||||
|
||||
const addThreshold = (overrides?: Partial<Threshold>): void => {
|
||||
let newThreshold;
|
||||
if (thresholds.length === 1) {
|
||||
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
|
||||
} else if (thresholds.length === 2) {
|
||||
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
|
||||
} else {
|
||||
newThreshold = {
|
||||
...INITIAL_RANDOM_THRESHOLD,
|
||||
id: v4(),
|
||||
color: getRandomColor(),
|
||||
};
|
||||
}
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [...thresholds, { ...newThreshold, ...overrides }],
|
||||
});
|
||||
};
|
||||
|
||||
const removeThreshold = (id: string): void => {
|
||||
if (thresholds.length > 1) {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds.filter((t) => t.id !== id),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateThreshold: UpdateThreshold = (id, field, value) => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
||||
});
|
||||
};
|
||||
|
||||
return { addThreshold, removeThreshold, updateThreshold };
|
||||
}
|
||||
@@ -15,11 +15,13 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ROUTING_POLICIES_ROUTE } from './constants';
|
||||
import { Threshold } from '../context/types';
|
||||
import { RoutingPolicyBannerProps } from './types';
|
||||
|
||||
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
@@ -43,6 +45,24 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
return queryConfig[currentQuery.queryType]?.() || [];
|
||||
}
|
||||
|
||||
export function getSmallestDeviation(thresholds: Threshold[]): number {
|
||||
const deviations = thresholds
|
||||
.map(({ thresholdValue }) => Number(thresholdValue))
|
||||
.filter((deviation) => deviation > 0);
|
||||
return deviations.length > 0 ? Math.min(...deviations) : 0;
|
||||
}
|
||||
|
||||
export function syncAnomalyFunction(
|
||||
functions: QueryFunction[],
|
||||
namedArgs: QueryFunction['namedArgs'],
|
||||
): QueryFunction[] {
|
||||
const otherFunctions = functions.filter(({ name }) => name !== 'anomaly');
|
||||
if (!namedArgs) {
|
||||
return otherFunctions;
|
||||
}
|
||||
return [...otherFunctions, { name: 'anomaly', args: [], namedArgs }];
|
||||
}
|
||||
|
||||
export function getCategoryByOptionId(id: string): string | undefined {
|
||||
const categories = getYAxisCategories(YAxisSource.ALERTS);
|
||||
return categories.find((category) =>
|
||||
@@ -223,17 +243,22 @@ function TooltipExample({
|
||||
thresholdValue: number;
|
||||
matchType: AlertThresholdMatchType;
|
||||
}): JSX.Element {
|
||||
const pointsLabel =
|
||||
matchType === AlertThresholdMatchType.IN_TOTAL
|
||||
? 'error counts'
|
||||
: 'data points';
|
||||
return (
|
||||
<div className="tooltip-example">
|
||||
<strong>Example:</strong>
|
||||
<br />
|
||||
Say, For a 5-minute window (configured in Evaluation settings), 1 min
|
||||
aggregation interval (set up in query) → 5{' '}
|
||||
{matchType === AlertThresholdMatchType.IN_TOTAL
|
||||
? 'error counts'
|
||||
: 'data points'}
|
||||
: [{dataPoints.join(', ')}]<br />
|
||||
With threshold {operatorSymbol} {thresholdValue}: {children}
|
||||
<span className="translate-safe">
|
||||
{`Say, For a 5-minute window (configured in Evaluation settings), 1 min aggregation interval (set up in query) → 5 ${pointsLabel}: [${dataPoints.join(', ')}]`}
|
||||
</span>
|
||||
<br />
|
||||
<span className="translate-safe">
|
||||
{`With threshold ${operatorSymbol} ${thresholdValue}: `}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,20 +1,12 @@
|
||||
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 { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
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,
|
||||
@@ -105,37 +97,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,14 @@ import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||
import { IEvaluationSettingsProps } from './types';
|
||||
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
function EvaluationSettings(): JSX.Element {
|
||||
function EvaluationSettings({
|
||||
rollingOnly = false,
|
||||
}: IEvaluationSettingsProps): JSX.Element {
|
||||
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
|
||||
|
||||
const [isEvaluationWindowPopoverOpen, setIsEvaluationWindowPopoverOpen] =
|
||||
@@ -24,6 +27,7 @@ function EvaluationSettings(): JSX.Element {
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
rollingOnly={rollingOnly}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
|
||||
@@ -20,7 +20,12 @@ import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNa
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
rollingOnly = false,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const windowTypeOptions = rollingOnly
|
||||
? EVALUATION_WINDOW_TYPE.filter((option) => option.value === 'rolling')
|
||||
: EVALUATION_WINDOW_TYPE;
|
||||
|
||||
const { containerRef, firstItemRef } =
|
||||
useKeyboardNavigationForEvaluationWindowPopover({
|
||||
onSelect: (value: string, sectionId: string): void => {
|
||||
@@ -136,7 +141,7 @@ function EvaluationWindowPopover({
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
windowTypeOptions,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
|
||||
@@ -39,6 +39,18 @@ describe('EvaluationWindowPopover', () => {
|
||||
expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers only rolling windows when rollingOnly is set', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
rollingOnly
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Rolling')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Cumulative')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all window type options with rolling selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
|
||||
@@ -31,9 +31,14 @@ export enum CumulativeWindowTimeframes {
|
||||
CURRENT_MONTH = 'currentMonth',
|
||||
}
|
||||
|
||||
export interface IEvaluationSettingsProps {
|
||||
rollingOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowPopoverProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
rollingOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowDetailsProps {
|
||||
|
||||
@@ -13,10 +13,7 @@ import APIError from 'types/api/error';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
validateCreateAlertState,
|
||||
} from './utils';
|
||||
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
import {
|
||||
@@ -84,7 +81,7 @@ function Footer(): JSX.Element {
|
||||
);
|
||||
|
||||
const handleTestNotification = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -117,11 +114,12 @@ function Footer(): JSX.Element {
|
||||
notificationSettings,
|
||||
currentQuery,
|
||||
testAlertRule,
|
||||
handleApiError,
|
||||
]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleSaveAlert = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -173,6 +171,7 @@ function Footer(): JSX.Element {
|
||||
createAlertRule,
|
||||
safeNavigate,
|
||||
handleApiError,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const isSavingAlertRule = isCreatingAlertRule || isUpdatingAlertRule;
|
||||
|
||||
@@ -48,7 +48,6 @@ const mockAlertContextState = createMockAlertContextState({
|
||||
selectedQuery: 'A',
|
||||
operator: AlertThresholdOperator.ABOVE_BELOW,
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
evaluationWindow: '5m0s',
|
||||
algorithm: 'standard',
|
||||
seasonality: 'hourly',
|
||||
thresholds: [
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertThresholdOperator,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -18,6 +20,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { BuildCreateAlertRulePayloadArgs } from '../types';
|
||||
import {
|
||||
buildCreateAlertRulePayload,
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
getAlertOnAbsentProps,
|
||||
getEnforceMinimumDatapointsProps,
|
||||
@@ -96,6 +99,33 @@ describe('Footer utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'Please enter a deviation greater than 0'],
|
||||
[0, 'Please enter a deviation greater than 0'],
|
||||
[-2, 'Please enter a deviation greater than 0'],
|
||||
['2.5', null],
|
||||
])('when the anomaly deviation is %p', (thresholdValue, expected) => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
basicAlertState: { ...args.basicAlertState, name: 'test name' },
|
||||
thresholdState: {
|
||||
...args.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...args.thresholdState.thresholds[0],
|
||||
thresholdValue: thresholdValue as number,
|
||||
},
|
||||
],
|
||||
},
|
||||
notificationSettings: {
|
||||
...args.notificationSettings,
|
||||
routingPolicies: true,
|
||||
},
|
||||
};
|
||||
expect(validateCreateAlertState(currentArgs)).toBe(expected);
|
||||
});
|
||||
|
||||
it('when threshold channels are not provided but routing policies are enabled', () => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
@@ -526,8 +556,6 @@ describe('Footer utils', () => {
|
||||
},
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: 'http://localhost/',
|
||||
version: 'v5',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -573,4 +601,93 @@ describe('Footer utils', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildCreateAlertRulePayload', () => {
|
||||
const mockCreateAlertContextState = createMockAlertContextState();
|
||||
const thresholdState = mockCreateAlertContextState.thresholdState;
|
||||
const anomalyArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
basicAlertState: mockCreateAlertContextState.alertState,
|
||||
thresholdState: {
|
||||
...thresholdState,
|
||||
seasonality: Seasonality.DAILY,
|
||||
thresholds: [
|
||||
{ ...thresholdState.thresholds[0], thresholdValue: 3, unit: 'ms' },
|
||||
],
|
||||
},
|
||||
advancedOptions: mockCreateAlertContextState.advancedOptions,
|
||||
evaluationWindow: {
|
||||
...mockCreateAlertContextState.evaluationWindow,
|
||||
windowType: 'rolling',
|
||||
timeframe: '1h0m0s',
|
||||
},
|
||||
notificationSettings: mockCreateAlertContextState.notificationSettings,
|
||||
query: initialQueriesMap.metrics,
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
};
|
||||
|
||||
it('builds a v2alpha1 anomaly rule with a rolling window', () => {
|
||||
const props = buildCreateAlertRulePayload(anomalyArgs);
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
expect(props.schemaVersion).toBe('v2alpha1');
|
||||
expect(props.condition.algorithm).toBe('standard');
|
||||
expect(props.condition.seasonality).toBe('daily');
|
||||
expect(props.condition.thresholds?.spec[0]).toMatchObject({
|
||||
target: 3,
|
||||
targetUnit: '',
|
||||
});
|
||||
expect(props.evaluation).toStrictEqual({
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[AlertThresholdOperator.IS_BELOW, -3],
|
||||
['2', -3],
|
||||
[AlertThresholdOperator.IS_ABOVE, 3],
|
||||
[AlertThresholdOperator.ABOVE_BELOW, 3],
|
||||
])('signs the z-score target for operator %s', (operator, target) => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
thresholdState: { ...anomalyArgs.thresholdState, operator },
|
||||
});
|
||||
expect(props.condition.thresholds?.spec[0].target).toBe(target);
|
||||
expect(props.condition.thresholds?.spec[0].op).toBe(operator);
|
||||
});
|
||||
|
||||
it('signs every anomaly severity the same way', () => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
thresholdState: {
|
||||
...anomalyArgs.thresholdState,
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [
|
||||
{ ...anomalyArgs.thresholdState.thresholds[0], thresholdValue: 3 },
|
||||
{
|
||||
...anomalyArgs.thresholdState.thresholds[0],
|
||||
id: 'warning',
|
||||
label: 'warning',
|
||||
thresholdValue: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
props.condition.thresholds?.spec.map(({ name, target }) => [name, target]),
|
||||
).toStrictEqual([
|
||||
['critical', -3],
|
||||
['warning', -2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds a threshold rule for other alert types', () => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
});
|
||||
expect(props.ruleType).toBe('threshold_rule');
|
||||
expect(props.condition.seasonality).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 {
|
||||
BasicThreshold,
|
||||
PostableAlertRuleV2,
|
||||
@@ -9,8 +10,10 @@ import {
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelope';
|
||||
|
||||
import { normalizeOperator } from '../context/conditionNormalizers';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertThresholdOperator,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from '../context/types';
|
||||
@@ -31,7 +34,8 @@ export function getFormattedTimeValue(timeValue: number, unit: string): string {
|
||||
export function validateCreateAlertState(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): string | null {
|
||||
const { basicAlertState, thresholdState, notificationSettings } = args;
|
||||
const { alertType, basicAlertState, thresholdState, notificationSettings } =
|
||||
args;
|
||||
|
||||
// Validate alert name
|
||||
if (!basicAlertState.name) {
|
||||
@@ -44,6 +48,12 @@ export function validateCreateAlertState(
|
||||
if (!threshold.label) {
|
||||
return 'Please enter a label for each threshold';
|
||||
}
|
||||
if (
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
!(Number(threshold.thresholdValue) > 0)
|
||||
) {
|
||||
return 'Please enter a deviation greater than 0';
|
||||
}
|
||||
// this runs during render, so a throw here takes down the whole page
|
||||
if (!notificationSettings.routingPolicies && !threshold.channels?.length) {
|
||||
return 'Please select at least one channel for each threshold or enable routing policies';
|
||||
@@ -256,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';
|
||||
}
|
||||
@@ -282,63 +292,48 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
version: 'v5',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Build Create Anomaly Alert Rule Payload
|
||||
// TODO: Update this function before enabling anomaly alert rule creation
|
||||
export function buildCreateAnomalyAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
const {
|
||||
alertType,
|
||||
basicAlertState,
|
||||
query,
|
||||
notificationSettings,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
} = args;
|
||||
const { thresholdState } = args;
|
||||
const payload = buildCreateThresholdAlertRulePayload(args);
|
||||
|
||||
const compositeQuery = compositeQueryToQueryEnvelope({
|
||||
builderQueries: {
|
||||
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
|
||||
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
|
||||
},
|
||||
promQueries: mapQueryDataToApi(query.promql, 'name').data,
|
||||
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
|
||||
queryType: query.queryType,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
unit: basicAlertState.yAxisUnit,
|
||||
});
|
||||
|
||||
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
|
||||
const enforceMinimumDatapointsProps =
|
||||
getEnforceMinimumDatapointsProps(advancedOptions);
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
const notificationSettingsProps =
|
||||
getNotificationSettingsProps(notificationSettings);
|
||||
// Targets are literal z-scores: "below N deviations" fires under -N.
|
||||
// A target unit would rescale the z-score, so it is always cleared.
|
||||
const sign =
|
||||
normalizeOperator(thresholdState.operator) === AlertThresholdOperator.IS_BELOW
|
||||
? -1
|
||||
: 1;
|
||||
const thresholds: BasicThreshold[] = (
|
||||
payload.condition.thresholds?.spec || []
|
||||
).map((threshold) => ({
|
||||
...threshold,
|
||||
target: sign * Math.abs(threshold.target),
|
||||
targetUnit: '',
|
||||
}));
|
||||
|
||||
return {
|
||||
alert: basicAlertState.name,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
alertType,
|
||||
...payload,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
condition: {
|
||||
compositeQuery,
|
||||
...alertOnAbsentProps,
|
||||
...enforceMinimumDatapointsProps,
|
||||
...payload.condition,
|
||||
thresholds: { kind: 'basic', spec: thresholds },
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
},
|
||||
labels: basicAlertState.labels,
|
||||
annotations: {
|
||||
description: notificationSettings.description,
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
return args.alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? buildCreateAnomalyAlertRulePayload(args)
|
||||
: buildCreateThresholdAlertRulePayload(args);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
@@ -74,20 +74,23 @@ function QuerySection(): JSX.Element {
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
const handleFetchingStateChange = useCallback((isFetching: boolean): void => {
|
||||
setIsLoadingQueries(isFetching);
|
||||
if (isFetching) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
}, []);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
void queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const runQueryHandler = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
void queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
|
||||
]);
|
||||
// Reset the source param when the query is changed
|
||||
// Then manually run the query
|
||||
if (source === YAxisSource.DASHBOARDS && didQueryChange) {
|
||||
@@ -129,6 +132,11 @@ function QuerySection(): JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
const isAnomalyDetection = alertType === AlertTypes.ANOMALY_BASED_ALERT;
|
||||
const activeTab = isAnomalyDetection
|
||||
? AlertTypes.METRICS_BASED_ALERT
|
||||
: alertType;
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<Stepper stepNumber={1} label="Define the query" />
|
||||
@@ -136,7 +144,7 @@ function QuerySection(): JSX.Element {
|
||||
alertDef={alertDef}
|
||||
source={source}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
onFetchingStateChange={handleFetchingStateChange}
|
||||
/>
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
@@ -144,9 +152,12 @@ function QuerySection(): JSX.Element {
|
||||
<Button
|
||||
key={tab.value}
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': alertType === tab.value,
|
||||
'active-tab': activeTab === tab.value,
|
||||
})}
|
||||
onClick={(): void => {
|
||||
if (isAnomalyDetection && tab.value === activeTab) {
|
||||
return;
|
||||
}
|
||||
setAlertType(tab.value as AlertTypes);
|
||||
}}
|
||||
>
|
||||
@@ -165,6 +176,7 @@ function QuerySection(): JSX.Element {
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
isAnomalyDetection={isAnomalyDetection}
|
||||
key={currentQuery.queryType}
|
||||
ruleId=""
|
||||
hideTitle
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
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 { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -61,10 +61,12 @@ jest.mock(
|
||||
alertType,
|
||||
panelType,
|
||||
setQueryCategory,
|
||||
isAnomalyDetection,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<div data-testid="query-section-component">
|
||||
<div data-testid="query-category">{queryCategory}</div>
|
||||
<div data-testid="is-anomaly-detection">{String(isAnomalyDetection)}</div>
|
||||
<div data-testid="alert-type">{alertType}</div>
|
||||
<div data-testid="panel-type">{panelType}</div>
|
||||
<button
|
||||
@@ -127,11 +129,11 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
},
|
||||
});
|
||||
const renderQuerySection = (): ReturnType<typeof render> =>
|
||||
const renderQuerySection = (search = ''): ReturnType<typeof render> =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<QuerySection />
|
||||
</CreateAlertProvider>
|
||||
@@ -201,6 +203,27 @@ describe('QuerySection', () => {
|
||||
expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS);
|
||||
});
|
||||
|
||||
it('keeps Metrics active and restricts query types for anomaly alerts', () => {
|
||||
renderQuerySection(`?${QueryParams.ruleType}=anomaly_rule`);
|
||||
|
||||
expect(screen.getByText(METRICS_TEXT).closest('button')).toHaveClass(
|
||||
ACTIVE_TAB_CLASS,
|
||||
);
|
||||
expect(screen.getByTestId('is-anomaly-detection')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('stays on the anomaly alert when its active Metrics tab is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderQuerySection(`?${QueryParams.ruleType}=anomaly_rule`);
|
||||
|
||||
await user.click(screen.getByText(METRICS_TEXT));
|
||||
|
||||
expect(
|
||||
mockUseQueryBuilder.redirectWithQueryBuilderData,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('is-anomaly-detection')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('handles alert type change when clicking on different tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderQuerySection();
|
||||
@@ -214,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,
|
||||
@@ -229,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,
|
||||
@@ -353,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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import { defaultPostableAlertRuleV2 } from '../constants';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import {
|
||||
getAdvancedOptionsStateFromAlertDef,
|
||||
getColorForThreshold,
|
||||
getAlertTypeFromAlertDef,
|
||||
getCreateAlertLocalStateFromAlertDef,
|
||||
getEvaluationWindowStateFromAlertDef,
|
||||
getNotificationSettingsStateFromAlertDef,
|
||||
@@ -271,7 +273,7 @@ describe('CreateAlertV2 utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef', () => {
|
||||
it('getThresholdStateFromAlertDef maps thresholds, query, operator and match type', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
annotations: {
|
||||
@@ -316,6 +318,81 @@ describe('CreateAlertV2 utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
|
||||
const anomalyDef: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
ruleType: 'anomaly_rule',
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target: -3,
|
||||
targetUnit: '',
|
||||
channels: ['email'],
|
||||
matchType: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
op: AlertThresholdOperator.IS_BELOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m' },
|
||||
},
|
||||
};
|
||||
|
||||
it('shows deviations unsigned and restores the anomaly settings', () => {
|
||||
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [{ thresholdValue: 3 }],
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the anomaly window into the shared evaluation window', () => {
|
||||
expect(getEvaluationWindowStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
windowType: 'rolling',
|
||||
timeframe: '1h0m0s',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps signed targets for threshold rules', () => {
|
||||
const state = getThresholdStateFromAlertDef({
|
||||
...anomalyDef,
|
||||
ruleType: 'threshold_rule',
|
||||
});
|
||||
expect(state.thresholds[0].thresholdValue).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertTypeFromAlertDef', () => {
|
||||
it('maps anomaly rules to the anomaly alert type', () => {
|
||||
expect(
|
||||
getAlertTypeFromAlertDef({
|
||||
...defaultPostableAlertRuleV2,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
ruleType: 'anomaly_rule',
|
||||
}),
|
||||
).toBe(AlertTypes.ANOMALY_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('keeps the stored alert type for other rules', () => {
|
||||
expect(
|
||||
getAlertTypeFromAlertDef({
|
||||
...defaultPostableAlertRuleV2,
|
||||
alertType: AlertTypes.LOGS_BASED_ALERT,
|
||||
ruleType: 'threshold_rule',
|
||||
}),
|
||||
).toBe(AlertTypes.LOGS_BASED_ALERT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef null channels', () => {
|
||||
it('falls back to an empty array so downstream consumers never see null', () => {
|
||||
const def: PostableAlertRuleV2 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter, useHistory } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { INITIAL_CREATE_ALERT_STATE } from '../constants';
|
||||
@@ -41,20 +40,20 @@ function Probe(): JSX.Element {
|
||||
}
|
||||
|
||||
function renderWithSearch(search: string): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: `/alerts/new${search}` },
|
||||
);
|
||||
}
|
||||
|
||||
const clickTestId = async (testId: string): Promise<void> => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByTestId(testId));
|
||||
};
|
||||
|
||||
function serializeThreshold(thresholdValue: number): string {
|
||||
return encodeURIComponent(
|
||||
JSON.stringify([
|
||||
@@ -97,6 +96,13 @@ describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('starts new anomaly alerts at 3 deviations', () => {
|
||||
renderWithSearch(`?ruleType=anomaly_rule`);
|
||||
|
||||
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('applies the meter evaluation window when the preset is declared', () => {
|
||||
// Ingestion-style prefill: explicit in_total + meter preset.
|
||||
renderWithSearch(
|
||||
@@ -114,17 +120,17 @@ describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
|
||||
// Reproduces the #12137 regression: on the alert overview page the query builder
|
||||
// rewrites location.search after the alert loads, which used to re-run the prefill
|
||||
// effect and RESET the loaded threshold back to 0.
|
||||
function SearchMutator(): JSX.Element {
|
||||
function SearchMutator({
|
||||
to = '/alerts/overview?compositeQuery=normalized&ruleId=r1',
|
||||
}: {
|
||||
to?: string;
|
||||
}): JSX.Element {
|
||||
const routerHistory = useHistory();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mutate-search"
|
||||
onClick={(): void =>
|
||||
routerHistory.replace(
|
||||
'/alerts/overview?compositeQuery=normalized&ruleId=r1',
|
||||
)
|
||||
}
|
||||
onClick={(): void => routerHistory.replace(to)}
|
||||
>
|
||||
change search
|
||||
</button>
|
||||
@@ -132,7 +138,7 @@ function SearchMutator(): JSX.Element {
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — edit mode ignores URL prefill', () => {
|
||||
it('keeps loaded thresholds when the query builder rewrites location.search', () => {
|
||||
it('keeps loaded thresholds when the query builder rewrites location.search', async () => {
|
||||
const initialAlertState = {
|
||||
...INITIAL_CREATE_ALERT_STATE,
|
||||
threshold: {
|
||||
@@ -147,30 +153,158 @@ describe('CreateAlertProvider — edit mode ignores URL prefill', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/alerts/overview?ruleId=r1']}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<Probe />
|
||||
<SearchMutator />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<Probe />
|
||||
<SearchMutator />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/overview?ruleId=r1' },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
|
||||
// Simulate the query builder normalizing the query into the URL post-load.
|
||||
fireEvent.click(screen.getByTestId('mutate-search'));
|
||||
await clickTestId('mutate-search');
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
});
|
||||
});
|
||||
|
||||
function ThresholdSetter(): JSX.Element {
|
||||
const { thresholdState, setThresholdState } = useCreateAlertState();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="set-threshold"
|
||||
onClick={(): void =>
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [{ ...thresholdState.thresholds[0], thresholdValue: 245 }],
|
||||
})
|
||||
}
|
||||
>
|
||||
set threshold
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — create mode', () => {
|
||||
it('keeps edited thresholds when running the query rewrites location.search', async () => {
|
||||
render(
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
<ThresholdSetter />
|
||||
<SearchMutator to="/alerts/new?alertType=METRIC_BASED_ALERT&compositeQuery=ran" />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/new?alertType=METRIC_BASED_ALERT' },
|
||||
);
|
||||
|
||||
await clickTestId('set-threshold');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
|
||||
await clickTestId('mutate-search');
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
});
|
||||
});
|
||||
|
||||
function AnomalySwitcher(): JSX.Element {
|
||||
const { setAlertType } = useCreateAlertState();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="switch-to-anomaly"
|
||||
onClick={(): void => setAlertType(AlertTypes.ANOMALY_BASED_ALERT)}
|
||||
>
|
||||
switch to anomaly
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — edit mode type switch', () => {
|
||||
it('starts the anomaly condition at 3 deviations instead of the loaded threshold', async () => {
|
||||
const initialAlertState = {
|
||||
...INITIAL_CREATE_ALERT_STATE,
|
||||
threshold: {
|
||||
...INITIAL_CREATE_ALERT_STATE.threshold,
|
||||
thresholds: [
|
||||
{
|
||||
...INITIAL_CREATE_ALERT_STATE.threshold.thresholds[0],
|
||||
thresholdValue: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<Probe />
|
||||
<AnomalySwitcher />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/overview?ruleId=r1' },
|
||||
);
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('500');
|
||||
|
||||
await clickTestId('switch-to-anomaly');
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('keeps the meter preset from bringing back a cumulative window for anomaly', async () => {
|
||||
render(
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
<AnomalySwitcher />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/alerts/new?matchType=in_total&evaluationWindowPreset=meter',
|
||||
},
|
||||
);
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
|
||||
|
||||
await clickTestId('switch-to-anomaly');
|
||||
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('moves a cumulative window back to rolling when switching to anomaly', async () => {
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={{
|
||||
...INITIAL_CREATE_ALERT_STATE,
|
||||
evaluationWindow: {
|
||||
...INITIAL_CREATE_ALERT_STATE.evaluationWindow,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Probe />
|
||||
<AnomalySwitcher />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/overview?ruleId=r1' },
|
||||
);
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
|
||||
|
||||
await clickTestId('switch-to-anomaly');
|
||||
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
TimeDuration,
|
||||
} from '../types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
@@ -266,7 +265,6 @@ describe('CreateAlertV2 Context Utils', () => {
|
||||
selectedQuery: 'B',
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
matchType: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
evaluationWindow: TimeDuration.TEN_MINUTES,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.DAILY,
|
||||
thresholds: [],
|
||||
@@ -280,7 +278,6 @@ describe('CreateAlertV2 Context Utils', () => {
|
||||
selectedQuery: 'C',
|
||||
operator: AlertThresholdOperator.IS_EQUAL_TO,
|
||||
matchType: AlertThresholdMatchType.ON_AVERAGE,
|
||||
evaluationWindow: TimeDuration.ONE_HOUR,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.WEEKLY,
|
||||
thresholds: [],
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
} from './types';
|
||||
|
||||
export const INITIAL_ALERT_STATE: AlertState = {
|
||||
@@ -35,6 +34,11 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||
color: Color.BG_SAKURA_500,
|
||||
};
|
||||
|
||||
export const INITIAL_ANOMALY_THRESHOLD: Threshold = {
|
||||
...INITIAL_CRITICAL_THRESHOLD,
|
||||
thresholdValue: 3,
|
||||
};
|
||||
|
||||
export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'warning',
|
||||
@@ -69,7 +73,6 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
||||
selectedQuery: 'A',
|
||||
operator: AlertThresholdOperator.IS_ABOVE,
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
evaluationWindow: TimeDuration.FIVE_MINUTES,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.HOURLY,
|
||||
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
||||
@@ -136,8 +139,8 @@ export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
];
|
||||
|
||||
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
|
||||
{ value: AlertThresholdOperator.ABOVE_BELOW, label: 'ABOVE/BELOW' },
|
||||
];
|
||||
|
||||
@@ -152,16 +155,8 @@ export const THRESHOLD_MATCH_TYPE_OPTIONS = [
|
||||
export const ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS = [
|
||||
{ value: AlertThresholdMatchType.AT_LEAST_ONCE, label: 'AT LEAST ONCE' },
|
||||
{ value: AlertThresholdMatchType.ALL_THE_TIME, label: 'ALL THE TIME' },
|
||||
];
|
||||
|
||||
export const ANOMALY_TIME_DURATION_OPTIONS = [
|
||||
{ value: TimeDuration.FIVE_MINUTES, label: '5 minutes' },
|
||||
{ value: TimeDuration.TEN_MINUTES, label: '10 minutes' },
|
||||
{ value: TimeDuration.FIFTEEN_MINUTES, label: '15 minutes' },
|
||||
{ value: TimeDuration.ONE_HOUR, label: '1 hour' },
|
||||
{ value: TimeDuration.THREE_HOURS, label: '3 hours' },
|
||||
{ value: TimeDuration.FOUR_HOURS, label: '4 hours' },
|
||||
{ value: TimeDuration.TWENTY_FOUR_HOURS, label: '24 hours' },
|
||||
{ value: AlertThresholdMatchType.ON_AVERAGE, label: 'ON AVERAGE' },
|
||||
{ value: AlertThresholdMatchType.LAST, label: 'LAST' },
|
||||
];
|
||||
|
||||
export const ANOMALY_ALGORITHM_OPTIONS = [
|
||||
|
||||
@@ -11,20 +11,25 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
useCreateRule,
|
||||
useTestRule,
|
||||
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';
|
||||
|
||||
import { INITIAL_CREATE_ALERT_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ANOMALY_THRESHOLD,
|
||||
INITIAL_CREATE_ALERT_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
EvaluationWindowPreset,
|
||||
pickAlertPrefillParams,
|
||||
resolveUrlAlertPrefill,
|
||||
} from './resolveUrlAlertPrefill';
|
||||
import {
|
||||
@@ -129,9 +134,10 @@ export function CreateAlertProvider(
|
||||
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
|
||||
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
|
||||
// Prefill declared in the URL; applied verbatim, agnostic of the producer.
|
||||
const prefillParams = pickAlertPrefillParams(location.search);
|
||||
const urlPrefill = useMemo(
|
||||
() => resolveUrlAlertPrefill(new URLSearchParams(location.search)),
|
||||
[location.search],
|
||||
() => resolveUrlAlertPrefill(new URLSearchParams(prefillParams)),
|
||||
[prefillParams],
|
||||
);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
@@ -141,27 +147,74 @@ export function CreateAlertProvider(
|
||||
return getInitialAlertTypeFromURL(queryParams, currentQuery);
|
||||
});
|
||||
|
||||
const resetThresholdState = useCallback((type: AlertTypes): void => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'RESET',
|
||||
},
|
||||
});
|
||||
|
||||
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [INITIAL_ANOMALY_THRESHOLD],
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAlertTypeChange = useCallback(
|
||||
(value: AlertTypes): void => {
|
||||
const queryToRedirect = buildInitialAlertDef(value);
|
||||
const currentQueryToRedirect = mapQueryDataFromApi(
|
||||
queryToRedirect.condition.compositeQuery,
|
||||
);
|
||||
// Threshold and anomaly alerts share the metric query, so switching keeps it.
|
||||
const metricAlertTypes = [
|
||||
AlertTypes.METRICS_BASED_ALERT,
|
||||
AlertTypes.ANOMALY_BASED_ALERT,
|
||||
];
|
||||
const keepsQuery =
|
||||
value !== alertType &&
|
||||
metricAlertTypes.includes(value) &&
|
||||
metricAlertTypes.includes(alertType);
|
||||
const queryToRedirect = keepsQuery
|
||||
? currentQuery
|
||||
: mapQueryDataFromApi(buildInitialAlertDef(value).condition.compositeQuery);
|
||||
redirectWithQueryBuilderData(
|
||||
currentQueryToRedirect,
|
||||
queryToRedirect,
|
||||
{
|
||||
[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,
|
||||
);
|
||||
setAlertType(value);
|
||||
if (isEditMode && keepsQuery) {
|
||||
resetThresholdState(value);
|
||||
}
|
||||
if (
|
||||
value === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
createAlertState.evaluationWindow.windowType !== 'rolling'
|
||||
) {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
});
|
||||
}
|
||||
},
|
||||
[redirectWithQueryBuilderData],
|
||||
[
|
||||
alertType,
|
||||
createAlertState.evaluationWindow.windowType,
|
||||
currentQuery,
|
||||
isEditMode,
|
||||
redirectWithQueryBuilderData,
|
||||
resetThresholdState,
|
||||
setEvaluationWindow,
|
||||
],
|
||||
);
|
||||
|
||||
const ruleNameAppliedRef = useRef(false);
|
||||
@@ -176,12 +229,7 @@ export function CreateAlertProvider(
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'RESET',
|
||||
},
|
||||
});
|
||||
resetThresholdState(alertType);
|
||||
|
||||
if (urlPrefill.thresholds) {
|
||||
setCreateAlertState({
|
||||
@@ -213,7 +261,10 @@ export function CreateAlertProvider(
|
||||
});
|
||||
}
|
||||
|
||||
if (urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER) {
|
||||
if (
|
||||
urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER &&
|
||||
alertType !== AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.EVALUATION_WINDOW,
|
||||
action: {
|
||||
@@ -243,7 +294,14 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL, isEditMode]);
|
||||
}, [
|
||||
alertType,
|
||||
urlPrefill,
|
||||
ruleNameFromURL,
|
||||
yAxisUnitFromURL,
|
||||
isEditMode,
|
||||
resetThresholdState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
Threshold,
|
||||
} from './types';
|
||||
|
||||
const PREFILL_PARAMS = [
|
||||
QueryParams.thresholds,
|
||||
QueryParams.matchType,
|
||||
QueryParams.compareOp,
|
||||
QueryParams.evaluationWindowPreset,
|
||||
];
|
||||
|
||||
export enum EvaluationWindowPreset {
|
||||
METER = 'meter',
|
||||
}
|
||||
@@ -39,6 +46,18 @@ function parseEvaluationWindowPreset(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function pickAlertPrefillParams(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
const prefillParams = new URLSearchParams();
|
||||
PREFILL_PARAMS.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value !== null) {
|
||||
prefillParams.set(key, value);
|
||||
}
|
||||
});
|
||||
return prefillParams.toString();
|
||||
}
|
||||
|
||||
/** URL → declarative prefill plan; the consumer applies it without knowing the producer. */
|
||||
export function resolveUrlAlertPrefill(
|
||||
params: URLSearchParams,
|
||||
|
||||
@@ -119,23 +119,11 @@ export interface AlertThresholdState {
|
||||
// canonical enum value.
|
||||
operator: AlertThresholdOperator | string;
|
||||
matchType: AlertThresholdMatchType | string;
|
||||
evaluationWindow: string;
|
||||
algorithm: string;
|
||||
seasonality: string;
|
||||
thresholds: Threshold[];
|
||||
}
|
||||
|
||||
export enum TimeDuration {
|
||||
ONE_MINUTE = '1m0s',
|
||||
FIVE_MINUTES = '5m0s',
|
||||
TEN_MINUTES = '10m0s',
|
||||
FIFTEEN_MINUTES = '15m0s',
|
||||
ONE_HOUR = '1h0m0s',
|
||||
THREE_HOURS = '3h0m0s',
|
||||
FOUR_HOURS = '4h0m0s',
|
||||
TWENTY_FOUR_HOURS = '24h0m0s',
|
||||
}
|
||||
|
||||
export enum Algorithm {
|
||||
STANDARD = 'standard',
|
||||
}
|
||||
@@ -150,7 +138,6 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_SELECTED_QUERY'; payload: string }
|
||||
| { type: 'SET_OPERATOR'; payload: AlertThresholdOperator }
|
||||
| { type: 'SET_MATCH_TYPE'; payload: AlertThresholdMatchType }
|
||||
| { type: 'SET_EVALUATION_WINDOW'; payload: string }
|
||||
| { type: 'SET_ALGORITHM'; payload: string }
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
|
||||
@@ -124,6 +124,10 @@ export const alertThresholdReducer = (
|
||||
return { ...state, operator: action.payload };
|
||||
case 'SET_MATCH_TYPE':
|
||||
return { ...state, matchType: action.payload };
|
||||
case 'SET_ALGORITHM':
|
||||
return { ...state, algorithm: action.payload };
|
||||
case 'SET_SEASONALITY':
|
||||
return { ...state, seasonality: action.payload };
|
||||
case 'SET_THRESHOLDS':
|
||||
return { ...state, thresholds: action.payload };
|
||||
case 'RESET':
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
export const useIsAnomalyDetectionAvailable = (): boolean => {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
return isCloudUser || isEnterpriseSelfHostedUser;
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { getRandomColor } from 'container/ExplorerOptions/utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -248,13 +250,17 @@ export {
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
const isAnomalyRule = alertDef.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
|
||||
return {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds:
|
||||
alertDef.condition.thresholds?.spec.map((threshold) => ({
|
||||
id: v4(),
|
||||
label: threshold.name,
|
||||
thresholdValue: threshold.target,
|
||||
// anomaly targets are signed z-scores; the operator carries the direction
|
||||
thresholdValue: isAnomalyRule
|
||||
? Math.abs(threshold.target)
|
||||
: threshold.target,
|
||||
recoveryThresholdValue: null,
|
||||
unit: threshold.targetUnit,
|
||||
color: getColorForThreshold(threshold.name),
|
||||
@@ -268,9 +274,23 @@ export function getThresholdStateFromAlertDef(
|
||||
matchType:
|
||||
alertDef.condition.thresholds?.spec[0].matchType ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
algorithm:
|
||||
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
|
||||
seasonality:
|
||||
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
|
||||
};
|
||||
}
|
||||
|
||||
// Anomaly rules are stored as metric alerts; only the rule type tells them apart.
|
||||
export function getAlertTypeFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): AlertTypes {
|
||||
if (alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
return alertDef?.alertType as AlertTypes;
|
||||
}
|
||||
|
||||
export function getCreateAlertLocalStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): CreateAlertState {
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import Spinner from 'components/Spinner';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -32,7 +32,7 @@ import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -46,7 +46,6 @@ 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';
|
||||
@@ -120,8 +119,6 @@ function ChartPreview({
|
||||
selectedTime: globalSelectedInterval,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const handleBackNavigation = (): void => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const startTime = searchParams.get(QueryParams.startTime);
|
||||
@@ -282,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 &&
|
||||
@@ -290,9 +287,7 @@ function ChartPreview({
|
||||
!queryResponse.isLoading &&
|
||||
(!queryResponse.isError || isCancelled);
|
||||
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
?.active || false;
|
||||
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
|
||||
|
||||
const isWarning = !isEmpty(queryResponse.data?.warning);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,126 +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('react-router-dom-v5-compat', () => {
|
||||
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 {
|
||||
...jest.requireActual('react-router-dom-v5-compat'),
|
||||
useLocation: 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,80 +1,11 @@
|
||||
import { Button, Card, Col, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Card } from 'antd';
|
||||
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;
|
||||
@@ -85,19 +16,3 @@ export const FormContainer = styled(Card)`
|
||||
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 { useLocation } from 'react-router-dom-v5-compat';
|
||||
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 = useLocation();
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user