Compare commits

..

4 Commits

Author SHA1 Message Date
Gaurav Tewari
ec0b9c843d Merge remote-tracking branch 'origin/main' into feat/saved-view-poc 2026-10-28 00:29:20 +05:30
Gaurav Tewari
ac4d31ef95 feat: make save view work 2026-10-27 23:32:55 +05:30
Gaurav Tewari
ab70655b27 feat: add AI preference framework for list and trace view columns 2026-10-27 23:32:55 +05:30
Gaurav Tewari
94339f3132 feat: add column options to the AI list view 2026-10-27 23:32:55 +05:30
119 changed files with 1870 additions and 4297 deletions

View File

@@ -97,7 +97,7 @@ interface ToggleAction {
isOnlyOrAllClicked?: boolean;
previousState?: CheckedState;
sectionType?: SectionType;
quickFilterSource?: QuickFiltersSource;
source?: QuickFiltersSource;
attributeValues?: string[];
}
@@ -117,8 +117,7 @@ function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
currentQuery: buildQuery(initialItems, initialExpression),
activeQueryIndex: 0,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
quickFilterSource:
c.action.quickFilterSource ?? QuickFiltersSource.LOGS_EXPLORER,
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
value: c.action.value,
checked: c.action.checked,
@@ -163,7 +162,7 @@ const TOGGLE_CASES: ToggleCase[] = [
action: {
value: 'a',
checked: false,
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
source: 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
@@ -314,7 +313,7 @@ const TOGGLE_CASES: ToggleCase[] = [
action: {
value: 'b',
checked: false,
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
source: QuickFiltersSource.INFRA_MONITORING,
},
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],

View File

@@ -46,18 +46,14 @@ 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[] = [
QuickFiltersSource.INFRA_MONITORING,
];
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
export function getNotInOperator(
quickFilterSource: QuickFiltersSource,
): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(quickFilterSource)) {
export function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
@@ -138,7 +134,7 @@ export function applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
quickFilterSource,
source,
attributeValues,
value,
checked,
@@ -149,7 +145,7 @@ export function applyCheckboxToggle({
currentQuery: Query;
activeQueryIndex: number;
filter: IQuickFiltersConfig;
quickFilterSource: QuickFiltersSource;
source: QuickFiltersSource;
attributeValues: string[];
value: string;
checked: boolean;
@@ -244,7 +240,7 @@ export function applyCheckboxToggle({
if (sectionType === SectionType.RELATED) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getNotInOperator(quickFilterSource),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
@@ -384,7 +380,7 @@ export function applyCheckboxToggle({
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(quickFilterSource),
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
@@ -408,9 +404,7 @@ 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(quickFilterSource),
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
key: filter.attributeKey,
value,
};

View File

@@ -14,7 +14,7 @@ import { SectionType } from './v2/itemRules';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
quickFilterSource: QuickFiltersSource;
source: QuickFiltersSource;
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
@@ -38,7 +38,7 @@ interface UseCheckboxFilterActionsReturn {
*/
function useCheckboxFilterActions({
filter,
quickFilterSource,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
@@ -65,7 +65,7 @@ function useCheckboxFilterActions({
currentQuery,
activeQueryIndex,
filter,
quickFilterSource,
source,
attributeValues,
value,
checked,

View File

@@ -1,6 +1,7 @@
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 {
@@ -20,6 +21,7 @@ export const DEFAULT_FILTER: IQuickFiltersConfig = {
dataType: DataTypes.String,
type: 'tag',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
};
@@ -156,7 +158,7 @@ export function renderWithFilter(
return render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',

View File

@@ -33,7 +33,7 @@ import styles from './CheckboxFilterV2.module.scss';
interface CheckboxFilterV2Props {
filter: IQuickFiltersConfig;
quickFilterSource: QuickFiltersSource;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
useFieldApis: QuickFilterCheckboxUseFieldApis;
@@ -42,18 +42,13 @@ interface CheckboxFilterV2Props {
export default function CheckboxFilterV2(
props: CheckboxFilterV2Props,
): JSX.Element {
const {
quickFilterSource,
filter,
onFilterChange,
onQuickFilterChange,
useFieldApis,
} = props;
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const [searchText, setSearchText] = useState<string>('');
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
const activeQueryIndex = useActiveQueryIndex(source);
const {
isOpen,
@@ -80,7 +75,7 @@ export default function CheckboxFilterV2(
searchText,
existingQuery,
metricNamespace: useFieldApis.metricNamespace,
quickFilterSource,
source,
startUnixMilli: useFieldApis.startUnixMilli,
endUnixMilli: useFieldApis.endUnixMilli,
enabled: isOpen,
@@ -109,7 +104,7 @@ export default function CheckboxFilterV2(
const { onChange, onClear } = useCheckboxFilterActions({
filter,
quickFilterSource,
source,
attributeValues,
activeQueryIndex,
onFilterChange,

View File

@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -44,7 +44,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -59,8 +59,6 @@ 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 () => {
@@ -72,7 +70,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);

View File

@@ -50,7 +50,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={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}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: null,
@@ -116,7 +116,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -154,7 +154,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -185,7 +185,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -222,7 +222,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
@@ -264,7 +264,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,

View File

@@ -1,48 +0,0 @@
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);
});
});

View File

@@ -72,7 +72,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -101,7 +101,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -119,7 +119,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -173,7 +173,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -240,7 +240,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -288,7 +288,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -330,7 +330,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -394,7 +394,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
@@ -440,7 +440,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -468,7 +468,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -490,7 +490,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -516,7 +516,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -568,7 +568,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -601,7 +601,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -627,7 +627,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -652,7 +652,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -691,7 +691,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -714,7 +714,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
@@ -761,7 +761,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
@@ -837,7 +837,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
@@ -971,7 +971,7 @@ describe('CheckboxFilterV2 - interactions', () => {
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);

View File

@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -55,7 +55,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -106,7 +106,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -140,7 +140,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={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}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={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}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={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}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -321,7 +321,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
@@ -373,7 +373,7 @@ describe('CheckboxFilterV2 - item rules', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={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}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={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}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={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}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',

View File

@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -47,7 +47,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={closedFilter}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -104,7 +104,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -133,7 +133,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -152,7 +152,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -172,7 +172,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
@@ -195,7 +195,7 @@ describe('CheckboxFilterV2 - states', () => {
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);

View File

@@ -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;
quickFilterSource: QuickFiltersSource;
source?: QuickFiltersSource;
startUnixMilli?: number;
endUnixMilli?: number;
enabled: boolean;
@@ -38,13 +38,12 @@ export function useFieldValues({
searchText,
existingQuery,
metricNamespace,
quickFilterSource,
source,
startUnixMilli,
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const isAIObservability =
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY;
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
@@ -60,12 +59,14 @@ export function useFieldValues({
endUnixMilli,
}
: {
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[quickFilterSource],
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: QUICK_FILTERS_SOURCE_TO_SOURCE[quickFilterSource],
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future

View File

@@ -31,11 +31,11 @@ export type { FilterType };
function Duration({
filter,
onFilterChange,
quickFilterSource,
source,
}: {
filter: IQuickFiltersConfig;
onFilterChange?: (query: Query) => void;
quickFilterSource: QuickFiltersSource;
source: QuickFiltersSource;
}): JSX.Element {
const [selectedFilters, setSelectedFilters] =
useState<
@@ -52,7 +52,7 @@ function Duration({
const compositeQuery = useGetCompositeQueryParam();
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
const activeQueryIndex = useActiveQueryIndex(source);
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {

View File

@@ -49,18 +49,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
className,
config,
handleFilterVisibilityChange,
quickFilterSource,
source,
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;
@@ -71,7 +68,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
customFilters,
refetchCustomFilters,
isCustomFiltersLoading,
} = useFilterConfig({ savedFiltersSource, config });
} = useFilterConfig({ signal, config });
const {
deniedPermissions: deniedSettingsPermissions,
isLoading: isSettingsChecking,
@@ -115,15 +112,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Show dropdown in ListView only for TRACES_EXPLORER source
const shouldShowDropdownInListView =
isListView && quickFilterSource === QuickFiltersSource.TRACES_EXPLORER;
isListView && source === 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 =
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY &&
source === QuickFiltersSource.AI_OBSERVABILITY &&
(isListView || panelType === PANEL_TYPES.TRACE);
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
const activeQueryIndex = useActiveQueryIndex(source);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
@@ -293,7 +290,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderContent = (): JSX.Element => (
<>
{quickFilterSource === QuickFiltersSource.API_MONITORING && (
{source === QuickFiltersSource.API_MONITORING && (
<div className="api-quick-filters-header">
<Typography.Text>Show IP addresses</Typography.Text>
<Switch
@@ -316,7 +313,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<Duration
key={filter.attributeKey.key}
quickFilterSource={quickFilterSource}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
@@ -327,7 +324,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<CheckboxV2
key={filter.attributeKey.key}
quickFilterSource={quickFilterSource}
source={source}
filter={filter}
onFilterChange={onFilterChange}
onQuickFilterChange={onQuickFilterChange}
@@ -366,14 +363,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{quickFilterSource !== QuickFiltersSource.INFRA_MONITORING && (
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}
{renderRightActions()}
</section>
)}
{savedFiltersSource ? (
{signal ? (
<AuthZGuardContent
checks={[QuickFilterReadPermission]}
fallbackOnLoading={filtersSkeleton}
@@ -394,9 +391,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
className,
)}
>
{isSettingsOpen && savedFiltersSource && (
{isSettingsOpen && (
<QuickFiltersSettings
savedFiltersSource={savedFiltersSource}
signal={signal}
setIsSettingsOpen={setIsSettingsOpen}
customFilters={customFilters}
refetchCustomFilters={refetchCustomFilters}
@@ -410,6 +407,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
QuickFilters.defaultProps = {
onFilterChange: null,
signal: '',
config: [],
showFilterCollapse: true,
showQueryName: true,

View File

@@ -34,17 +34,17 @@ jest.mock('container/SavedViews/SavedViewsRestore', () => ({
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({
quickFilterSource,
source,
savedViewsHeader,
handleFilterVisibilityChange,
}: {
quickFilterSource: string;
source: string;
savedViewsHeader?: React.ReactNode;
handleFilterVisibilityChange?: () => void;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{quickFilterSource}
{source}
{handleFilterVisibilityChange && (
<Button
size="md"
@@ -62,7 +62,7 @@ jest.mock('../../QuickFilters', () => ({
}));
const quickFilterProps = {
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
source: QuickFiltersSource.TRACES_EXPLORER,
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };

View File

@@ -1,12 +1,10 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import {
QuickfiltertypesSourceDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig } from 'api/querySuggestions/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QUICK_FILTER_SOURCE_TO_SIGNAL } from 'components/QuickFilters/constants';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
@@ -15,6 +13,7 @@ import {
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -33,20 +32,18 @@ function OtherFiltersSkeleton(): JSX.Element {
}
function OtherFilters({
savedFiltersSource,
signal,
inputValue,
addedFilters,
setAddedFilters,
}: {
savedFiltersSource: QuickfiltertypesSourceDTO;
signal: SignalType | undefined;
inputValue: string;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isMeterDataSource =
savedFiltersSource === QuickfiltertypesSourceDTO.meter;
const isAIObservability =
savedFiltersSource === QuickfiltertypesSourceDTO.ai_observability;
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
@@ -56,7 +53,9 @@ function OtherFilters({
? { searchText: inputValue }
: {
searchText: inputValue,
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[savedFiltersSource],
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
};

View File

@@ -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({
savedFiltersSource,
signal,
setIsSettingsOpen,
customFilters,
refetchCustomFilters,
}: {
savedFiltersSource: QuickfiltertypesSourceDTO;
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
@@ -37,7 +37,7 @@ function QuickFiltersSettings({
setIsSettingsOpen,
customFilters,
refetchCustomFilters,
savedFiltersSource,
signal,
});
return (
@@ -73,7 +73,7 @@ function QuickFiltersSettings({
setAddedFilters={setAddedFilters}
/>
<OtherFilters
savedFiltersSource={savedFiltersSource}
signal={signal}
inputValue={debouncedInputValue}
addedFilters={addedFilters}
setAddedFilters={setAddedFilters}

View File

@@ -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,12 +43,10 @@ describe('OtherFilters - AI observability keys', () => {
);
});
function renderOtherFilters(
savedFiltersSource: QuickfiltertypesSourceDTO,
): void {
function renderOtherFilters(signal: SignalType): void {
render(
<OtherFilters
savedFiltersSource={savedFiltersSource}
signal={signal}
inputValue=""
addedFilters={[]}
setAddedFilters={jest.fn()}
@@ -57,7 +55,7 @@ describe('OtherFilters - AI observability keys', () => {
}
it('reads AI observability keys from their own endpoint', async () => {
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
renderOtherFilters(SignalType.AI_OBSERVABILITY);
await expect(
screen.findByText('gen_ai.request.model'),
@@ -66,7 +64,7 @@ describe('OtherFilters - AI observability keys', () => {
});
it('does not narrow the AI keys by fieldContext', async () => {
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
renderOtherFilters(SignalType.AI_OBSERVABILITY);
// A `trace` context would return only the computed per-trace aggregates,
// which cannot be filtered on.
@@ -75,7 +73,7 @@ describe('OtherFilters - AI observability keys', () => {
});
it('keeps other signals on the signal-wide keys endpoint', async () => {
renderOtherFilters(QuickfiltertypesSourceDTO.traces);
renderOtherFilters(SignalType.TRACES);
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiKeysParams).toBeUndefined());

View File

@@ -0,0 +1,11 @@
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,
};

View File

@@ -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;
savedFiltersSource: QuickfiltertypesSourceDTO;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
@@ -37,7 +37,7 @@ const useQuickFilterSettings = ({
customFilters,
setIsSettingsOpen,
refetchCustomFilters,
savedFiltersSource,
signal,
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
@@ -113,19 +113,21 @@ const useQuickFilterSettings = ({
);
const handleSaveChanges = useCallback((): void => {
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]);
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]);
return {
handleSettingsClose,

View File

@@ -1,16 +0,0 @@
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,
};

View File

@@ -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(quickFilterSource: QuickFiltersSource): number {
function useActiveQueryIndex(source: 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 (quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY) {
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
return isListView || panelType === PANEL_TYPES.TRACE
? 0
: lastUsedQuery || 0;
}
if (isListView) {
return quickFilterSource === QuickFiltersSource.TRACES_EXPLORER
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, panelType, quickFilterSource, lastUsedQuery]);
}, [isListView, panelType, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -1,13 +1,12 @@
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 } from '../types';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
interface UseFilterConfigProps {
savedFiltersSource?: QuickfiltertypesSourceDTO;
signal?: SignalType;
config: IQuickFiltersConfig[];
}
interface UseFilterConfigReturn {
@@ -19,7 +18,7 @@ interface UseFilterConfigReturn {
}
const useFilterConfig = ({
savedFiltersSource,
signal,
config,
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
@@ -27,8 +26,8 @@ const useFilterConfig = ({
data,
refetch,
} = useGetQuickFilters(
{ source: savedFiltersSource ?? '' },
{ query: { enabled: !!savedFiltersSource } },
{ source: signal ?? '' },
{ query: { enabled: !!signal } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
@@ -36,11 +35,11 @@ const useFilterConfig = ({
[data],
);
const isDynamicFilters = !!savedFiltersSource;
const isDynamicFilters = !!signal;
const filterConfig = useMemo(
() => getFilterConfig(customFilters, config),
[config, customFilters],
() => getFilterConfig(signal, customFilters, config),
[config, customFilters, signal],
);
return {

View File

@@ -89,15 +89,6 @@ 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')),

View File

@@ -15,9 +15,8 @@ import {
loadingFiltersHandlers,
queryBuilder,
selectedServiceQueryBuilder,
noSavedFiltersHandler,
} from './QuickFilters.stories.mocks';
import { QuickFiltersSource } from '../types';
import { QuickFiltersSource, SignalType } from '../types';
const meta = {
title: 'Components/Quick Filters',
@@ -34,7 +33,8 @@ const meta = {
args: {
config: checkboxConfig,
handleFilterVisibilityChange: (): void => undefined,
quickFilterSource: QuickFiltersSource.LOGS_EXPLORER,
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
useFieldApis: fieldApis,
},
parameters: {
@@ -94,16 +94,13 @@ export const LoadingFilters: Story = {
/** Empty: a loaded quick-filter configuration with no filters has no result rows. */
export const NoResults: Story = {
args: { config: [] },
parameters: {
msw: { handlers: [noSavedFiltersHandler, ...handlers] },
},
args: { config: [], signal: undefined },
};
/** 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,
},
@@ -114,18 +111,12 @@ 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 workspace's own saved filter list is answered empty.
* the signal that would add the workspace's own dynamic filters is left off.
*/
export const Tooltips: TooltipsStory = {
args: { tooltipsOpen: true },
args: { signal: undefined, tooltipsOpen: true },
parameters: {
msw: {
handlers: [
noSavedFiltersHandler,
fieldValuesHandler(LONG_FILTER_VALUES),
...handlers,
],
},
msw: { handlers: [fieldValuesHandler(LONG_FILTER_VALUES), ...handlers] },
},
};

View File

@@ -24,7 +24,7 @@ import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource } from '../types';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -35,7 +35,8 @@ jest.mock('container/ApiMonitoring/queryParams');
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/logs`;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -80,10 +81,11 @@ afterEach(() => {
jest.clearAllMocks();
});
function renderWithSavedList(): void {
function renderWithSignal(): void {
render(
<QuickFilters
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
source={QuickFiltersSource.LOGS_EXPLORER}
signal={SIGNAL}
handleFilterVisibilityChange={jest.fn()}
useFieldApis={QuickFiltersFieldApis}
/>,
@@ -93,7 +95,7 @@ function renderWithSavedList(): void {
function renderStaticConfig(): void {
render(
<QuickFilters
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
source={QuickFiltersSource.EXCEPTIONS}
config={QuickFiltersConfig}
handleFilterVisibilityChange={jest.fn()}
useFieldApis={QuickFiltersFieldApis}
@@ -106,7 +108,7 @@ describe('QuickFilters - AuthZ', () => {
it('shows the inline denial instead of the filters, header stays', async () => {
server.use(setupAuthzDeny(QuickFilterReadPermission));
renderWithSavedList();
renderWithSignal();
await expect(
screen.findByText(NOT_AUTHORIZED_TEXT),
@@ -131,7 +133,7 @@ describe('QuickFilters - AuthZ', () => {
it('renders the filters but disables the settings trigger', async () => {
server.use(setupAuthzDeny(QuickFilterUpdatePermission));
renderWithSavedList();
renderWithSignal();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -156,7 +158,7 @@ describe('QuickFilters - AuthZ', () => {
it('renders the filters and opens settings from the trigger', async () => {
server.use(setupAuthzAdmin());
renderWithSavedList();
renderWithSignal();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -174,7 +176,7 @@ describe('QuickFilters - AuthZ', () => {
});
});
describe('static config pages (no saved list)', () => {
describe('static config pages (no signal)', () => {
it('is not gated even when every permission is denied', async () => {
server.use(setupAuthzDenyAll());
@@ -193,7 +195,7 @@ describe('QuickFilters - AuthZ', () => {
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
renderWithSavedList();
renderWithSignal();
await waitFor(() =>
// eslint-disable-next-line testing-library/no-node-access

View File

@@ -1,11 +1,8 @@
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,
@@ -19,7 +16,7 @@ import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import '@testing-library/jest-dom';
import QuickFilters from '../QuickFilters';
import { IQuickFiltersConfig, QuickFiltersSource } from '../types';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -36,9 +33,9 @@ const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
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 SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -57,9 +54,6 @@ 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)),
),
@@ -74,24 +68,48 @@ const setupServer = (): void => {
};
function TestQuickFilters({
quickFilterSource = QuickFiltersSource.EXCEPTIONS,
signal = SignalType.LOGS,
config = QuickFiltersConfig,
}: {
quickFilterSource?: QuickFiltersSource;
signal?: SignalType;
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<QuickFilters
quickFilterSource={quickFilterSource}
source={QuickFiltersSource.EXCEPTIONS}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={QuickFiltersFieldApis}
signal={signal}
/>
);
}
TestQuickFilters.defaultProps = {
quickFilterSource: QuickFiltersSource.EXCEPTIONS,
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: '',
config: QuickFiltersConfig,
};
@@ -269,14 +287,10 @@ describe('Quick Filters', () => {
it('toggles Show IP addresses and updates API Monitoring params', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.API_MONITORING} />,
);
render(<TestQuickFiltersApiMonitoring />);
// Switch should be rendered and initially checked
await expect(
screen.findByText('Show IP addresses'),
).resolves.toBeInTheDocument();
expect(screen.getByText('Show IP addresses')).toBeInTheDocument();
const toggle = screen.getByRole('switch');
expect(toggle).toHaveAttribute('aria-checked', 'true');
@@ -294,9 +308,7 @@ describe('Quick Filters with custom filters', () => {
it('loads the custom filters correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
@@ -356,9 +368,7 @@ describe('Quick Filters with custom filters', () => {
),
);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -386,9 +396,7 @@ 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 quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -409,9 +417,7 @@ 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 quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -440,9 +446,7 @@ describe('Quick Filters with custom filters', () => {
it('restores original filter state on Discard', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -484,9 +488,7 @@ 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 quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
@@ -523,9 +525,7 @@ describe('Quick Filters with custom filters', () => {
pointerEventsCheck: 0,
});
const { getByTestId } = render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
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 for a page with a saved list', async () => {
it('fetches custom filters on every mount when signal is provided', async () => {
let getCalls = 0;
server.use(
@@ -589,18 +589,14 @@ describe('Quick Filters refetch behavior', () => {
}),
);
const { unmount } = render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
unmount();
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
@@ -608,24 +604,19 @@ describe('Quick Filters refetch behavior', () => {
expect(getCalls).toBe(2);
});
it('does not fetch custom filters for a page without a saved list', async () => {
it('does not fetch custom filters when signal is undefined', async () => {
let getCalls = 0;
server.use(
rest.get(anyQuickFiltersListURL, (_req, res, ctx) => {
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.INFRA_MONITORING} />,
);
render(<TestQuickFilters signal={undefined} />);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
expect(getCalls).toBe(0);
await waitFor(() => expect(getCalls).toBe(0));
});
it('refetches custom filters after saving settings', async () => {
@@ -644,9 +635,7 @@ describe('Quick Filters refetch behavior', () => {
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -698,9 +687,7 @@ describe('Quick Filters refetch behavior', () => {
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
);
render(<TestQuickFilters signal={SIGNAL} />);
await expect(
screen.findByText(FILTER_SERVICE_NAME),
@@ -731,47 +718,10 @@ describe('Quick Filters refetch behavior', () => {
),
);
render(
<TestQuickFilters
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
config={[]}
/>,
);
render(<TestQuickFilters signal={SIGNAL} 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);
});
});

View File

@@ -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,6 +19,15 @@ 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
*/
@@ -30,6 +39,7 @@ export interface IQuickFiltersConfig {
attributeKey: BaseAutocompleteData;
aggregateOperator?: string;
aggregateAttribute?: string;
dataSource?: DataSource;
customRendererForValue?: (value: string) => JSX.Element;
defaultOpen: boolean;
}
@@ -43,9 +53,11 @@ export interface QuickFilterChangeEventData {
export interface IQuickFiltersProps {
config: IQuickFiltersConfig[];
handleFilterVisibilityChange?: () => void;
quickFilterSource: QuickFiltersSource;
source: 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;
@@ -53,19 +65,15 @@ export interface IQuickFiltersProps {
savedViewsHeader?: ReactNode;
}
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 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 type QuickFilterCheckboxUseFieldApis = {
startUnixMilli: number;

View File

@@ -1,8 +1,9 @@
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 } from './types';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
const FILTER_TITLE_MAP: Record<string, string> = {
duration_nano: 'Duration',
@@ -49,10 +50,11 @@ const getFilterType = (att: TelemetryFieldKey): FiltersType => {
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: TelemetryFieldKey[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length) {
if (!customFilters?.length || !signal) {
return config || [];
}
@@ -61,6 +63,7 @@ export const getFilterConfig = (
({
type: getFilterType(att),
title: getFilterName(att.name),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.name,
key: att.name,

View File

@@ -14,6 +14,8 @@ export enum LOCALSTORAGE {
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
AI_OBSERVABILITY_LIST_OPTIONS = 'AI_OBSERVABILITY_LIST_OPTIONS',
AI_OBSERVABILITY_TRACE_OPTIONS = 'AI_OBSERVABILITY_TRACE_OPTIONS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',

View File

@@ -334,7 +334,6 @@ function AnomalyAlertEvaluationView({
<Checkbox
color="primary"
key={seriesKey}
testId="anomaly-series-option"
name="series"
value={selectedSeries === seriesKey}
onChange={(): void => handleSeriesChange(seriesKey)}

View File

@@ -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 } from 'components/QuickFilters/types';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import DomainList from './Domains/DomainList';
@@ -24,7 +24,8 @@ function Explorer(): JSX.Element {
showFilters
quickFilterProps={{
className: 'qf-api-monitoring',
quickFilterSource: QuickFiltersSource.API_MONITORING,
source: QuickFiltersSource.API_MONITORING,
signal: SignalType.API_MONITORING,
showQueryName: false,
useFieldApis: quickFilterFieldApis,
}}

View File

@@ -91,6 +91,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
dataType: DataTypes.String,
type: 'resource',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
},
{
@@ -101,6 +102,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
dataType: DataTypes.String,
type: 'resource',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
},
{
@@ -111,6 +113,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
dataType: DataTypes.String,
type: 'tag',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
},
];

View File

@@ -7,12 +7,11 @@ 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&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
}),
}));
@@ -21,7 +20,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
hash: '',
state: null,
})),

View File

@@ -1,8 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
LicensePlatform,
LicenseResModel,
} from 'types/api/licensesV3/getActive';
import { FeatureKeys } from 'constants/features';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -11,10 +8,6 @@ import SelectAlertType from '..';
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const CLOUD_LICENSE = {
platform: LicensePlatform.CLOUD,
} as LicenseResModel;
describe('SelectAlertType', () => {
const mockOnSelect = jest.fn();
@@ -26,7 +19,15 @@ describe('SelectAlertType', () => {
it('should render all alert type options when anomaly detection is enabled', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
activeLicense: CLOUD_LICENSE,
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<SelectAlertType onSelect={mockOnSelect} />);
@@ -38,19 +39,6 @@ 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} />);
@@ -74,7 +62,15 @@ describe('SelectAlertType', () => {
it('should call onSelect with anomaly based alert type', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
activeLicense: CLOUD_LICENSE,
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<SelectAlertType onSelect={mockOnSelect} />);

View File

@@ -5,7 +5,8 @@ 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 { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
@@ -16,8 +17,11 @@ import { OptionType } from './types';
function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
const { t } = useTranslation(['alerts']);
const { featureFlags } = useAppContext();
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
const optionList = getOptionList(t, isAnomalyDetectionEnabled);

View File

@@ -1,8 +1,5 @@
import { fireEvent, screen } from '@testing-library/react';
import {
LicensePlatform,
LicenseResModel,
} from 'types/api/licensesV3/getActive';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
@@ -78,10 +75,6 @@ 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();
@@ -159,7 +152,7 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -170,9 +163,8 @@ describe('CreateAlertRule', () => {
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {
@@ -280,7 +272,15 @@ describe('CreateAlertRule', () => {
it('should navigate with anomaly detection params for anomaly alert', () => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState({}),
activeLicense: CLOUD_LICENSE,
featureFlags: [
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
});
render(<CreateAlertRule />);

View File

@@ -41,7 +41,7 @@ function CreateRules(): JSX.Element {
useEffect(() => {
if (isTypeSelectionMode) {
void logEvent('Alert: New alert data source selection page visited', {});
logEvent('Alert: New alert data source selection page visited', {});
}
}, [isTypeSelectionMode]);
@@ -100,7 +100,10 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (showClassicCreateAlertsPageFlag) {
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}

View File

@@ -1,7 +0,0 @@
:global(.ant-tooltip).tabTooltip {
max-width: 360px;
}
.switchNote {
margin-top: var(--spacing-4);
}

View File

@@ -1,30 +1,20 @@
import { ReactNode } from 'react';
import { Button, Tooltip } from 'antd';
import classNames from 'classnames';
import { Activity, ChartLine } from '@signozhq/icons';
import { 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_SWITCH_NOTE,
ANOMALY_TAB_TOOLTIP,
THRESHOLD_TAB_SWITCH_NOTE,
THRESHOLD_TAB_TOOLTIP,
} from './constants';
import { useAnomalyFunctionSync } from './useAnomalyFunctionSync';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
import styles from './AlertCondition.module.scss';
import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
useAnomalyFunctionSync();
const {
data: channels,
@@ -33,12 +23,9 @@ 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 ||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
alertType === AlertTypes.METRICS_BASED_ALERT;
const tabs = [
{
@@ -46,15 +33,16 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {
@@ -64,23 +52,11 @@ function AlertCondition(): JSX.Element {
setAlertType(value);
};
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;
const getTabTooltip = (tab: { value: AlertTypes }): string => {
if (tab.value === AlertTypes.ANOMALY_BASED_ALERT) {
return ANOMALY_TAB_TOOLTIP;
}
const switchNote = isAnomalyTab
? ANOMALY_TAB_SWITCH_NOTE
: THRESHOLD_TAB_SWITCH_NOTE;
return (
<>
<div>{description}</div>
<div className={styles.switchNote}>{switchNote}</div>
</>
);
return THRESHOLD_TAB_TOOLTIP;
};
return (
@@ -89,11 +65,7 @@ function AlertCondition(): JSX.Element {
<div className="alert-condition">
<div className="alert-condition-tabs">
{tabs.map((tab) => (
<Tooltip
key={tab.value}
title={getTabTooltip(tab)}
overlayClassName={styles.tabTooltip}
>
<Tooltip key={tab.value} title={getTabTooltip(tab)}>
<Button
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': alertType === tab.value,

View File

@@ -1,14 +1,18 @@
import { useEffect } from 'react';
import { Button } from '@signozhq/ui/button';
import { Select, Tooltip } from 'antd';
import { Button, 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';
@@ -19,8 +23,7 @@ import {
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { normalizeMatchType, normalizeOperator } from '../utils';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps } from './types';
import { useThresholdList } from './useThresholdList';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
@@ -68,7 +71,42 @@ function AlertThreshold({
selectedCategory || '',
);
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
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 onTooltipOpenChange = (open: boolean): void => {
// Stop propagation of click events on tooltip text to dropdown
@@ -256,12 +294,11 @@ function AlertThreshold({
/>
))}
<Button
variant="dashed"
color="secondary"
size="md"
prefix={<Plus size={14} />}
onClick={(): void => addThreshold()}
testId="add-threshold-button"
type="dashed"
icon={<Plus size={16} />}
onClick={addThreshold}
className="add-threshold-btn"
data-testid="add-threshold-button"
>
Add Threshold
</Button>

View File

@@ -1,13 +0,0 @@
.querySelect {
width: 80px;
}
.algorithmSelect,
.seasonalitySelect {
width: 140px;
}
.operatorSelect,
.matchTypeSelect {
width: 180px;
}

View File

@@ -1,8 +1,8 @@
import { Button } from '@signozhq/ui/button';
import { useMemo } from 'react';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { useCreateAlertState } from '../context';
import {
@@ -10,20 +10,19 @@ import {
ANOMALY_SEASONALITY_OPTIONS,
ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS,
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
INITIAL_ANOMALY_THRESHOLD,
ANOMALY_TIME_DURATION_OPTIONS,
} 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 { useThresholdList } from './useThresholdList';
import { getQueryNames, RoutingPolicyBanner } from './utils';
import styles from './AnomalyThreshold.module.scss';
import {
getQueryNames,
NotificationChannelsNotFoundContent,
RoutingPolicyBanner,
} from './utils';
function AnomalyThreshold({
channels,
@@ -31,6 +30,7 @@ function AnomalyThreshold({
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
const { user } = useAppContext();
const {
thresholdState,
setThresholdState,
@@ -42,13 +42,23 @@ function AnomalyThreshold({
const queryNames = getQueryNames(currentQuery);
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
const deviationOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 7; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const handleAddThreshold = (): void => {
addThreshold({
thresholdValue: Math.max(
1,
INITIAL_ANOMALY_THRESHOLD.thresholdValue - thresholdState.thresholds.length,
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,
),
});
};
@@ -59,12 +69,11 @@ function AnomalyThreshold({
{/* Sentence 1 */}
<div className="alert-condition-sentence">
<Typography.Text data-testid="notification-text" className="sentence-text">
Send a notification when
Send notification when the observed value for
</Typography.Text>
<Select
value={thresholdState.selectedQuery}
data-testid="query-select"
className={styles.querySelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
@@ -73,16 +82,50 @@ 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',
@@ -95,7 +138,7 @@ function AnomalyThreshold({
data-testid="predicted-data-text"
className="sentence-text"
>
the prediction
the predicted data
</Typography.Text>
<Select
value={
@@ -103,7 +146,6 @@ function AnomalyThreshold({
thresholdState.matchType) as AlertThresholdMatchType
}
data-testid="match-type-select"
className={styles.matchTypeSelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',
@@ -112,22 +154,15 @@ 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',
@@ -145,7 +180,6 @@ function AnomalyThreshold({
<Select
value={thresholdState.seasonality}
data-testid="seasonality-select"
className={styles.seasonalitySelect}
onChange={(value): void => {
setThresholdState({
type: 'SET_SEASONALITY',
@@ -154,39 +188,52 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
{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>
)}
</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}

View File

@@ -21,7 +21,6 @@ function ThresholdItem({
isErrorChannels,
refreshChannels,
isLoadingChannels,
isAnomaly = false,
}: ThresholdItemProps): JSX.Element {
const { user } = useAppContext();
const { thresholdState, notificationSettings } = useCreateAlertState();
@@ -105,16 +104,10 @@ function ThresholdItem({
style={{ width: 200 }}
data-testid="threshold-name-input"
/>
{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>
</>
)}
<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}
@@ -125,11 +118,7 @@ function ThresholdItem({
type="number"
data-testid="threshold-value-input"
/>
{isAnomaly ? (
<Typography.Text className="sentence-text">deviations</Typography.Text>
) : (
yAxisUnitSelect
)}
{yAxisUnitSelect}
{!notificationSettings.routingPolicies && (
<>
<Typography.Text className="sentence-text">send to</Typography.Text>

View File

@@ -1,7 +1,7 @@
import { IAppContext } from 'providers/App/types';
import { render, screen, userEvent } from 'tests/test-utils';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -65,55 +65,54 @@ 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: (): unknown => ({
currentQuery: mockCurrentQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
handleSetQueryData: mockHandleSetQueryData,
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(),
}),
}));
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderAlertCondition = (
alertType?: string,
appContextOverrides?: Partial<IAppContext>,
): ReturnType<typeof render> =>
render(
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertCondition />
</CreateAlertProvider>,
undefined,
{
initialRoute: alertType ? `/?alertType=${alertType}` : '/',
appContextOverrides,
},
): 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>,
);
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(
@@ -126,9 +125,10 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -136,7 +136,8 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -151,7 +152,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('renders anomaly tab when alert type supports multiple tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('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();
@@ -165,47 +167,36 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
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 () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
await clickText(ANOMALY_TAB_TEXT);
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
it('switches between threshold and anomaly tabs', async () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// Click anomaly tab
await clickText(ANOMALY_TAB_TEXT);
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
// Should show anomaly component
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
// Click threshold tab
await clickText(THRESHOLD_TAB_TEXT);
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
fireEvent.click(thresholdTab);
// Should show threshold component again
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
@@ -214,40 +205,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
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 () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -258,7 +217,7 @@ describe('AlertCondition', () => {
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
// Click anomaly tab
await clickText(ANOMALY_TAB_TEXT);
fireEvent.click(anomalyTab);
// Anomaly tab should be active now
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
@@ -268,17 +227,11 @@ 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();
});
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();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -286,8 +239,9 @@ describe('AlertCondition', () => {
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_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();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -1,11 +1,13 @@
import { render, screen } from '@testing-library/react';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
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(),
@@ -20,11 +22,12 @@ jest.mock('uplot', () => {
const mockSetAlertState = jest.fn();
const mockSetThresholdState = jest.fn();
const mockContextState = createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
});
const useCreateAlertStateSpy = jest.spyOn(context, 'useCreateAlertState');
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
}),
);
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -49,12 +52,10 @@ 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()}
@@ -64,117 +65,31 @@ const renderAnomalyThreshold = (): ReturnType<typeof render> =>
describe('AnomalyThreshold', () => {
beforeEach(() => {
jest.clearAllMocks();
useCreateAlertStateSpy.mockReturnValue(mockContextState);
});
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 condition sentence in the same order as threshold alerts', () => {
it('renders the first condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('notification-text')).toHaveTextContent(
'Send a notification when',
);
expect(screen.getByTestId('query-select')).toBeInTheDocument();
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-text')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-window-select')).toBeInTheDocument();
});
it('renders the second condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('threshold-text')).toBeInTheDocument();
expect(screen.getByTestId('threshold-value-select')).toBeInTheDocument();
expect(screen.getByTestId('deviations-text')).toBeInTheDocument();
expect(screen.getByTestId('operator-select')).toBeInTheDocument();
expect(screen.getByTestId('predicted-data-text')).toHaveTextContent(
'the prediction',
);
expect(screen.getByTestId('predicted-data-text')).toBeInTheDocument();
expect(screen.getByTestId('match-type-select')).toBeInTheDocument();
expect(screen.getByTestId('evaluation-settings-button')).toHaveTextContent(
'Rolling',
);
});
it('renders the algorithm and seasonality sentence', () => {
it('renders the third condition 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')).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();
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
});
});

View File

@@ -149,14 +149,6 @@ 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();

View File

@@ -1,58 +0,0 @@
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);
});
});

View File

@@ -4,11 +4,5 @@ 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';

View File

@@ -90,6 +90,8 @@
}
.ant-select {
width: 240px;
.ant-select-selector {
background-color: var(--l2-background);
border: 1px solid var(--l1-border);
@@ -253,6 +255,28 @@
}
}
}
.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 {
@@ -290,6 +314,14 @@
}
}
.anomaly-threshold-container {
.ant-select {
.ant-select-selector {
min-width: 150px;
}
}
}
.condensed-alert-threshold-container,
.condensed-anomaly-threshold-container {
width: 100%;

View File

@@ -26,13 +26,6 @@ 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 {

View File

@@ -1,36 +0,0 @@
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,
]);
}

View File

@@ -1,53 +0,0 @@
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 };
}

View File

@@ -15,13 +15,11 @@ 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[] {
@@ -45,24 +43,6 @@ 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) =>
@@ -243,22 +223,17 @@ 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 />
<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}
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}
</div>
);
}

View File

@@ -4,14 +4,11 @@ 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({
rollingOnly = false,
}: IEvaluationSettingsProps): JSX.Element {
function EvaluationSettings(): JSX.Element {
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
const [isEvaluationWindowPopoverOpen, setIsEvaluationWindowPopoverOpen] =
@@ -27,7 +24,6 @@ function EvaluationSettings({
<EvaluationWindowPopover
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
rollingOnly={rollingOnly}
/>
}
trigger="click"

View File

@@ -20,12 +20,7 @@ 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 => {
@@ -141,7 +136,7 @@ function EvaluationWindowPopover({
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
windowTypeOptions,
EVALUATION_WINDOW_TYPE,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({

View File

@@ -39,18 +39,6 @@ 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

View File

@@ -31,14 +31,9 @@ export enum CumulativeWindowTimeframes {
CURRENT_MONTH = 'currentMonth',
}
export interface IEvaluationSettingsProps {
rollingOnly?: boolean;
}
export interface IEvaluationWindowPopoverProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
rollingOnly?: boolean;
}
export interface IEvaluationWindowDetailsProps {

View File

@@ -13,7 +13,10 @@ import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
import './styles.scss';
import {
@@ -81,7 +84,7 @@ function Footer(): JSX.Element {
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -114,12 +117,11 @@ function Footer(): JSX.Element {
notificationSettings,
currentQuery,
testAlertRule,
handleApiError,
]);
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -171,7 +173,6 @@ function Footer(): JSX.Element {
createAlertRule,
safeNavigate,
handleApiError,
queryClient,
]);
const isSavingAlertRule = isCreatingAlertRule || isUpdatingAlertRule;

View File

@@ -48,6 +48,7 @@ const mockAlertContextState = createMockAlertContextState({
selectedQuery: 'A',
operator: AlertThresholdOperator.ABOVE_BELOW,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
evaluationWindow: '5m0s',
algorithm: 'standard',
seasonality: 'hourly',
thresholds: [

View File

@@ -9,10 +9,8 @@ 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';
@@ -20,7 +18,6 @@ import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateAlertRulePayload,
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
@@ -99,33 +96,6 @@ 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,
@@ -603,93 +573,4 @@ 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();
});
});
});

View File

@@ -2,7 +2,6 @@ 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,
@@ -10,10 +9,8 @@ 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';
@@ -34,8 +31,7 @@ export function getFormattedTimeValue(timeValue: number, unit: string): string {
export function validateCreateAlertState(
args: BuildCreateAlertRulePayloadArgs,
): string | null {
const { alertType, basicAlertState, thresholdState, notificationSettings } =
args;
const { basicAlertState, thresholdState, notificationSettings } = args;
// Validate alert name
if (!basicAlertState.name) {
@@ -48,12 +44,6 @@ 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';
@@ -299,43 +289,56 @@ 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 { thresholdState } = args;
const payload = buildCreateThresholdAlertRulePayload(args);
const {
alertType,
basicAlertState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
// 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: '',
}));
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);
return {
...payload,
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType: AlertTypes.METRICS_BASED_ALERT,
alertType,
condition: {
...payload.condition,
thresholds: { kind: 'basic', spec: thresholds },
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
compositeQuery,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
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);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Button } from 'antd';
import classNames from 'classnames';
@@ -74,23 +74,20 @@ function QuerySection(): JSX.Element {
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
const [isCancelled, setIsCancelled] = useState(false);
const handleFetchingStateChange = useCallback((isFetching: boolean): void => {
setIsLoadingQueries(isFetching);
if (isFetching) {
useEffect(() => {
if (isLoadingQueries) {
setIsCancelled(false);
}
}, []);
}, [isLoadingQueries]);
const handleCancelQuery = useCallback(() => {
void queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
setIsCancelled(true);
}, [queryClient]);
const runQueryHandler = useCallback(() => {
setIsCancelled(false);
void queryClient.invalidateQueries([
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
]);
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) {
@@ -132,11 +129,6 @@ 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" />
@@ -144,7 +136,7 @@ function QuerySection(): JSX.Element {
alertDef={alertDef}
source={source}
isCancelled={isCancelled}
onFetchingStateChange={handleFetchingStateChange}
onFetchingStateChange={setIsLoadingQueries}
/>
<div className="query-section-tabs">
<div className="query-section-query-actions">
@@ -152,12 +144,9 @@ function QuerySection(): JSX.Element {
<Button
key={tab.value}
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': activeTab === tab.value,
'active-tab': alertType === tab.value,
})}
onClick={(): void => {
if (isAnomalyDetection && tab.value === activeTab) {
return;
}
setAlertType(tab.value as AlertTypes);
}}
>
@@ -176,7 +165,6 @@ function QuerySection(): JSX.Element {
handleCancelQuery={handleCancelQuery}
alertDef={alertDef}
panelType={PANEL_TYPES.TIME_SERIES}
isAnomalyDetection={isAnomalyDetection}
key={currentQuery.queryType}
ruleId=""
hideTitle

View File

@@ -61,12 +61,10 @@ 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
@@ -129,11 +127,11 @@ const queryClient = new QueryClient({
},
},
});
const renderQuerySection = (search = ''): ReturnType<typeof render> =>
const renderQuerySection = (): ReturnType<typeof render> =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
@@ -203,27 +201,6 @@ 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();

View File

@@ -1,6 +1,5 @@
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';
@@ -12,7 +11,6 @@ import {
import {
getAdvancedOptionsStateFromAlertDef,
getColorForThreshold,
getAlertTypeFromAlertDef,
getCreateAlertLocalStateFromAlertDef,
getEvaluationWindowStateFromAlertDef,
getNotificationSettingsStateFromAlertDef,
@@ -273,7 +271,7 @@ describe('CreateAlertV2 utils', () => {
});
});
it('getThresholdStateFromAlertDef maps thresholds, query, operator and match type', () => {
describe('getThresholdStateFromAlertDef', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
@@ -318,81 +316,6 @@ 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 = {

View File

@@ -1,5 +1,6 @@
import { useHistory } from 'react-router-dom';
import { render, screen, userEvent } from 'tests/test-utils';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, useHistory } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { INITIAL_CREATE_ALERT_STATE } from '../constants';
@@ -40,20 +41,20 @@ function Probe(): JSX.Element {
}
function renderWithSearch(search: string): void {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<Probe />
</CreateAlertProvider>,
undefined,
{ initialRoute: `/alerts/new${search}` },
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<Probe />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
);
}
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([
@@ -96,13 +97,6 @@ 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(
@@ -120,17 +114,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({
to = '/alerts/overview?compositeQuery=normalized&ruleId=r1',
}: {
to?: string;
}): JSX.Element {
function SearchMutator(): JSX.Element {
const routerHistory = useHistory();
return (
<button
type="button"
data-testid="mutate-search"
onClick={(): void => routerHistory.replace(to)}
onClick={(): void =>
routerHistory.replace(
'/alerts/overview?compositeQuery=normalized&ruleId=r1',
)
}
>
change search
</button>
@@ -138,7 +132,7 @@ function SearchMutator({
}
describe('CreateAlertProvider — edit mode ignores URL prefill', () => {
it('keeps loaded thresholds when the query builder rewrites location.search', async () => {
it('keeps loaded thresholds when the query builder rewrites location.search', () => {
const initialAlertState = {
...INITIAL_CREATE_ALERT_STATE,
threshold: {
@@ -153,158 +147,30 @@ describe('CreateAlertProvider — edit mode ignores URL prefill', () => {
},
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<CreateAlertProvider
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
isEditMode
ruleId="r1"
initialAlertState={initialAlertState}
>
<Probe />
<SearchMutator />
</CreateAlertProvider>,
undefined,
{ initialRoute: '/alerts/overview?ruleId=r1' },
<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>,
);
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
// Simulate the query builder normalizing the query into the URL post-load.
await clickTestId('mutate-search');
fireEvent.click(screen.getByTestId('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');
});
});

View File

@@ -27,6 +27,7 @@ import {
EvaluationWindowState,
NotificationSettingsState,
Seasonality,
TimeDuration,
} from '../types';
import {
advancedOptionsReducer,
@@ -265,6 +266,7 @@ 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: [],
@@ -278,6 +280,7 @@ 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: [],

View File

@@ -16,6 +16,7 @@ import {
NotificationSettingsState,
Seasonality,
Threshold,
TimeDuration,
} from './types';
export const INITIAL_ALERT_STATE: AlertState = {
@@ -34,11 +35,6 @@ 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',
@@ -73,6 +69,7 @@ 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],
@@ -139,8 +136,8 @@ export const THRESHOLD_OPERATOR_OPTIONS = [
];
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_ABOVE, label: 'ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
{ value: AlertThresholdOperator.ABOVE_BELOW, label: 'ABOVE/BELOW' },
];
@@ -155,8 +152,16 @@ 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' },
{ value: AlertThresholdMatchType.ON_AVERAGE, label: 'ON AVERAGE' },
{ value: AlertThresholdMatchType.LAST, label: 'LAST' },
];
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' },
];
export const ANOMALY_ALGORITHM_OPTIONS = [

View File

@@ -22,14 +22,9 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
INITIAL_ANOMALY_THRESHOLD,
INITIAL_CREATE_ALERT_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from './constants';
import { INITIAL_CREATE_ALERT_STATE } from './constants';
import {
EvaluationWindowPreset,
pickAlertPrefillParams,
resolveUrlAlertPrefill,
} from './resolveUrlAlertPrefill';
import {
@@ -134,10 +129,9 @@ 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(prefillParams)),
[prefillParams],
() => resolveUrlAlertPrefill(new URLSearchParams(location.search)),
[location.search],
);
const [alertType, setAlertType] = useState<AlertTypes>(() => {
@@ -147,41 +141,14 @@ 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 => {
// 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);
const queryToRedirect = buildInitialAlertDef(value);
const currentQueryToRedirect = mapQueryDataFromApi(
queryToRedirect.condition.compositeQuery,
);
redirectWithQueryBuilderData(
queryToRedirect,
currentQueryToRedirect,
{
[QueryParams.alertType]: value,
[QueryParams.ruleType]:
@@ -193,28 +160,8 @@ 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,
});
}
},
[
alertType,
createAlertState.evaluationWindow.windowType,
currentQuery,
isEditMode,
redirectWithQueryBuilderData,
resetThresholdState,
setEvaluationWindow,
],
[redirectWithQueryBuilderData],
);
const ruleNameAppliedRef = useRef(false);
@@ -229,7 +176,12 @@ export function CreateAlertProvider(
return;
}
resetThresholdState(alertType);
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'RESET',
},
});
if (urlPrefill.thresholds) {
setCreateAlertState({
@@ -261,10 +213,7 @@ export function CreateAlertProvider(
});
}
if (
urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER &&
alertType !== AlertTypes.ANOMALY_BASED_ALERT
) {
if (urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER) {
setCreateAlertState({
slice: CreateAlertSlice.EVALUATION_WINDOW,
action: {
@@ -294,14 +243,7 @@ export function CreateAlertProvider(
},
});
}
}, [
alertType,
urlPrefill,
ruleNameFromURL,
yAxisUnitFromURL,
isEditMode,
resetThresholdState,
]);
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL, isEditMode]);
useEffect(() => {
if (isEditMode && initialAlertState) {

View File

@@ -7,13 +7,6 @@ import {
Threshold,
} from './types';
const PREFILL_PARAMS = [
QueryParams.thresholds,
QueryParams.matchType,
QueryParams.compareOp,
QueryParams.evaluationWindowPreset,
];
export enum EvaluationWindowPreset {
METER = 'meter',
}
@@ -46,18 +39,6 @@ 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,

View File

@@ -119,11 +119,23 @@ 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',
}
@@ -138,6 +150,7 @@ 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[] }

View File

@@ -124,10 +124,6 @@ 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':

View File

@@ -1,6 +0,0 @@
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
export const useIsAnomalyDetectionAvailable = (): boolean => {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
return isCloudUser || isEnterpriseSelfHostedUser;
};

View File

@@ -4,8 +4,6 @@ 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';
@@ -250,18 +248,13 @@ 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,
// anomaly targets are signed z-scores; the operator carries the direction
thresholdValue: isAnomalyRule
? Math.abs(threshold.target)
: threshold.target,
thresholdValue: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
@@ -275,23 +268,9 @@ 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 {

View File

@@ -8,6 +8,7 @@ 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';
@@ -31,7 +32,7 @@ import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { isEmpty } from 'lodash-es';
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
@@ -119,6 +120,8 @@ 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);
@@ -287,7 +290,9 @@ function ChartPreview({
!queryResponse.isLoading &&
(!queryResponse.isError || isCancelled);
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
const isWarning = !isEmpty(queryResponse.data?.warning);

View File

@@ -21,6 +21,7 @@ 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';
@@ -38,7 +39,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 { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -100,6 +101,7 @@ function FormAlertRules({
}: FormAlertRuleProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
const { featureFlags } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
@@ -810,7 +812,9 @@ function FormAlertRules({
},
];
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
const source = useMemo(
() => urlQuery.get(QueryParams.source) as YAxisSource,

View File

@@ -243,7 +243,7 @@ function Hosts(): JSX.Element {
</Tooltip>
</div>
<QuickFilters
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
source={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig()}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={{

View File

@@ -10,6 +10,7 @@ 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';
@@ -73,6 +74,7 @@ export function getHostsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -85,6 +87,7 @@ export function getHostsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{

View File

@@ -321,7 +321,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
</div>
{selectedCategoryConfig && (
<QuickFilters
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={selectedCategoryUseFieldApis}

View File

@@ -228,6 +228,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -241,6 +242,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -254,6 +256,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -267,6 +270,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -280,6 +284,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -293,6 +298,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -306,6 +312,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -319,6 +326,7 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -347,6 +355,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -360,6 +369,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -373,6 +383,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -386,6 +397,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -399,6 +411,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -412,6 +425,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -425,6 +439,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -438,6 +453,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -451,6 +467,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -464,6 +481,7 @@ export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
@@ -492,6 +510,7 @@ export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -505,6 +524,7 @@ export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -533,6 +553,7 @@ export function GetNamespaceQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -546,6 +567,7 @@ export function GetNamespaceQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -574,6 +596,7 @@ export function GetClustersQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -602,6 +625,7 @@ export function GetVolumesQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_VOLUME_CAPACITY,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -615,6 +639,7 @@ export function GetVolumesQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_VOLUME_CAPACITY,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -628,6 +653,7 @@ export function GetVolumesQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_VOLUME_CAPACITY,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -656,6 +682,7 @@ export function GetDeploymentsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -669,6 +696,7 @@ export function GetDeploymentsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -682,6 +710,7 @@ export function GetDeploymentsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -710,6 +739,7 @@ export function GetStatefulsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -723,6 +753,7 @@ export function GetStatefulsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -736,6 +767,7 @@ export function GetStatefulsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -764,6 +796,7 @@ export function GetDaemonsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -776,6 +809,7 @@ export function GetDaemonsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -788,6 +822,7 @@ export function GetDaemonsetsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -816,6 +851,7 @@ export function GetJobsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -828,6 +864,7 @@ export function GetJobsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
@@ -840,6 +877,7 @@ export function GetJobsQuickFiltersConfig(): IQuickFiltersConfig[] {
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{

View File

@@ -3,11 +3,11 @@ import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueryAIWithType } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
@@ -191,9 +191,11 @@ function Explorer(): JSX.Element {
testId="llm-observability-explorer"
showFilters={isOpen}
onToggleFilters={(): void => setOpen(!isOpen)}
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
quickFilterProps={{
className: 'qf-traces-explorer',
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,
source: QuickFiltersSource.AI_OBSERVABILITY,
signal: SignalType.AI_OBSERVABILITY,
useFieldApis: quickFiltersFieldApis,
}}
>
@@ -225,11 +227,11 @@ function Explorer(): JSX.Element {
}
/>
</div>
<ExplorerCard sourcepage={DataSource.TRACES}>
<div>
<div className="query-section-container">
<QuerySection />
</div>
</ExplorerCard>
</div>
<div className="traces-explorer-views">
{isCancelled && (

View File

@@ -37,11 +37,8 @@ import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { PER_PAGE_OPTIONS, TIMESTAMP_FIELD } from './configs';
import { useListViewColumns } from './useListViewColumns';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
@@ -77,6 +74,23 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const {
selectedFields,
fieldsSelectorConfig,
onColumnOrderChange,
onColumnRemove,
} = useListViewColumns();
// Sorted names: stable on reorder, changes on add/remove (the API only returns selected columns).
const selectColumnsSignature = useMemo(
() =>
selectedFields
.map((field) => field.name)
.sort()
.join(','),
[selectedFields],
);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -97,12 +111,14 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -123,7 +139,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: defaultSelectedColumns,
selectColumns: selectedFields,
},
},
ENTITY_VERSION_V5,
@@ -156,13 +172,13 @@ function ListView({
[queryTableDataResult],
);
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
),
[],
[
TIMESTAMP_FIELD,
...selectedFields.filter((field) => field.name !== TIMESTAMP_FIELD.name),
].map((field) => getFieldColumn(field)),
[selectedFields],
);
const rows = useMemo(
@@ -204,6 +220,7 @@ function ListView({
isLoading={isFetching}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
/>
</div>
@@ -211,7 +228,6 @@ function ListView({
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -219,6 +235,8 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
/>
</div>
);

View File

@@ -0,0 +1,119 @@
import { toast } from '@signozhq/ui/sonner';
import { TelemetryFieldKey } from 'api/v5/v5';
import ROUTES from 'constants/routes';
import { render, screen, userEvent } from 'tests/test-utils';
import { getFieldColumn } from '../../TracesTable/getFieldColumn';
import { defaultSelectedColumns } from '../configs';
import { LAST_COLUMN_ERROR, useListViewColumns } from '../useListViewColumns';
jest.mock('@signozhq/ui/sonner', () => ({
toast: { error: jest.fn(), success: jest.fn() },
}));
const [SERVICE_NAME, SPAN_NAME, DURATION] = defaultSelectedColumns;
const MODEL = {
name: 'gen_ai.request.model',
signal: 'traces',
fieldContext: 'attribute',
fieldDataType: 'string',
} as TelemetryFieldKey;
function Harness(): JSX.Element {
const {
selectedFields,
fieldsSelectorConfig,
onColumnOrderChange,
onColumnRemove,
} = useListViewColumns();
const removeFirst = (): void =>
onColumnRemove(getFieldColumn(selectedFields[0]).id);
return (
<div>
<div data-testid="columns">
{selectedFields.map((field) => field.name).join(',')}
</div>
<button
type="button"
onClick={(): void =>
fieldsSelectorConfig.fieldsSelector?.onFieldsChange([SPAN_NAME, MODEL])
}
>
save drawer
</button>
<button
type="button"
onClick={(): void =>
onColumnOrderChange(
[DURATION, SERVICE_NAME, SPAN_NAME].map((field) => getFieldColumn(field)),
)
}
>
reorder
</button>
<button type="button" onClick={removeFirst}>
remove first
</button>
</div>
);
}
const renderHarness = (): void => {
render(<Harness />, undefined, {
initialRoute: ROUTES.AI_OBSERVABILITY_EXPLORER,
});
};
describe('useListViewColumns', () => {
beforeEach(() => {
localStorage.clear();
});
it('falls back to the default columns when nothing is stored', () => {
renderHarness();
expect(screen.getByTestId('columns')).toHaveTextContent(
defaultSelectedColumns.map((field) => field.name).join(','),
);
});
it('saves the drawer selection', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByRole('button', { name: 'save drawer' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
'name,gen_ai.request.model',
);
});
it('follows the table order on a header drag, keeping unlisted columns last', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByRole('button', { name: 'reorder' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
/^duration_nano,service\.name,name,/,
);
});
it('removes a column from the header but never the last one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderHarness();
await user.click(screen.getByRole('button', { name: 'save drawer' }));
await user.click(screen.getByRole('button', { name: 'remove first' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
/^gen_ai\.request\.model$/,
);
await user.click(screen.getByRole('button', { name: 'remove first' }));
expect(screen.getByTestId('columns')).toHaveTextContent(
/^gen_ai\.request\.model$/,
);
expect(jest.mocked(toast.error)).toHaveBeenCalledWith(LAST_COLUMN_ERROR);
});
});

View File

@@ -0,0 +1,76 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { defaultSelectedColumns } from './configs';
export const LAST_COLUMN_ERROR = 'There must be at least one selected column';
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseListViewColumns {
selectedFields: TelemetryFieldKey[];
fieldsSelectorConfig: OptionsMenuConfig;
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove: (columnId: string) => void;
}
export function useListViewColumns(): UseListViewColumns {
const {
aiObservability: { columns, updateColumns },
} = usePreferenceContext();
const selectedFields = columns.list ?? defaultSelectedColumns;
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => updateColumns('list', next),
[updateColumns],
);
const fieldsSelectorConfig = useMemo<OptionsMenuConfig>(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
);
const onColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
const orderIndex = new Map(cols.map((col, index) => [col.id, index]));
updateColumns(
'list',
[...selectedFields].sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
),
);
},
[selectedFields, updateColumns],
);
const onColumnRemove = useCallback(
(columnId: string): void => {
const next = selectedFields.filter(
(field) => columnIdOf(field) !== columnId,
);
if (!next.length) {
toast.error(LAST_COLUMN_ERROR);
return;
}
updateColumns('list', next);
},
[selectedFields, updateColumns],
);
return {
selectedFields,
fieldsSelectorConfig,
onColumnOrderChange,
onColumnRemove,
};
}

View File

@@ -69,6 +69,8 @@ function TracesView({
columns,
selectedFields,
onFieldsChange,
onColumnOrderChange,
onColumnRemove,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
@@ -183,15 +185,6 @@ function TracesView({
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
@@ -226,13 +219,12 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columns={columns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading || isColumnsLoading}
@@ -240,6 +232,8 @@ function TracesView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
/>
</div>
);

View File

@@ -1,5 +1,6 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
@@ -40,15 +41,51 @@ export const sortByDefaultOrder = (
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied while no selection is stored. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
export const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
export const getDefaultTraceViewFields = (
availableFields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
availableFields.filter((field) => DEFAULT_VISIBLE_FIELDS.has(field.name));
/** The selection the old column store encoded; `null` when it matches the defaults. */
export const getLegacyTraceViewSelection = (
availableFields: TelemetryFieldKey[],
hiddenColumnIds: string[],
columnOrder: string[],
): TelemetryFieldKey[] | null => {
const hidden = new Set(hiddenColumnIds);
// A saved order lists exactly the columns the user kept.
if (columnOrder.length) {
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
const kept = availableFields
.filter((field) => orderIndex.has(columnIdOf(field)))
.filter((field) => !hidden.has(columnIdOf(field)));
return kept.length
? kept.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? 0) -
(orderIndex.get(columnIdOf(b)) ?? 0),
)
: null;
}
const defaults = getDefaultTraceViewFields(availableFields);
const kept = defaults.filter((field) => !hidden.has(columnIdOf(field)));
return kept.length === defaults.length ? null : kept;
};
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,

View File

@@ -1,17 +1,15 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
@@ -23,27 +21,41 @@ import {
} from '../constants';
import {
buildTraceViewColumns,
columnIdOf,
getDefaultTraceViewFields,
getLegacyTraceViewSelection,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
/** Now only column widths; it held the selection as hidden ids + order before the preference slice. */
const TABLE_STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
onColumnOrderChange: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove: (columnId: string) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
const withTraceId = (
fields: TelemetryFieldKey[],
availableFields: TelemetryFieldKey[],
): TelemetryFieldKey[] => {
if (fields.some((field) => field.name === TRACE_ID_COLUMN_ID)) {
return fields;
}
const traceId = availableFields.find(
(field) => field.name === TRACE_ID_COLUMN_ID,
);
return traceId ? [traceId, ...fields] : fields;
};
export function useTraceViewColumns(): UseTraceViewColumns {
const {
data: fetchedFields = [],
@@ -66,60 +78,99 @@ export function useTraceViewColumns(): UseTraceViewColumns {
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
const {
aiObservability: {
columns: { trace: storedFields },
updateColumns,
},
} = usePreferenceContext();
// Captured on first render, before the table prunes hidden ids it no longer renders.
const legacyHiddenIds = useHiddenColumnIds(TABLE_STORAGE_KEY);
const legacyOrder = useColumnOrder(TABLE_STORAGE_KEY);
const legacy = useRef({ hiddenIds: legacyHiddenIds, order: legacyOrder });
const migratedFields = useMemo(
() =>
isSuccess && !storedFields
? getLegacyTraceViewSelection(
availableFields,
legacy.current.hiddenIds,
legacy.current.order,
)
: null,
[isSuccess, storedFields, availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
if (!migratedFields) {
return;
}
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
updateColumns('trace', migratedFields);
// Clear the old encoding so a later reset to defaults isn't migrated again.
setColumnOrder(TABLE_STORAGE_KEY, []);
legacy.current.hiddenIds.forEach((id) => showColumn(TABLE_STORAGE_KEY, id));
legacy.current = { hiddenIds: [], order: [] };
}, [migratedFields, updateColumns]);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
const byName = new Map(availableFields.map((field) => [field.name, field]));
const source =
storedFields ?? migratedFields ?? getDefaultTraceViewFields(availableFields);
// Stored fields the endpoint no longer reports are dropped.
const resolved = source
.map((field) => byName.get(field.name))
.filter((field): field is TelemetryFieldKey => !!field);
return withTraceId(resolved, availableFields);
}, [availableFields, storedFields, migratedFields]);
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const columns = useMemo(
() => buildTraceViewColumns(selectedFields),
[selectedFields],
);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
updateColumns('trace', withTraceId(next, availableFields));
},
[columns, isSuccess],
[isSuccess, availableFields, updateColumns],
);
const onColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
const orderIndex = new Map(cols.map((col, index) => [col.id, index]));
onFieldsChange(
[...selectedFields].sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
),
);
},
[selectedFields, onFieldsChange],
);
const onColumnRemove = useCallback(
(columnId: string): void => {
if (columnId === TRACE_ID_COLUMN_ID) {
return;
}
onFieldsChange(
selectedFields.filter((field) => columnIdOf(field) !== columnId),
);
},
[selectedFields, onFieldsChange],
);
return {
columns,
selectedFields,
onFieldsChange,
onColumnOrderChange,
onColumnRemove,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,

View File

@@ -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 } from 'components/QuickFilters/types';
import { QuickFiltersSource, SignalType } 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,7 +130,8 @@ function Explorer(): JSX.Element {
savedViewProps={{ source: SavedviewtypesSourceDTO.meter }}
quickFilterProps={{
className: 'qf-meter-explorer',
quickFilterSource: QuickFiltersSource.METER_EXPLORER,
source: QuickFiltersSource.METER_EXPLORER,
signal: SignalType.METER_EXPLORER,
showFilterCollapse: true,
showQueryName: false,
useFieldApis: quickFilterFieldApis,

View File

@@ -2,18 +2,16 @@ import { useEffect, useMemo, useRef } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
import { useSavedViewColumns } from './useSavedViewColumns';
export function useActiveSavedView(
source: SavedviewtypesSourceDTO,
@@ -21,10 +19,7 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { hasColumnChanges } = useSavedViewColumns(source);
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
[buildSavedViewReadPermission(viewKey)],
@@ -59,20 +54,20 @@ export function useActiveSavedView(
lastResult.current.hasUnsavedChanges
);
}
return hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
});
return (
hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
}) || hasColumnChanges(view)
);
}, [
view,
isError,
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
options,
hasColumnChanges,
]);
useEffect(() => {

View File

@@ -15,18 +15,14 @@ import {
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
@@ -38,10 +34,10 @@ import {
} from '../events';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
import { useSavedViewColumns } from './useSavedViewColumns';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
// Columns and formatting need no handling here: with a viewKey in the url the
@@ -60,15 +56,7 @@ export function useSavedViewActions(
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
const { options, applyViewColumns } = useSavedViewColumns(source);
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
@@ -86,10 +74,10 @@ export function useSavedViewActions(
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
options,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
[stagedQuery, currentQuery, panelType, options],
);
const selectView = useCallback(
@@ -105,22 +93,13 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (optionsDataSource) {
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
);
preferencesSlice.updateColumns(columns);
if (formatting) {
preferencesSlice.updateFormatting(formatting);
}
}
applyViewColumns(view);
selectView(view);
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
[applyViewColumns, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt

View File

@@ -0,0 +1,97 @@
import { useCallback, useMemo } from 'react';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import isEqual from 'lodash-es/isEqual';
import {
panelTypeToColumnsView,
sanitizeColumns,
} from 'providers/preferences/configs/aiObservabilityLoaderConfig';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { SavedViewOptions, UseSavedViewColumnsResult } from '../types';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { hasUnsavedColumnChanges } from '../utils/hasUnsavedViewChanges';
/** The columns a saved view stores and restores, per source. */
export function useSavedViewColumns(
source: SavedviewtypesSourceDTO,
): UseSavedViewColumnsResult {
const isAIObservability = source === SavedviewtypesSourceDTO.ai_observability;
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
// AI views hold the columns of the tab they were saved from.
const { panelType } = useQueryBuilder();
const activeTab = panelTypeToColumnsView(panelType ?? undefined);
const { columns: aiColumns, updateColumns: updateAIColumns } =
preferences.aiObservability;
const activeTabColumns = activeTab ? aiColumns[activeTab] : null;
const savedOptions = useMemo((): SavedViewOptions | undefined => {
if (isAIObservability) {
// Omitted while the tab is on its defaults.
return activeTabColumns ? { selectColumns: activeTabColumns } : undefined;
}
return optionsDataSource ? options : undefined;
}, [isAIObservability, activeTabColumns, optionsDataSource, options]);
const hasColumnChanges = useCallback(
(view: SavedviewtypesSavedViewDTO): boolean => {
if (isAIObservability) {
if (!activeTab) {
return false;
}
const toNames = (fields: { name: string }[] | null): string[] | null =>
fields?.map((field) => field.name) ?? null;
return !isEqual(
toNames(sanitizeColumns(activeTab, view.spec.selectedFields)),
toNames(activeTabColumns),
);
}
return optionsDataSource
? hasUnsavedColumnChanges({ view, dataSource: optionsDataSource, options })
: false;
},
[isAIObservability, activeTab, activeTabColumns, optionsDataSource, options],
);
const applyViewColumns = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (isAIObservability) {
const tab = panelTypeToColumnsView(view.spec.panelType);
if (tab) {
updateAIColumns(tab, sanitizeColumns(tab, view.spec.selectedFields) ?? []);
}
return;
}
if (!optionsDataSource) {
return;
}
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
);
preferencesSlice.updateColumns(columns);
if (formatting) {
preferencesSlice.updateFormatting(formatting);
}
},
[isAIObservability, updateAIColumns, optionsDataSource, preferencesSlice],
);
return { options: savedOptions, hasColumnChanges, applyViewColumns };
}

View File

@@ -14,10 +14,23 @@ import { SavedViewCreatedFrom } from './events';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
OptionsQuery,
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
>;
/** Formatting is Logs-only. */
export type SavedViewOptions = Pick<OptionsQuery, 'selectColumns'> &
Partial<Pick<OptionsQuery, 'format' | 'maxLines' | 'fontSize'>>;
export interface HasUnsavedColumnChangesArgs {
view: SavedviewtypesSavedViewDTO;
dataSource: DataSource;
options: SavedViewOptions;
}
export interface UseSavedViewColumnsResult {
/** What a saved spec stores; `undefined` when the source saves no columns. */
options: SavedViewOptions | undefined;
hasColumnChanges: (view: SavedviewtypesSavedViewDTO) => boolean;
/** Puts the view's columns back, as on discard. */
applyViewColumns: (view: SavedviewtypesSavedViewDTO) => void;
}
export interface ToSavedViewSpecArgs {
query: Query;

View File

@@ -3,7 +3,10 @@ import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMapp
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import {
HasUnsavedColumnChangesArgs,
HasUnsavedViewChangesArgs,
} from '../types';
import { getSavedViewQuery } from './getSavedViewQuery';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
@@ -76,7 +79,19 @@ export function hasUnsavedViewChanges({
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
return hasUnsavedColumnChanges({
view,
dataSource: stagedQuery.builder.queryData[0]?.dataSource,
options,
});
}
/** Logs/Traces columns and formatting against the view's, defaults filled in. */
export function hasUnsavedColumnChanges({
view,
dataSource,
options,
}: HasUnsavedColumnChangesArgs): boolean {
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,

View File

@@ -10,13 +10,11 @@ import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import {
getAlertTypeFromAlertDef,
getCreateAlertLocalStateFromAlertDef,
} from 'container/CreateAlertV2/utils';
import { 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';
@@ -76,7 +74,7 @@ function AlertDetails(): JSX.Element {
const handleTabChange = (route: string): void => {
if (route === ROUTES.ALERT_HISTORY) {
void logEvent('Alert History tab: Visited', { ruleId });
logEvent('Alert History tab: Visited', { ruleId });
}
};
@@ -91,7 +89,7 @@ function AlertDetails(): JSX.Element {
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={getAlertTypeFromAlertDef(alertRuleDetails)}
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertState={initialAlertState}
>
<div

View File

@@ -14,11 +14,9 @@ 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';
@@ -48,13 +46,6 @@ 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.',
@@ -95,7 +86,6 @@ export const alertOverviewMocks = defineStoryMocks({
severity: values.ruleSeverity,
state: values.ruleState,
schema: values.alertSchema,
kind: values.ruleKind,
}),
),
),

View File

@@ -44,35 +44,6 @@ 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' },

View File

@@ -18,8 +18,6 @@ import {
import type {
MetricRangePayloadV5,
QueryRangeRequestV5,
TimeSeries,
TimeSeriesValue,
} from 'types/api/v5/queryRange';
import {
@@ -54,97 +52,23 @@ export const alertPreviewSeries = async (
return queryRangeV5EmptyResponse(queryName);
}
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,
},
],
},
return queryRangeV5TimeSeriesResponse([
{
queryName,
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 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[] = [

View File

@@ -7,26 +7,22 @@ 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,
@@ -74,10 +70,6 @@ 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',
@@ -98,10 +90,7 @@ 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,
functions?: Querybuildertypesv5FunctionDTO[],
): RuletypesAlertCompositeQueryDTO => ({
const compositeQuery = (seed: RuleSeed): RuletypesAlertCompositeQueryDTO => ({
queryType: RuletypesQueryTypeDTO.builder,
panelType: RuletypesPanelTypeDTO.graph,
unit: seed.unit,
@@ -125,60 +114,11 @@ const compositeQuery = (
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,
@@ -356,7 +296,6 @@ export interface RuleShape {
severity: SeverityChoice;
state: RuleStateChoice;
schema?: AlertSchema;
kind?: RuleKind;
}
const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
@@ -366,34 +305,23 @@ 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: isAnomaly
? RuletypesAlertTypeDTO.METRIC_BASED_ALERT
: seed.alertType,
ruleType: isAnomaly
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
alertType: seed.alertType,
ruleType: RuletypesRuleTypeDTO.threshold_rule,
state,
disabled: state === RuletypesAlertStateDTO.disabled,
condition: isAnomaly ? anomalyCondition(seed) : condition(seed),
condition: 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: isAnomaly ? '1h0m0s' : '5m0s',
evalWindow: '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',

View File

@@ -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 } from 'components/QuickFilters/types';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import RouteTab from 'components/RouteTab';
import { LOCALSTORAGE } from 'constants/localStorage';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
@@ -64,7 +64,8 @@ function AllErrors(): JSX.Element {
onToggleFilters={handleFilterVisibilityChange}
quickFilterProps={{
className: 'qf-exceptions',
quickFilterSource: QuickFiltersSource.EXCEPTIONS,
source: QuickFiltersSource.EXCEPTIONS,
signal: SignalType.EXCEPTIONS,
useFieldApis: quickFilterFieldApis,
}}
>

View File

@@ -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, `classic-form` opts back into the classic form, and everything else
* the picker, anomaly detection routes to the classic form, and everything else
* opens the current one.
*/
const ALERT_MODES = [

Some files were not shown because too many files have changed in this diff Show More