mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 19:40:41 +00:00
Compare commits
10 Commits
fix/error-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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?
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,11 +7,12 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
// The setup guide button only exists in the classic form.
|
||||
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',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,7 +21,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useNavigationType: jest.fn(() => 'PUSH'),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/alerts/new',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
hash: '',
|
||||
state: null,
|
||||
})),
|
||||
|
||||
@@ -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';
|
||||
@@ -75,6 +78,10 @@ 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();
|
||||
@@ -152,7 +159,7 @@ 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';
|
||||
@@ -163,8 +170,9 @@ describe('CreateAlertRule', () => {
|
||||
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();
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
@@ -272,15 +280,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 />);
|
||||
|
||||
@@ -41,7 +41,7 @@ function CreateRules(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isTypeSelectionMode) {
|
||||
logEvent('Alert: New alert data source selection page visited', {});
|
||||
void logEvent('Alert: New alert data source selection page visited', {});
|
||||
}
|
||||
}, [isTypeSelectionMode]);
|
||||
|
||||
@@ -100,10 +100,7 @@ function CreateRules(): JSX.Element {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
if (
|
||||
showClassicCreateAlertsPageFlag ||
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
if (showClassicCreateAlertsPageFlag) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -573,4 +603,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -289,56 +299,43 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
}
|
||||
|
||||
// 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,
|
||||
...payload,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
alertType,
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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 = [
|
||||
|
||||
@@ -22,9 +22,14 @@ 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,14 +147,41 @@ 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]:
|
||||
@@ -160,8 +193,28 @@ export function CreateAlertProvider(
|
||||
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;
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { Spin } from 'antd';
|
||||
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { getRandomColor } from 'container/ExplorerOptions/utils';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -248,13 +250,18 @@ export {
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
const isAnomalyRule =
|
||||
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
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 +275,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 === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
return alertDef?.alertType as AlertTypes;
|
||||
}
|
||||
|
||||
export function getCreateAlertLocalStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): CreateAlertState {
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 +31,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';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/Que
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -39,7 +38,7 @@ import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQu
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
import Tabs2 from 'periscope/components/Tabs2';
|
||||
import { useAlertRuleOptional } from 'providers/Alert';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -101,7 +100,6 @@ function FormAlertRules({
|
||||
}: FormAlertRuleProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
const { featureFlags } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
@@ -812,9 +810,7 @@ function FormAlertRules({
|
||||
},
|
||||
];
|
||||
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
?.active || false;
|
||||
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
|
||||
|
||||
const source = useMemo(
|
||||
() => urlQuery.get(QueryParams.source) as YAxisSource,
|
||||
|
||||
@@ -243,7 +243,7 @@ function Hosts(): JSX.Element {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig()}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={{
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TriangleAlert } from '@signozhq/icons';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
const HOSTNAME_DOCS_URL =
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#host-name-is-blankempty';
|
||||
|
||||
@@ -74,7 +73,6 @@ export function getHostsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -87,7 +85,6 @@ export function getHostsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -321,7 +321,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={selectedCategoryUseFieldApis}
|
||||
|
||||
@@ -228,7 +228,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -242,7 +241,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -256,7 +254,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -270,7 +267,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -284,7 +280,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -298,7 +293,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -312,7 +306,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -326,7 +319,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -355,7 +347,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -369,7 +360,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -383,7 +373,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -397,7 +386,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -411,7 +399,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -425,7 +412,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -439,7 +425,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -453,7 +438,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -467,7 +451,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -481,7 +464,6 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: false,
|
||||
},
|
||||
{
|
||||
@@ -510,7 +492,6 @@ export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -524,7 +505,6 @@ export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -553,7 +533,6 @@ export function GetNamespaceQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -567,7 +546,6 @@ export function GetNamespaceQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -596,7 +574,6 @@ export function GetClustersQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -625,7 +602,6 @@ export function GetVolumesQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_VOLUME_CAPACITY,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -639,7 +615,6 @@ export function GetVolumesQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_VOLUME_CAPACITY,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -653,7 +628,6 @@ export function GetVolumesQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_VOLUME_CAPACITY,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -682,7 +656,6 @@ export function GetDeploymentsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -696,7 +669,6 @@ export function GetDeploymentsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -710,7 +682,6 @@ export function GetDeploymentsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -739,7 +710,6 @@ export function GetStatefulsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -753,7 +723,6 @@ export function GetStatefulsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -767,7 +736,6 @@ export function GetStatefulsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -796,7 +764,6 @@ export function GetDaemonsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -809,7 +776,6 @@ export function GetDaemonsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -822,7 +788,6 @@ export function GetDaemonsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -851,7 +816,6 @@ export function GetJobsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -864,7 +828,6 @@ export function GetJobsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -877,7 +840,6 @@ export function GetJobsQuickFiltersConfig(): IQuickFiltersConfig[] {
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
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 WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueryAIWithType } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
@@ -193,8 +193,7 @@ function Explorer(): JSX.Element {
|
||||
onToggleFilters={(): void => setOpen(!isOpen)}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
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 { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
@@ -130,8 +130,7 @@ function Explorer(): JSX.Element {
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.meter }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
quickFilterSource: QuickFiltersSource.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
const UNCONSTRAINED_ROUTE = '/unconstrained';
|
||||
|
||||
const renderOn = (
|
||||
compositeQuery: string,
|
||||
route: string = UNCONSTRAINED_ROUTE,
|
||||
): Query | null => {
|
||||
const search = new URLSearchParams();
|
||||
search.set('compositeQuery', compositeQuery);
|
||||
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`${route}?${search}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
return result.current;
|
||||
};
|
||||
|
||||
const logsWith = (item: object): string =>
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [{ ...initialQueriesMap.logs.builder.queryData[0], ...item }],
|
||||
},
|
||||
});
|
||||
|
||||
const withHavingConverted = (queryData: object[]): object[] =>
|
||||
queryData.map((item) => ({ ...item, having: { expression: '' } }));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('treats a non-object or broken compositeQuery as no query', () => {
|
||||
['[]', '"x"', '5', 'null', '{not json'].forEach((raw) =>
|
||||
expect(renderOn(raw)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs the builder shape and converts the legacy having', () => {
|
||||
const defaultQueryData = withHavingConverted(
|
||||
initialQueryState.builder.queryData,
|
||||
);
|
||||
[{ a: 1 }, [null, initialQueriesMap.logs.builder.queryData[0]]].forEach(
|
||||
(queryData) =>
|
||||
expect(
|
||||
renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData },
|
||||
}),
|
||||
)?.builder.queryData,
|
||||
).toStrictEqual(defaultQueryData),
|
||||
);
|
||||
|
||||
expect(
|
||||
renderOn(logsWith({ having: [null] }))?.builder.queryData[0].having,
|
||||
).toStrictEqual({ expression: '' });
|
||||
expect(renderOn(JSON.stringify(initialQueriesMap.logs))).toStrictEqual({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: withHavingConverted(initialQueriesMap.logs.builder.queryData),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('gives an id-less query with a filter the same id on every parse', () => {
|
||||
const { id: _, ...withoutId } = JSON.parse(
|
||||
logsWith({ filter: { expression: "service.name='x'" } }),
|
||||
);
|
||||
const first = renderOn(JSON.stringify(withoutId));
|
||||
const again = renderOn(JSON.stringify(withoutId));
|
||||
const other = renderOn(
|
||||
JSON.stringify({ ...withoutId, queryType: EQueryType.PROM }),
|
||||
);
|
||||
|
||||
expect(first?.id).toHaveLength(36);
|
||||
expect(again?.id).toBe(first?.id);
|
||||
expect(other?.id).not.toBe(first?.id);
|
||||
});
|
||||
|
||||
it('applies the page constraints on every constrained route', () => {
|
||||
const clickhouseWithOperator = JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.metrics.builder.queryData[0],
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
});
|
||||
|
||||
[
|
||||
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
|
||||
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
|
||||
].forEach(([route, dataSource, source]) => {
|
||||
const query = renderOn(clickhouseWithOperator, route);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(dataSource);
|
||||
expect(query?.builder.queryData[0].source ?? '').toBe(source);
|
||||
expect(query?.builder.queryTraceOperator).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the query type on a route without constraints', () => {
|
||||
const query = renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.metrics,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(DataSource.METRICS);
|
||||
});
|
||||
});
|
||||
54
frontend/src/hooks/queryBuilder/explorerQueryRules.ts
Normal file
54
frontend/src/hooks/queryBuilder/explorerQueryRules.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type ExplorerQueryRules = {
|
||||
dataSource: DataSource;
|
||||
/** `meter` on the meter explorer, which shares the metrics signal; '' elsewhere. */
|
||||
source: BuilderQuerySource;
|
||||
builderQueryType: BuilderQueryType;
|
||||
allowsTraceOperator: boolean;
|
||||
};
|
||||
|
||||
const EXPLORER_QUERY_RULES: Partial<Record<string, ExplorerQueryRules>> = {
|
||||
[ROUTES.LOGS_EXPLORER]: {
|
||||
dataSource: DataSource.LOGS,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.TRACES_EXPLORER]: {
|
||||
dataSource: DataSource.TRACES,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: true,
|
||||
},
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
|
||||
dataSource: DataSource.TRACES,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_ai_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
|
||||
dataSource: DataSource.METRICS,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.METER_EXPLORER]: {
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function getExplorerQueryRules(
|
||||
pathname: string,
|
||||
): ExplorerQueryRules | undefined {
|
||||
return EXPLORER_QUERY_RULES[pathname];
|
||||
}
|
||||
297
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.test.ts
Normal file
297
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { normalizeCompositeQuery } from './normalizeCompositeQuery';
|
||||
|
||||
const NOT_AN_EXPLORER = ROUTES.HOME;
|
||||
|
||||
const logsQuery = initialQueryBuilderFormValuesMap.logs;
|
||||
const tracesQuery = initialQueryBuilderFormValuesMap.traces;
|
||||
const metricsQuery = initialQueryBuilderFormValuesMap.metrics;
|
||||
|
||||
function withBuilderQueries(
|
||||
query: Query,
|
||||
queryData: unknown[],
|
||||
builder: object = {},
|
||||
): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: queryData as IBuilderQuery[],
|
||||
...builder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firstBuilderQuery(
|
||||
query: Query,
|
||||
queryData: unknown[],
|
||||
pathname: string,
|
||||
): IBuilderQuery {
|
||||
return normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname)
|
||||
.query.builder.queryData[0];
|
||||
}
|
||||
|
||||
function without(builderQuery: object, key: keyof IBuilderQuery): object {
|
||||
const { [key]: _, ...rest } = builderQuery as IBuilderQuery;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const traceOperator = { queryTraceOperator: [tracesQuery] };
|
||||
|
||||
describe('normalizeCompositeQuery', () => {
|
||||
it('leaves complete queries untouched', () => {
|
||||
const clickhouse = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
extra: 'kept',
|
||||
};
|
||||
const aiQuery = withBuilderQueries(initialQueriesMap.traces, [
|
||||
{ ...tracesQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER },
|
||||
]);
|
||||
|
||||
[NOT_AN_EXPLORER, ROUTES.LOGS_EXPLORER].forEach((pathname) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.logs, pathname),
|
||||
).toStrictEqual({ query: initialQueriesMap.logs, changed: false }),
|
||||
);
|
||||
expect(normalizeCompositeQuery(clickhouse, NOT_AN_EXPLORER)).toStrictEqual({
|
||||
query: clickhouse,
|
||||
changed: false,
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(initialQueriesMap.logs, [without(logsQuery, 'source')]),
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
).changed,
|
||||
).toBe(false);
|
||||
expect(
|
||||
normalizeCompositeQuery(aiQuery, ROUTES.AI_OBSERVABILITY_EXPLORER).changed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('adds missing blocks and a stable id without changing its inputs', () => {
|
||||
const { builder: _, ...builderless } = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
};
|
||||
const urlQueryBefore = JSON.stringify(builderless);
|
||||
const defaultsBefore = JSON.stringify(initialQueryState.builder);
|
||||
const repaired = normalizeCompositeQuery(builderless, NOT_AN_EXPLORER);
|
||||
repaired.query.builder.queryData[0].queryName = 'changed';
|
||||
const {
|
||||
promql: _p,
|
||||
clickhouse_sql: _c,
|
||||
...withoutText
|
||||
} = initialQueriesMap.logs;
|
||||
const withoutLists = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
builder: { queryData: initialQueriesMap.logs.builder.queryData },
|
||||
},
|
||||
NOT_AN_EXPLORER,
|
||||
);
|
||||
const { id: _id, ...withoutId } = initialQueriesMap.logs;
|
||||
const { id } = normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query;
|
||||
|
||||
expect(repaired.query.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(repaired.changed).toBe(true);
|
||||
expect(JSON.stringify(builderless)).toBe(urlQueryBefore);
|
||||
expect(JSON.stringify(initialQueryState.builder)).toBe(defaultsBefore);
|
||||
expect(
|
||||
normalizeCompositeQuery(withoutText, NOT_AN_EXPLORER).query,
|
||||
).toMatchObject({
|
||||
promql: initialQueryState.promql,
|
||||
clickhouse_sql: initialQueryState.clickhouse_sql,
|
||||
});
|
||||
expect(withoutLists.query.builder.queryFormulas).toStrictEqual([]);
|
||||
expect(withoutLists.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(withoutLists.changed).toBe(true);
|
||||
[[], [5, logsQuery]].forEach((queryData) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(initialQueriesMap.logs, queryData),
|
||||
NOT_AN_EXPLORER,
|
||||
).query.builder.queryData,
|
||||
).toStrictEqual(initialQueryState.builder.queryData),
|
||||
);
|
||||
expect(id).toHaveLength(36);
|
||||
expect(normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query.id).toBe(id);
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...withoutId, queryType: EQueryType.PROM },
|
||||
NOT_AN_EXPLORER,
|
||||
).query.id,
|
||||
).not.toBe(id);
|
||||
});
|
||||
|
||||
it('switches explorer queries to the query builder and clears their text', () => {
|
||||
const withText = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
|
||||
},
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
);
|
||||
const { builder: _, ...builderless } = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
};
|
||||
|
||||
expect(withText.query).toMatchObject({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
clickhouse_sql: initialQueryState.clickhouse_sql,
|
||||
promql: initialQueryState.promql,
|
||||
});
|
||||
expect(withText.changed).toBe(true);
|
||||
expect(
|
||||
normalizeCompositeQuery(builderless, ROUTES.LOGS_EXPLORER).query,
|
||||
).toMatchObject({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ ...logsQuery, source: '' }] },
|
||||
});
|
||||
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...initialQueriesMap.traces, queryType },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
).query.queryType,
|
||||
).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets builder queries that do not match the explorer, keeping their names', () => {
|
||||
const logs = initialQueriesMap.logs;
|
||||
const traces = initialQueriesMap.traces;
|
||||
const ai = ROUTES.AI_OBSERVABILITY_EXPLORER;
|
||||
const severityFilter = { filter: { expression: "severity='error'" } };
|
||||
const llmFilter = { filter: { expression: "name='llm'" } };
|
||||
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...tracesQuery, queryName: 'B', expression: 'B', ...llmFilter }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
),
|
||||
).toStrictEqual({
|
||||
...logsQuery,
|
||||
source: '',
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...without(logsQuery, 'dataSource'), ...severityFilter }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
),
|
||||
).toMatchObject({ dataSource: DataSource.LOGS, ...severityFilter });
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...logsQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
).builderQueryType,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
traces,
|
||||
[
|
||||
{
|
||||
...tracesQuery,
|
||||
queryName: 'D',
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
...llmFilter,
|
||||
},
|
||||
],
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
),
|
||||
).toStrictEqual({ ...tracesQuery, source: '', queryName: 'D' });
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
initialQueriesMap.metrics,
|
||||
[{ ...without(metricsQuery, 'source'), filter: { expression: "x='y'" } }],
|
||||
ROUTES.METER_EXPLORER,
|
||||
),
|
||||
).toMatchObject({
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
filter: { expression: "x='y'" },
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.metrics, ROUTES.METER_EXPLORER)
|
||||
.query.builder.queryData[0].source,
|
||||
).toBe(TelemetrytypesSourceDTO.meter);
|
||||
expect(
|
||||
firstBuilderQuery(traces, [{ ...tracesQuery, ...llmFilter }], ai),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
...llmFilter,
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
traces,
|
||||
[
|
||||
{
|
||||
...tracesQuery,
|
||||
queryName: 'B',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
...llmFilter,
|
||||
},
|
||||
],
|
||||
ai,
|
||||
),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
queryName: 'B',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(traces, [{ ...logsQuery, queryName: 'C' }], ai),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'C',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a trace operator everywhere but the traces explorer', () => {
|
||||
const withOperator = (
|
||||
query: Query,
|
||||
pathname: string,
|
||||
): ReturnType<typeof normalizeCompositeQuery> =>
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(query, query.builder.queryData, traceOperator),
|
||||
pathname,
|
||||
);
|
||||
const onLogs = withOperator(initialQueriesMap.logs, ROUTES.LOGS_EXPLORER);
|
||||
const onTraces = withOperator(
|
||||
initialQueriesMap.traces,
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
|
||||
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(onLogs.changed).toBe(true);
|
||||
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
|
||||
expect(onTraces.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
196
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.ts
Normal file
196
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, isPlainObject } from 'lodash-es';
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
|
||||
import {
|
||||
ExplorerQueryRules,
|
||||
getExplorerQueryRules,
|
||||
} from './explorerQueryRules';
|
||||
|
||||
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
|
||||
|
||||
export type NormalizedQuery = {
|
||||
query: Query;
|
||||
/** The url query had to be repaired, so the url should be rewritten. */
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repairs a `compositeQuery` read from the url so the query builder can load it.
|
||||
* Links built by agents can leave out whole blocks, or carry a query the
|
||||
* current explorer cannot run.
|
||||
*/
|
||||
export function normalizeCompositeQuery(
|
||||
urlQuery: object,
|
||||
pathname: string,
|
||||
): NormalizedQuery {
|
||||
const query = cloneDeep(urlQuery) as Query;
|
||||
|
||||
addMissingBlocks(query, urlQuery);
|
||||
|
||||
const rules = getExplorerQueryRules(pathname);
|
||||
if (rules) {
|
||||
fitQueryToExplorer(query, rules);
|
||||
}
|
||||
|
||||
return { query, changed: !isEqual(query, urlQuery) };
|
||||
}
|
||||
|
||||
function addMissingBlocks(query: Query, urlQuery: object): void {
|
||||
if (!query.id) {
|
||||
query.id = getStableId(urlQuery);
|
||||
}
|
||||
|
||||
if (!Object.values(EQueryType).includes(query.queryType)) {
|
||||
query.queryType = EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
if (!isListOfObjects(query.promql)) {
|
||||
query.promql = cloneDeep(initialQueryState.promql);
|
||||
}
|
||||
|
||||
if (!isListOfObjects(query.clickhouse_sql)) {
|
||||
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
|
||||
}
|
||||
|
||||
if (!isPlainObject(query.builder)) {
|
||||
query.builder = cloneDeep(initialQueryState.builder);
|
||||
}
|
||||
|
||||
const { queryData } = query.builder;
|
||||
if (!isListOfObjects(queryData) || queryData.length === 0) {
|
||||
query.builder.queryData = cloneDeep(initialQueryState.builder.queryData);
|
||||
}
|
||||
|
||||
if (!query.builder.queryFormulas) {
|
||||
query.builder.queryFormulas = [];
|
||||
}
|
||||
|
||||
if (!query.builder.queryTraceOperator) {
|
||||
query.builder.queryTraceOperator = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider matches the staged query to the url query by id, so a link
|
||||
* without an id must get the same one on every read.
|
||||
*/
|
||||
function getStableId(urlQuery: object): string {
|
||||
return uuidv5(JSON.stringify(urlQuery), COMPOSITE_QUERY_ID_NAMESPACE);
|
||||
}
|
||||
|
||||
function isListOfObjects(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every((item) => isPlainObject(item));
|
||||
}
|
||||
|
||||
function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
|
||||
// Explorers only have the query builder: no PromQL or ClickHouse editor.
|
||||
query.queryType = EQueryType.QUERY_BUILDER;
|
||||
|
||||
if (query.promql.some((promql) => promql.query !== '')) {
|
||||
query.promql = cloneDeep(initialQueryState.promql);
|
||||
}
|
||||
|
||||
if (query.clickhouse_sql.some((clickhouse) => clickhouse.query !== '')) {
|
||||
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
|
||||
}
|
||||
|
||||
if (!rules.allowsTraceOperator) {
|
||||
query.builder.queryTraceOperator = [];
|
||||
}
|
||||
|
||||
query.builder.queryData = query.builder.queryData.map((builderQuery) =>
|
||||
fitBuilderQueryToExplorer(builderQuery, rules),
|
||||
);
|
||||
}
|
||||
|
||||
function fitBuilderQueryToExplorer(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): IBuilderQuery {
|
||||
fillMissingSignal(builderQuery, rules);
|
||||
|
||||
if (belongsToExplorer(builderQuery, rules)) {
|
||||
return builderQuery;
|
||||
}
|
||||
|
||||
const replacement = getDefaultBuilderQuery(rules);
|
||||
// Keep the name, so formulas that reference this query still resolve.
|
||||
replacement.queryName = builderQuery.queryName ?? replacement.queryName;
|
||||
replacement.expression = builderQuery.expression ?? replacement.expression;
|
||||
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/** A link may leave out the fields that pick the signal: read them as the explorer's. */
|
||||
function fillMissingSignal(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): void {
|
||||
if (builderQuery.dataSource === undefined) {
|
||||
builderQuery.dataSource = rules.dataSource;
|
||||
}
|
||||
|
||||
// A missing `source` already means '', so only meter needs it written.
|
||||
if (
|
||||
builderQuery.source === undefined &&
|
||||
rules.source === TelemetrytypesSourceDTO.meter
|
||||
) {
|
||||
builderQuery.source = rules.source;
|
||||
}
|
||||
|
||||
// A missing `builderQueryType` already means builder_query, so only AI needs it written.
|
||||
if (
|
||||
builderQuery.builderQueryType === undefined &&
|
||||
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
|
||||
) {
|
||||
builderQuery.builderQueryType = rules.builderQueryType;
|
||||
}
|
||||
}
|
||||
|
||||
function belongsToExplorer(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): boolean {
|
||||
if (builderQuery.dataSource !== rules.dataSource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((builderQuery.source ?? '') !== rules.source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const builderQueryType =
|
||||
builderQuery.builderQueryType ??
|
||||
Querybuildertypesv5QueryTypeDTO.builder_query;
|
||||
|
||||
if (builderQueryType !== rules.builderQueryType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
|
||||
const builderQuery = cloneDeep(
|
||||
initialQueryBuilderFormValuesMap[rules.dataSource],
|
||||
);
|
||||
builderQuery.source = rules.source;
|
||||
|
||||
if (
|
||||
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
|
||||
) {
|
||||
builderQuery.builderQueryType = rules.builderQueryType;
|
||||
}
|
||||
|
||||
return builderQuery;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
@@ -6,15 +7,23 @@ import {
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isPlainObject } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
import {
|
||||
NormalizedQuery,
|
||||
normalizeCompositeQuery,
|
||||
} from './normalizeCompositeQuery';
|
||||
|
||||
export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
let changed = false;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
@@ -23,18 +32,18 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
|
||||
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
|
||||
parsedCompositeQuery = JSON.parse(
|
||||
const rawQuery: unknown = JSON.parse(
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
if (!isPlainObject(rawQuery)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
|
||||
rawQuery as object,
|
||||
pathname,
|
||||
));
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
@@ -51,7 +60,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
const convertedHaving = convertHavingToExpression(
|
||||
query.having.filter(isPlainObject),
|
||||
);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
@@ -75,6 +86,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
return parsedCompositeQuery ? { query: parsedCompositeQuery, changed } : null;
|
||||
}, [urlQuery, pathname]);
|
||||
};
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null =>
|
||||
useNormalizedCompositeQueryParam()?.query ?? null;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export function replaceIncorrectObjectFields<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
TargetValue extends object,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
ResultValue extends object,
|
||||
>(
|
||||
targetObject: TargetValue,
|
||||
defaultObject: ResultValue,
|
||||
): { isValid: boolean; validData: ResultValue } {
|
||||
const targetObjectKeys = Object.keys(targetObject);
|
||||
const defaultObjectKeys = Object.keys(defaultObject);
|
||||
|
||||
let isValid = true;
|
||||
|
||||
const result: ResultValue = { ...defaultObject };
|
||||
|
||||
defaultObjectKeys.forEach((key) => {
|
||||
if (targetObjectKeys.includes(key)) {
|
||||
result[key as keyof ResultValue] = targetObject[
|
||||
key as keyof TargetValue
|
||||
] as unknown as ResultValue[keyof ResultValue];
|
||||
} else {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isValid, validData: result };
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { usePlotContext } from '../../context/PlotContext';
|
||||
import { UPlotChartProps } from '../types';
|
||||
import { prepareAlignedData } from './utils';
|
||||
import { prepareAlignedData, setInstanceHandle } from './utils';
|
||||
|
||||
/**
|
||||
* Check if dimensions have changed
|
||||
@@ -48,6 +48,9 @@ export default function UPlotChart({
|
||||
const { setPlotContextInitialState } = usePlotContext();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
// The container the live plot was created on. React detaches `containerRef`
|
||||
// before effect cleanups run, so destroy clears this one, not the ref.
|
||||
const plotHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevPropsRef = useRef<UPlotChartProps | null>(null);
|
||||
|
||||
/**
|
||||
@@ -58,6 +61,8 @@ export default function UPlotChart({
|
||||
onDestroy?.(plotInstanceRef.current);
|
||||
plotInstanceRef.current.destroy();
|
||||
plotInstanceRef.current = null;
|
||||
setInstanceHandle(plotHostRef.current, null);
|
||||
plotHostRef.current = null;
|
||||
setPlotContextInitialState({ uPlotInstance: null });
|
||||
plotRef?.(null);
|
||||
}
|
||||
@@ -101,6 +106,8 @@ export default function UPlotChart({
|
||||
});
|
||||
|
||||
plotInstanceRef.current = plot;
|
||||
plotHostRef.current = containerRef.current;
|
||||
setInstanceHandle(plotHostRef.current, plot);
|
||||
}, [
|
||||
config,
|
||||
data,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { AlignedData } from 'uplot';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
export function prepareAlignedData({
|
||||
data,
|
||||
@@ -14,3 +14,22 @@ export function prepareAlignedData({
|
||||
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
|
||||
: (data as AlignedData);
|
||||
}
|
||||
|
||||
/** A chart container carrying its live uPlot instance, for tests and debugging. */
|
||||
export interface UPlotHostElement extends HTMLDivElement {
|
||||
__uplot?: uPlot;
|
||||
}
|
||||
|
||||
export function setInstanceHandle(
|
||||
node: HTMLDivElement | null,
|
||||
plot: uPlot | null,
|
||||
): void {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (plot) {
|
||||
(node as UPlotHostElement).__uplot = plot;
|
||||
} else {
|
||||
delete (node as UPlotHostElement).__uplot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +352,57 @@ describe('UPlotChart', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance handle', () => {
|
||||
const hostOf = (call: number): HTMLElement & { __uplot?: unknown } =>
|
||||
mockUPlotConstructor.mock.calls[call][2];
|
||||
|
||||
it('hangs the live instance on the container it was created on', () => {
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(hostOf(0).__uplot).toBe(instances[0]);
|
||||
});
|
||||
|
||||
it('clears the handle from that container on unmount', () => {
|
||||
const { unmount } = render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
const host = hostOf(0);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(host.__uplot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the handle from that container when the data becomes empty', () => {
|
||||
const config = createMockConfig();
|
||||
const { rerender } = render(
|
||||
<UPlotChart config={config} data={validData} width={600} height={400} />,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
const host = hostOf(0);
|
||||
|
||||
rerender(
|
||||
<UPlotChart config={config} data={emptyData} width={600} height={400} />,
|
||||
);
|
||||
|
||||
expect(host.__uplot).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanGaps data transformation', () => {
|
||||
it('inserts null break points before passing data to uPlot when a gap exceeds the numeric threshold', () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
@@ -10,11 +10,13 @@ import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
|
||||
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
|
||||
import {
|
||||
getAlertTypeFromAlertDef,
|
||||
getCreateAlertLocalStateFromAlertDef,
|
||||
} from 'container/CreateAlertV2/utils';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
@@ -74,7 +76,7 @@ function AlertDetails(): JSX.Element {
|
||||
|
||||
const handleTabChange = (route: string): void => {
|
||||
if (route === ROUTES.ALERT_HISTORY) {
|
||||
logEvent('Alert History tab: Visited', { ruleId });
|
||||
void logEvent('Alert History tab: Visited', { ruleId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,7 +91,7 @@ function AlertDetails(): JSX.Element {
|
||||
<CreateAlertProvider
|
||||
ruleId={ruleId || ''}
|
||||
isEditMode
|
||||
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
|
||||
initialAlertType={getAlertTypeFromAlertDef(alertRuleDetails)}
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
ALERT_SCHEMAS,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
RULE_KINDS,
|
||||
RULE_STATE_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
type AlertSchema,
|
||||
type RuleKind,
|
||||
type RuleStateChoice,
|
||||
type SeverityChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
@@ -46,6 +48,13 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
ruleKind: choiceControl<RuleKind>('Rule kind', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`anomaly` fires on how far each point strays from the seasonal forecast, in deviations, rather than on a fixed value.',
|
||||
options: RULE_KINDS,
|
||||
value: 'threshold',
|
||||
}),
|
||||
ruleState: choiceControl<RuleStateChoice>('State', {
|
||||
group: RULE,
|
||||
description: 'The badge next to the rule name in the header.',
|
||||
@@ -86,6 +95,7 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
schema: values.alertSchema,
|
||||
kind: values.ruleKind,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -44,6 +44,35 @@ export const ClassicSchema: Story = {
|
||||
args: { alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An anomaly rule in the current schema: the condition reads in deviations from
|
||||
* the forecast, and the preview draws the band the rule compares against.
|
||||
*/
|
||||
export const AnomalyRule: Story = {
|
||||
args: { ruleKind: 'anomaly' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The anomaly rule with one series picked in the preview: that series drawn
|
||||
* against its forecast, inside the band of deviations the rule allows.
|
||||
*/
|
||||
export const AnomalyRuleForecastBand: Story = {
|
||||
args: { ruleKind: 'anomaly' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [firstSeries] = await within(canvasElement).findAllByTestId(
|
||||
'anomaly-series-option',
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await userEvent.click(firstSeries);
|
||||
},
|
||||
};
|
||||
|
||||
/** An anomaly rule written before the current schema, still in the classic form. */
|
||||
export const ClassicAnomalyRule: Story = {
|
||||
args: { ruleKind: 'anomaly', alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/** A rule someone turned off: the toggle in the header is what turns it back on. */
|
||||
export const Disabled: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import type {
|
||||
MetricRangePayloadV5,
|
||||
QueryRangeRequestV5,
|
||||
TimeSeries,
|
||||
TimeSeriesValue,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
@@ -52,23 +54,97 @@ export const alertPreviewSeries = async (
|
||||
return queryRangeV5EmptyResponse(queryName);
|
||||
}
|
||||
|
||||
return queryRangeV5TimeSeriesResponse([
|
||||
{
|
||||
queryName,
|
||||
series: Array.from({ length: count }, (_unused, index) => ({
|
||||
labels: [
|
||||
{ key: { name: 'host.name' }, value: HOSTS[index % HOSTS.length] },
|
||||
const series = Array.from({ length: count }, (_unused, index) => ({
|
||||
labels: [{ key: { name: 'host.name' }, value: HOSTS[index % HOSTS.length] }],
|
||||
values: timeSeriesPoints({
|
||||
start: body.start,
|
||||
end: body.end,
|
||||
base: 55 + index * 6,
|
||||
amplitude: 12,
|
||||
seed: index * 3,
|
||||
}),
|
||||
}));
|
||||
const response = queryRangeV5TimeSeriesResponse([{ queryName, series }]);
|
||||
|
||||
const zScore = requestedZScore(body);
|
||||
if (zScore === undefined) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// The anomaly engine answers with the forecast, its band and the z-score per
|
||||
// point next to the observed series, which is what the anomaly chart draws.
|
||||
const forecast = series.map((observed, index) => ({
|
||||
labels: observed.labels,
|
||||
values: timeSeriesPoints({
|
||||
start: body.start,
|
||||
end: body.end,
|
||||
base: 55 + index * 6,
|
||||
amplitude: 9,
|
||||
seed: index * 3 + 1,
|
||||
}),
|
||||
}));
|
||||
const shifted = (by: number): TimeSeries[] =>
|
||||
forecast.map(({ labels, values }) => ({
|
||||
labels,
|
||||
values: values.map((point) => ({ ...point, value: point.value + by })),
|
||||
}));
|
||||
const anomalyScores = series.map(({ labels, values }, index) => ({
|
||||
labels,
|
||||
values: values.map((point, pointIndex): TimeSeriesValue => ({
|
||||
...point,
|
||||
value:
|
||||
(point.value - forecast[index].values[pointIndex].value) / ANOMALY_SPREAD,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
data: {
|
||||
...response.data,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: {},
|
||||
series,
|
||||
predictedSeries: forecast,
|
||||
upperBoundSeries: shifted(zScore * ANOMALY_SPREAD),
|
||||
lowerBoundSeries: shifted(-zScore * ANOMALY_SPREAD),
|
||||
anomalyScores,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
values: timeSeriesPoints({
|
||||
start: body.start,
|
||||
end: body.end,
|
||||
base: 55 + index * 6,
|
||||
amplitude: 12,
|
||||
seed: index * 3,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
};
|
||||
|
||||
const ANOMALY_SPREAD = 4;
|
||||
|
||||
// The engine sizes the band from the `anomaly` function's `z_score_threshold`.
|
||||
const requestedZScore = (body: QueryRangeRequestV5): number | undefined => {
|
||||
const anomaly = body.compositeQuery?.queries
|
||||
?.flatMap(
|
||||
(query) =>
|
||||
(
|
||||
query.spec as {
|
||||
functions?: {
|
||||
name: string;
|
||||
args?: { name?: string; value?: unknown }[];
|
||||
}[];
|
||||
}
|
||||
).functions ?? [],
|
||||
)
|
||||
.find(({ name }) => name === 'anomaly');
|
||||
if (!anomaly) {
|
||||
return undefined;
|
||||
}
|
||||
const zScore = anomaly.args?.find(({ name }) => name === 'z_score_threshold');
|
||||
return Number(zScore?.value ?? 3);
|
||||
};
|
||||
|
||||
const METRIC_SEEDS: MetricsexplorertypesListMetricDTO[] = [
|
||||
|
||||
@@ -7,22 +7,26 @@ import {
|
||||
MetrictypesSpaceAggregationDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTimeAggregationDTO,
|
||||
Querybuildertypesv5FunctionNameDTO,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5ReduceToDTO,
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesCompareOperatorDTO,
|
||||
RuletypesEvaluationRollingDTOKind,
|
||||
RuletypesMatchTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleTypeDTO,
|
||||
RuletypesSeasonalityDTO,
|
||||
RuletypesThresholdBasicDTOKind,
|
||||
type AlertmanagertypesDeprecatedGettableAlertDTO,
|
||||
type GetAlerts200,
|
||||
type GetRuleByID200,
|
||||
type ListRules200,
|
||||
type RenderErrorResponseDTO,
|
||||
type Querybuildertypesv5FunctionDTO,
|
||||
type RuletypesAlertCompositeQueryDTO,
|
||||
type RuletypesRuleConditionDTO,
|
||||
type RuletypesRuleDTO,
|
||||
@@ -70,6 +74,10 @@ export const ALERT_SCHEMAS = ['v2', 'classic'] as const;
|
||||
|
||||
export type AlertSchema = (typeof ALERT_SCHEMAS)[number];
|
||||
|
||||
export const RULE_KINDS = ['threshold', 'anomaly'] as const;
|
||||
|
||||
export type RuleKind = (typeof RULE_KINDS)[number];
|
||||
|
||||
export const CHANNEL_TYPES = [
|
||||
'slack',
|
||||
'webhook',
|
||||
@@ -90,7 +98,10 @@ export type ChannelType = (typeof CHANNEL_TYPES)[number];
|
||||
* One query envelope is enough for the alert form to mount its query builder
|
||||
* over the rule, and it is the query the preview chart is drawn for.
|
||||
*/
|
||||
const compositeQuery = (seed: RuleSeed): RuletypesAlertCompositeQueryDTO => ({
|
||||
const compositeQuery = (
|
||||
seed: RuleSeed,
|
||||
functions?: Querybuildertypesv5FunctionDTO[],
|
||||
): RuletypesAlertCompositeQueryDTO => ({
|
||||
queryType: RuletypesQueryTypeDTO.builder,
|
||||
panelType: RuletypesPanelTypeDTO.graph,
|
||||
unit: seed.unit,
|
||||
@@ -114,11 +125,60 @@ const compositeQuery = (seed: RuleSeed): RuletypesAlertCompositeQueryDTO => ({
|
||||
groupBy: [],
|
||||
order: [],
|
||||
stepInterval: 60,
|
||||
functions,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* An anomaly rule fires on the z-score of each point against the seasonal
|
||||
* forecast, so its targets are deviations: the classic form stores `below 3`, the
|
||||
* current schema the signed `-3`. The `anomaly` function on the query is what
|
||||
* makes the preview draw the forecast band.
|
||||
*/
|
||||
const anomalyCondition = (seed: RuleSeed): RuletypesRuleConditionDTO => {
|
||||
return {
|
||||
...condition(seed),
|
||||
compositeQuery: compositeQuery(seed, [
|
||||
{
|
||||
name: Querybuildertypesv5FunctionNameDTO.anomaly,
|
||||
args: [
|
||||
{ name: 'z_score_threshold', value: 2 },
|
||||
{ name: 'seasonality', value: 'daily' },
|
||||
],
|
||||
},
|
||||
]),
|
||||
op: RuletypesCompareOperatorDTO.below,
|
||||
matchType: RuletypesMatchTypeDTO.all_the_times,
|
||||
target: 3,
|
||||
targetUnit: '',
|
||||
algorithm: 'standard',
|
||||
seasonality: RuletypesSeasonalityDTO.daily,
|
||||
thresholds: {
|
||||
kind: RuletypesThresholdBasicDTOKind.basic,
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
matchType: RuletypesMatchTypeDTO.all_the_times,
|
||||
op: RuletypesCompareOperatorDTO.below,
|
||||
target: -3,
|
||||
targetUnit: '',
|
||||
channels: ['ops-slack'],
|
||||
},
|
||||
{
|
||||
name: 'warning',
|
||||
matchType: RuletypesMatchTypeDTO.all_the_times,
|
||||
op: RuletypesCompareOperatorDTO.below,
|
||||
target: -2,
|
||||
targetUnit: '',
|
||||
channels: ['ops-slack'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const condition = (seed: RuleSeed): RuletypesRuleConditionDTO => ({
|
||||
compositeQuery: compositeQuery(seed),
|
||||
op: RuletypesCompareOperatorDTO.above,
|
||||
@@ -296,6 +356,7 @@ export interface RuleShape {
|
||||
severity: SeverityChoice;
|
||||
state: RuleStateChoice;
|
||||
schema?: AlertSchema;
|
||||
kind?: RuleKind;
|
||||
}
|
||||
|
||||
const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
|
||||
@@ -305,23 +366,34 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
|
||||
shape.state === 'mixed'
|
||||
? seed.state
|
||||
: (shape.state as RuletypesAlertStateDTO);
|
||||
const isAnomaly = shape.kind === 'anomaly';
|
||||
|
||||
return {
|
||||
id: `rule-${index + 1}`,
|
||||
alert: ruleName(index),
|
||||
alertType: seed.alertType,
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
alertType: isAnomaly
|
||||
? RuletypesAlertTypeDTO.METRIC_BASED_ALERT
|
||||
: seed.alertType,
|
||||
ruleType: isAnomaly
|
||||
? RuletypesRuleTypeDTO.anomaly_rule
|
||||
: RuletypesRuleTypeDTO.threshold_rule,
|
||||
state,
|
||||
disabled: state === RuletypesAlertStateDTO.disabled,
|
||||
condition: condition(seed),
|
||||
condition: isAnomaly ? anomalyCondition(seed) : condition(seed),
|
||||
labels: { severity, ...seed.labels },
|
||||
annotations: {
|
||||
summary: `${seed.alert} crossed its threshold of ${seed.target}`,
|
||||
description:
|
||||
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
|
||||
},
|
||||
evalWindow: '5m0s',
|
||||
evalWindow: isAnomaly ? '1h0m0s' : '5m0s',
|
||||
frequency: '1m0s',
|
||||
...(isAnomaly && {
|
||||
evaluation: {
|
||||
kind: RuletypesEvaluationRollingDTOKind.rolling,
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m0s' },
|
||||
},
|
||||
}),
|
||||
createdAt: ago((index + 3) * DAY),
|
||||
updatedAt: ago((index + 1) * HOUR),
|
||||
createdBy: 'ada@signoz.io',
|
||||
|
||||
@@ -8,7 +8,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
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 RouteTab from 'components/RouteTab';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
@@ -64,8 +64,7 @@ function AllErrors(): JSX.Element {
|
||||
onToggleFilters={handleFilterVisibilityChange}
|
||||
quickFilterProps={{
|
||||
className: 'qf-exceptions',
|
||||
source: QuickFiltersSource.EXCEPTIONS,
|
||||
signal: SignalType.EXCEPTIONS,
|
||||
quickFilterSource: QuickFiltersSource.EXCEPTIONS,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
/**
|
||||
* Which alert the page is building. The page reads this off the URL, so the
|
||||
* control is a route rather than a response: with no type at all it stays on
|
||||
* the picker, anomaly detection routes to the classic form, and everything else
|
||||
* the picker, `classic-form` opts back into the classic form, and everything else
|
||||
* opens the current one.
|
||||
*/
|
||||
const ALERT_MODES = [
|
||||
|
||||
@@ -52,13 +52,35 @@ export const LogsAlert: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Anomaly detection, which is still written in the classic form: the seasonality
|
||||
* and the deviation take the place of a fixed threshold.
|
||||
* Anomaly detection: the condition is a number of deviations from the seasonal
|
||||
* forecast instead of a fixed threshold, and only builder metric queries apply.
|
||||
*/
|
||||
export const AnomalyAlert: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An anomaly alert handed to routing policies: the channel picker leaves the
|
||||
* condition, and the banner points at the policies that route it instead.
|
||||
*/
|
||||
export const AnomalyAlertRoutingPolicies: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('routing-policies-switch', undefined, {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
canvas.queryByTestId('threshold-notification-channel-select'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The classic form, which `showClassicCreateAlertsPage` opts back into. */
|
||||
export const ClassicForm: Story = {
|
||||
args: { alertMode: 'classic-form' },
|
||||
|
||||
@@ -131,6 +131,18 @@ describe('ThresholdsSection', () => {
|
||||
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the row still in edit mode when another is added', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
await user.type(screen.getByTestId('threshold-value-0'), '10');
|
||||
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
|
||||
expect(screen.getByTestId('threshold-value-1')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags a threshold unit in a different category than the y-axis unit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user