Compare commits

..

3 Commits

Author SHA1 Message Date
Piyush Singariya
e54a555470 feat: delete pipelines 2026-02-25 13:46:03 +05:30
Piyush Singariya
ae55a51d66 feat: update module function 2026-02-24 18:13:32 +05:30
Piyush Singariya
023e315a22 fix: setting up foundation of logs pipelines 2026-02-24 17:05:45 +05:30
39 changed files with 844 additions and 1343 deletions

View File

@@ -54,7 +54,7 @@ jobs:
- sqlite
clickhouse-version:
- 25.5.6
- 25.12.5
- 25.10.5
schema-migrator-version:
- v0.142.0
postgres-version:

View File

@@ -82,12 +82,6 @@ exporters:
timeout: 45s
sending_queue:
enabled: false
metadataexporter:
cache:
provider: in_memory
dsn: tcp://clickhouse:9000/signoz_metadata
enabled: true
timeout: 45s
service:
telemetry:
logs:
@@ -99,19 +93,19 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, metadataexporter, signozmeter]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]

View File

@@ -82,12 +82,6 @@ exporters:
timeout: 45s
sending_queue:
enabled: false
metadataexporter:
cache:
provider: in_memory
dsn: tcp://clickhouse:9000/signoz_metadata
enabled: true
timeout: 45s
service:
telemetry:
logs:
@@ -99,19 +93,19 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, metadataexporter, signozmeter]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]

View File

@@ -308,15 +308,3 @@ export const PublicDashboardPage = Loadable(
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
),
);
export const AlertTypeSelectionPage = Loadable(
() =>
import(
/* webpackChunkName: "Alert Type Selection Page" */ 'pages/AlertTypeSelection'
),
);
export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
);

View File

@@ -1,10 +1,12 @@
import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import AlertTypeSelectionPage from 'pages/AlertTypeSelection';
import MessagingQueues from 'pages/MessagingQueues';
import MeterExplorer from 'pages/MeterExplorer';
import {
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
AllAlertChannels,
AllErrors,
ApiMonitoring,
@@ -27,8 +29,6 @@ import {
LogsExplorer,
LogsIndexToFields,
LogsSaveViews,
MessagingQueuesMainPage,
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
Onboarding,
@@ -399,28 +399,28 @@ const routes: AppRoutes[] = [
{
path: ROUTES.MESSAGING_QUEUES_KAFKA,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_KAFKA',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_CELERY_TASK',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
isPrivate: true,
},
@@ -463,21 +463,21 @@ const routes: AppRoutes[] = [
{
path: ROUTES.METER,
exact: true,
component: MeterExplorerPage,
component: MeterExplorer,
key: 'METER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER,
exact: true,
component: MeterExplorerPage,
component: MeterExplorer,
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER_VIEWS,
exact: true,
component: MeterExplorerPage,
component: MeterExplorer,
key: 'METER_EXPLORER_VIEWS',
isPrivate: true,
},

View File

@@ -21,7 +21,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
export const defaultTableStyle: CSSProperties = {
minWidth: '40rem',
maxWidth: '90rem',
maxWidth: '60rem',
};
export const defaultListViewPanelStyle: CSSProperties = {

View File

@@ -1,17 +1,10 @@
/* eslint-disable sonarjs/no-identical-functions */
import { MemoryRouter, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { getNonIntegrationDashboardById } from 'mocks-server/__mockdata__/dashboards';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import DashboardDescription from '..';
@@ -24,7 +17,6 @@ const DASHBOARD_TITLE_TEXT = 'thor';
const DASHBOARD_PATH = '/dashboard/4';
const mockSafeNavigate = jest.fn();
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -36,11 +28,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useNavigate: (): jest.Mock => mockNavigate,
}));
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
@@ -58,7 +45,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
describe('Dashboard landing page actions header tests', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
mockNavigate.mockClear();
});
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
@@ -138,7 +124,7 @@ describe('Dashboard landing page actions header tests', () => {
await waitFor(() => expect(lockUnlockButton).not.toBeDisabled());
});
it('should navigate back to the dashboard list when clicking the dashboard breadcrumb', async () => {
it('should navigate to dashboard list with correct params and exclude variables', async () => {
const dashboardUrlWithVariables = `${DASHBOARD_PATH}?variables=%7B%22var1%22%3A%22value1%22%7D&otherParam=test`;
const mockLocation = {
pathname: DASHBOARD_PATH,
@@ -147,17 +133,6 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation);
// Simulate having browser history so navigate(-1) fires rather than the
// ROUTES.ALL_DASHBOARD fallback.
const originalDescriptor = Object.getOwnPropertyDescriptor(
window.history,
'length',
);
Object.defineProperty(window.history, 'length', {
configurable: true,
get: (): number => 2,
});
const { getByText } = render(
<MemoryRouter initialEntries={[dashboardUrlWithVariables]}>
<DashboardProvider>
@@ -179,55 +154,27 @@ describe('Dashboard landing page actions header tests', () => {
),
);
// Click the dashboard breadcrumb to navigate back to list
const dashboardButton = getByText('Dashboard /');
await userEvent.click(dashboardButton);
fireEvent.click(dashboardButton);
expect(mockNavigate).toHaveBeenCalledWith(-1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
// Restore original state: if there was no own property before, delete the
// override so window.history.length falls back to the prototype value again.
if (originalDescriptor) {
Object.defineProperty(window.history, 'length', originalDescriptor);
} else {
Reflect.deleteProperty(window.history, 'length');
}
});
it('should navigate to dashboard list page when there is no browser history', async () => {
const mockLocation = {
pathname: DASHBOARD_PATH,
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
// jsdom default: window.history.length === 1, so the fallback fires.
const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardProvider>
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardProvider>
</MemoryRouter>,
// Verify navigation was called with correct URL
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard?columnKey=updatedAt&order=descend&page=1&search=',
);
await waitFor(() =>
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
DASHBOARD_TITLE_TEXT,
),
);
// Ensure the URL contains only essential dashboard list params
const calledUrl = mockSafeNavigate.mock.calls[0][0] as string;
const urlParams = new URLSearchParams(calledUrl.split('?')[1]);
const dashboardButton = getByText('Dashboard /');
await userEvent.click(dashboardButton);
// Should have essential dashboard list params
expect(urlParams.get('columnKey')).toBe('updatedAt');
expect(urlParams.get('order')).toBe('descend');
expect(urlParams.get('page')).toBe('1');
expect(urlParams.get('search')).toBe('');
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.ALL_DASHBOARD);
expect(mockSafeNavigate).not.toHaveBeenCalled();
// Should NOT have variables or other dashboard-specific params
expect(urlParams.has('variables')).toBeFalsy();
expect(urlParams.has('relativeTime')).toBeFalsy();
});
});

View File

@@ -9,7 +9,6 @@ import {
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
@@ -32,9 +31,6 @@ function DashboardVariableSelection(): JSX.Element | null {
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables();
const dashboardId = useDashboardVariablesSelector(
(state) => state.dashboardId,
);
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
@@ -100,28 +96,6 @@ function DashboardVariableSelection(): JSX.Element | null {
updateUrlVariable(name || id, value);
}
// Synchronously update the external store with the new variable value so that
// child variables see the updated parent value when they refetch, rather than
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
const updatedVariables = { ...dashboardVariables };
if (updatedVariables[id]) {
updatedVariables[id] = {
...updatedVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (updatedVariables[name]) {
updatedVariables[name] = {
...updatedVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
setSelectedDashboard((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
@@ -156,12 +130,10 @@ function DashboardVariableSelection(): JSX.Element | null {
return prev;
});
// Cascade: enqueue query-type descendants for refetching.
// Safe to call synchronously now that the store already has the updated value.
// Cascade: enqueue query-type descendants for refetching
enqueueDescendantsOfVariable(name);
},
[
dashboardId,
dashboardVariables,
updateLocalStorageDashboardVariables,
updateUrlVariable,

View File

@@ -5,7 +5,7 @@ import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQ
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isEmpty } from 'lodash-es';
import { isArray, isEmpty, isString } from 'lodash-es';
import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -54,7 +54,7 @@ function QueryVariableInput({
onChange,
onDropdownVisibleChange,
handleClear,
getDefaultValue,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
@@ -68,93 +68,81 @@ function QueryVariableInput({
try {
setErrorMessage(null);
// This is just a check given the previously undefined typed name prop. Not significant
// This will be changed when we change the schema
// TODO: @AshwinBhatkal Perses
if (!variableData.name) {
return;
}
// if the response is not an array, premature return
if (
!variablesRes?.variableValues ||
!Array.isArray(variablesRes?.variableValues)
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
return;
}
const sortedNewOptions = sortValues(
variablesRes.variableValues,
variableData.sort,
);
const sortedOldOptions = sortValues(optionsData, variableData.sort);
// if options are the same as before, no need to update state or check for selected value validity
// ! selectedValue needs to be set in the first pass though, as options are initially empty array and we need to apply default if needed
// Expecatation is that when oldOptions are not empty, then there is always some selectedValue
if (areArraysEqual(sortedNewOptions, sortedOldOptions)) {
return;
}
setOptionsData(sortedNewOptions);
let isSelectedValueMissingInNewOptions = false;
// Check if currently selected value(s) are present in the new options list
if (isArray(variableData.selectedValue)) {
isSelectedValueMissingInNewOptions = variableData.selectedValue.some(
(val) => !sortedNewOptions.includes(val),
const newOptionsData = sortValues(
variablesRes?.variableValues,
variableData.sort,
);
} else if (
variableData.selectedValue &&
!sortedNewOptions.includes(variableData.selectedValue)
) {
isSelectedValueMissingInNewOptions = true;
}
// If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options
// This block does not depend on selected value because of ALL and also because we would only come here if options are different from the previous
if (
variableData.multiSelect &&
variableData.showALLOption &&
variableData.allSelected &&
isSelectedValueMissingInNewOptions
) {
onValueUpdate(variableData.name, variableData.id, sortedNewOptions, true);
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(sortedNewOptions.map((option) => option.toString()));
}
return;
}
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
let valueNotInList = false;
const value = variableData.selectedValue;
let allSelected = false;
if (isArray(variableData.selectedValue)) {
variableData.selectedValue.forEach((val) => {
if (!newOptionsData.includes(val)) {
valueNotInList = true;
}
});
} else if (
isString(variableData.selectedValue) &&
!newOptionsData.includes(variableData.selectedValue)
) {
valueNotInList = true;
}
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
sortedNewOptions.length > 0 &&
Array.isArray(selectedValue) &&
sortedNewOptions.every((option) => selectedValue.includes(option));
}
if (variableData.name && (valueNotInList || variableData.allSelected)) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(
variableData.name,
variableData.id,
newOptionsData,
true,
);
}
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
} else {
const defaultValue = getDefaultValue(sortedNewOptions);
if (defaultValue !== undefined) {
onValueUpdate(
variableData.name,
variableData.id,
defaultValue,
allSelected,
);
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(newOptionsData.map((option) => option.toString()));
}
} else {
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
setOptionsData(newOptionsData);
// Apply default if no value is selected (e.g., new variable, first load)
applyDefaultIfNeeded(newOptionsData);
}
}
} catch (e) {
@@ -167,7 +155,7 @@ function QueryVariableInput({
onValueUpdate,
tempSelection,
setTempSelection,
getDefaultValue,
applyDefaultIfNeeded,
],
);

View File

@@ -1,6 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { act, render } from '@testing-library/react';
import * as dashboardVariablesStoreModule from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
dashboardVariablesStore,
setDashboardVariablesStore,
@@ -11,7 +10,6 @@ import {
IDashboardVariablesStoreState,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
@@ -19,17 +17,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DashboardVariableSelection from '../DashboardVariableSelection';
// Mutable container to capture the onValueUpdate callback from VariableItem
const mockVariableItemCallbacks: {
onValueUpdate?: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
} = {};
// Mock providers/Dashboard/Dashboard
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
@@ -69,14 +56,10 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
}));
// VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests
// Mock VariableItem to avoid rendering complexity
jest.mock('../VariableItem', () => ({
__esModule: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any): JSX.Element => {
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
return <div data-testid="variable-item" />;
},
default: (): JSX.Element => <div data-testid="variable-item" />,
}));
function createVariable(
@@ -217,162 +200,4 @@ describe('DashboardVariableSelection', () => {
expect(initializeVariableFetchStore).not.toHaveBeenCalled();
expect(enqueueFetchOfAllVariables).not.toHaveBeenCalled();
});
describe('onValueUpdate', () => {
let updateStoreSpy: jest.SpyInstance;
beforeEach(() => {
resetStore();
jest.clearAllMocks();
// Real implementation pass-through — we just want to observe calls
updateStoreSpy = jest.spyOn(
dashboardVariablesStoreModule,
'updateDashboardVariablesStore',
);
});
afterEach(() => {
updateStoreSpy.mockRestore();
});
it('updates dashboardVariablesStore synchronously before enqueueDescendantsOfVariable', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
},
});
render(<DashboardVariableSelection />);
const callOrder: string[] = [];
updateStoreSpy.mockImplementation(() => {
callOrder.push('updateDashboardVariablesStore');
});
(enqueueDescendantsOfVariable as jest.Mock).mockImplementation(() => {
callOrder.push('enqueueDescendantsOfVariable');
});
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
expect(callOrder).toEqual([
'updateDashboardVariablesStore',
'enqueueDescendantsOfVariable',
]);
});
it('passes updated variable value to dashboardVariablesStore', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
id: 'env-id',
order: 0,
selectedValue: 'staging',
}),
},
});
render(<DashboardVariableSelection />);
// Clear spy calls that happened during setup/render
updateStoreSpy.mockClear();
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
expect(updateStoreSpy).toHaveBeenCalledWith(
expect.objectContaining({
dashboardId: 'dash-1',
variables: expect.objectContaining({
env: expect.objectContaining({
selectedValue: 'production',
allSelected: false,
}),
}),
}),
);
});
it('calls enqueueDescendantsOfVariable synchronously without a timer', () => {
jest.useFakeTimers();
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
},
});
render(<DashboardVariableSelection />);
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
'production',
false,
);
});
// Must be called immediately — no timer advancement needed
expect(enqueueDescendantsOfVariable).toHaveBeenCalledWith('env');
jest.useRealTimers();
});
it('propagates allSelected and haveCustomValuesSelected to the store', () => {
setDashboardVariablesStore({
dashboardId: 'dash-1',
variables: {
env: createVariable({
name: 'env',
id: 'env-id',
order: 0,
multiSelect: true,
showALLOption: true,
}),
},
});
render(<DashboardVariableSelection />);
updateStoreSpy.mockClear();
act(() => {
mockVariableItemCallbacks.onValueUpdate?.(
'env',
'env-id',
['production', 'staging'],
true,
false,
);
});
expect(updateStoreSpy).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({
env: expect.objectContaining({
selectedValue: ['production', 'staging'],
allSelected: true,
haveCustomValuesSelected: false,
}),
}),
}),
);
});
});
});

View File

@@ -1,275 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, render, waitFor } from '@testing-library/react';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { variableFetchStore } from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import QueryVariableInput from '../QueryVariableInput';
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
}));
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
},
});
}
function Wrapper({
children,
queryClient,
}: {
children: React.ReactNode;
queryClient: QueryClient;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
function createVariable(
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable {
return {
id: 'env-id',
name: 'env',
description: '',
type: 'QUERY',
sort: 'DISABLED',
showALLOption: false,
multiSelect: false,
order: 0,
queryValue: 'SELECT env FROM table',
...overrides,
};
}
/** Put the named variable into 'loading' state so useQuery fires on mount */
function setVariableLoading(name: string): void {
variableFetchStore.update((draft) => {
draft.states[name] = 'loading';
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
});
}
function resetFetchStore(): void {
variableFetchStore.set(() => ({
states: {},
lastUpdated: {},
cycleIds: {},
}));
}
describe('QueryVariableInput - getOptions logic', () => {
const mockOnValueUpdate = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
resetFetchStore();
});
afterEach(() => {
resetFetchStore();
});
it('applies default value (first option) when selectedValue is empty on first load', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging', 'dev'] },
});
const variable = createVariable({ selectedValue: undefined });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
'production', // first option by default
false,
);
});
});
it('keeps existing selectedValue when it is present in new options', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
const variable = createVariable({ selectedValue: 'staging' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
'staging',
false,
);
});
});
it('selects all new options when allSelected=true and value is missing from new options', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
const variable = createVariable({
selectedValue: ['old-env'],
allSelected: true,
multiSelect: true,
showALLOption: true,
});
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'env',
'env-id',
['production', 'staging'],
true,
);
});
});
it('does not call onValueUpdate a second time when options have not changed', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production', 'staging'] },
});
(dashboardVariablesQuery as jest.Mock).mockImplementation(mockQueryFn);
const variable = createVariable({ selectedValue: 'production' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
// Wait for first fetch and onValueUpdate call
await waitFor(() => {
expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
});
mockOnValueUpdate.mockClear();
// Trigger a second fetch cycle with the same API response
act(() => {
variableFetchStore.update((draft) => {
draft.states['env'] = 'revalidating';
draft.cycleIds['env'] = (draft.cycleIds['env'] || 0) + 1;
});
});
// Wait for second query to fire
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
// Options are unchanged, so onValueUpdate must not fire again
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
it('does not call onValueUpdate when API returns a non-array response', async () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: null },
});
const variable = createVariable({ selectedValue: 'production' });
setVariableLoading('env');
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
await waitFor(() => {
expect(dashboardVariablesQuery).toHaveBeenCalled();
});
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
it('does not fire the query when variableData.name is empty', () => {
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
statusCode: 200,
payload: { variableValues: ['production'] },
});
// Variable with no name — useVariableFetchState will be called with ''
// and the query key will have an empty name, leaving it disabled
const variable = createVariable({ name: '' });
// Note: we do NOT put it in 'loading' state since name is empty
// (no variableFetchStore entry for '' means isVariableFetching=false)
const queryClient = createTestQueryClient();
render(
<Wrapper queryClient={queryClient}>
<QueryVariableInput
variableData={variable}
existingVariables={{ 'env-id': variable }}
onValueUpdate={mockOnValueUpdate}
/>
</Wrapper>,
);
expect(dashboardVariablesQuery).not.toHaveBeenCalled();
expect(mockOnValueUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -46,9 +46,6 @@ interface UseDashboardVariableSelectHelperReturn {
applyDefaultIfNeeded: (
overrideOptions?: (string | number | boolean)[],
) => void;
getDefaultValue: (
overrideOptions?: (string | number | boolean)[],
) => string | string[] | undefined;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -251,6 +248,5 @@ export function useDashboardVariableSelectHelper({
defaultValue,
onChange,
applyDefaultIfNeeded,
getDefaultValue,
};
}

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { DashboardData } from 'types/api/dashboard/getAll';
@@ -11,8 +11,8 @@ import { Base64Icons } from '../../DashboardSettings/General/utils';
import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const navigate = useNavigate();
const { selectedDashboard } = useDashboard();
const { safeNavigate } = useSafeNavigate();
const { selectedDashboard, listSortOrder } = useDashboard();
const selectedData = selectedDashboard
? {
@@ -24,12 +24,15 @@ function DashboardBreadcrumbs(): JSX.Element {
const { title = '', image = Base64Icons[0] } = selectedData || {};
const goToListPage = useCallback(() => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate(ROUTES.ALL_DASHBOARD);
}
}, [navigate]);
const urlParams = new URLSearchParams();
urlParams.set('columnKey', listSortOrder.columnKey as string);
urlParams.set('order', listSortOrder.order as string);
urlParams.set('page', listSortOrder.pagination as string);
urlParams.set('search', listSortOrder.search as string);
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
safeNavigate(generatedUrl);
}, [listSortOrder, safeNavigate]);
return (
<div className="dashboard-breadcrumbs">

View File

@@ -45,7 +45,6 @@ import {
} from 'container/DashboardContainer/DashboardDescription/utils';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import dayjs from 'dayjs';
import { useDashboardListSortOrder } from 'hooks/dashboard/useDashboardListSortOrder';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
@@ -77,6 +76,7 @@ import {
// see more: https://github.com/lucide-icons/lucide/issues/94
import { handleContactSupport } from 'pages/Integrations/utils';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import {
@@ -104,19 +104,22 @@ function DashboardsList(): JSX.Element {
const {
data: dashboardListResponse,
isLoading: isDashboardListLoading,
isFetching: isDashboardListFetching,
isRefetching: isDashboardListRefetching,
error: dashboardFetchError,
refetch: refetchDashboardList,
} = useGetAllDashboard();
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { listSortOrder, setListSortOrder } = useDashboardListSortOrder();
const {
listSortOrder: sortOrder,
setListSortOrder: setSortOrder,
} = useDashboard();
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const [searchString, setSearchString] = useState<string>(
listSortOrder.search || '',
sortOrder.search || '',
);
const [action, createNewDashboard] = useComponentPermission(
['action', 'create_new_dashboards'],
@@ -136,6 +139,7 @@ function DashboardsList(): JSX.Element {
] = useState<boolean>(false);
const [uploadedGrafana, setUploadedGrafana] = useState<boolean>(false);
const [isFilteringDashboards, setIsFilteringDashboards] = useState(false);
const [isConfigureMetadataOpen, setIsConfigureMetadata] = useState<boolean>(
false,
);
@@ -183,34 +187,76 @@ function DashboardsList(): JSX.Element {
}
}
const dashboards = useMemo((): Dashboard[] => {
const filtered = filterDashboard(
searchString,
dashboardListResponse?.data || [],
const [dashboards, setDashboards] = useState<Dashboard[]>();
const sortDashboardsByCreatedAt = (dashboards: Dashboard[]): void => {
const sortedDashboards = dashboards.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
if (listSortOrder.columnKey === 'createdAt') {
return filtered.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
return filtered.sort(
setDashboards(sortedDashboards);
};
const sortDashboardsByUpdatedAt = (dashboards: Dashboard[]): void => {
const sortedDashboards = dashboards.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
}, [dashboardListResponse?.data, searchString, listSortOrder.columnKey]);
setDashboards(sortedDashboards);
};
const sortHandle = (key: string): void => {
setListSortOrder({
columnKey: key,
order: 'descend',
pagination: listSortOrder.pagination || '1',
search: listSortOrder.search || '',
});
if (!dashboards) {
return;
}
if (key === 'createdAt') {
sortDashboardsByCreatedAt(dashboards);
setSortOrder({
columnKey: 'createdAt',
order: 'descend',
pagination: sortOrder.pagination || '1',
search: sortOrder.search || '',
});
} else if (key === 'updatedAt') {
sortDashboardsByUpdatedAt(dashboards);
setSortOrder({
columnKey: 'updatedAt',
order: 'descend',
pagination: sortOrder.pagination || '1',
search: sortOrder.search || '',
});
}
};
function handlePageSizeUpdate(page: number): void {
setListSortOrder({ ...listSortOrder, pagination: String(page) });
setSortOrder({ ...sortOrder, pagination: String(page) });
}
useEffect(() => {
const filteredDashboards = filterDashboard(
searchString,
dashboardListResponse?.data || [],
);
if (sortOrder.columnKey === 'updatedAt') {
sortDashboardsByUpdatedAt(filteredDashboards || []);
} else if (sortOrder.columnKey === 'createdAt') {
sortDashboardsByCreatedAt(filteredDashboards || []);
} else if (sortOrder.columnKey === 'null') {
setSortOrder({
columnKey: 'updatedAt',
order: 'descend',
pagination: sortOrder.pagination || '1',
search: sortOrder.search || '',
});
sortDashboardsByUpdatedAt(filteredDashboards || []);
}
}, [
dashboardListResponse,
searchString,
setSortOrder,
sortOrder.columnKey,
sortOrder.pagination,
sortOrder.search,
]);
const [newDashboardState, setNewDashboardState] = useState({
loading: false,
error: false,
@@ -219,25 +265,26 @@ function DashboardsList(): JSX.Element {
const { showErrorModal } = useErrorModal();
const data: Data[] = dashboards.map((e) => ({
createdAt: e.createdAt,
description: e.data.description || '',
id: e.id,
lastUpdatedTime: e.updatedAt,
name: e.data.title,
tags: e.data.tags || [],
key: e.id,
createdBy: e.createdBy,
isLocked: !!e.locked || false,
lastUpdatedBy: e.updatedBy,
image: e.data.image || Base64Icons[0],
variables: e.data.variables,
widgets: e.data.widgets,
layout: e.data.layout,
panelMap: e.data.panelMap,
version: e.data.version,
refetchDashboardList,
}));
const data: Data[] =
dashboards?.map((e) => ({
createdAt: e.createdAt,
description: e.data.description || '',
id: e.id,
lastUpdatedTime: e.updatedAt,
name: e.data.title,
tags: e.data.tags || [],
key: e.id,
createdBy: e.createdBy,
isLocked: !!e.locked || false,
lastUpdatedBy: e.updatedBy,
image: e.data.image || Base64Icons[0],
variables: e.data.variables,
widgets: e.data.widgets,
layout: e.data.layout,
panelMap: e.data.panelMap,
version: e.data.version,
refetchDashboardList,
})) || [];
const onNewDashboardHandler = useCallback(async () => {
try {
@@ -277,9 +324,16 @@ function DashboardsList(): JSX.Element {
};
const handleSearch = (event: ChangeEvent<HTMLInputElement>): void => {
setIsFilteringDashboards(true);
const searchText = (event as React.BaseSyntheticEvent)?.target?.value || '';
const filteredDashboards = filterDashboard(
searchText,
dashboardListResponse?.data || [],
);
setDashboards(filteredDashboards);
setIsFilteringDashboards(false);
setSearchString(searchText);
setListSortOrder({ ...listSortOrder, search: searchText });
setSortOrder({ ...sortOrder, search: searchText });
};
const [state, setCopy] = useCopyToClipboard();
@@ -617,8 +671,8 @@ function DashboardsList(): JSX.Element {
showTotal: showPaginationItem,
showSizeChanger: false,
onChange: (page: any): void => handlePageSizeUpdate(page),
current: Number(listSortOrder.pagination),
defaultCurrent: Number(listSortOrder.pagination) || 1,
current: Number(sortOrder.pagination),
defaultCurrent: Number(sortOrder.pagination) || 1,
hideOnSinglePage: true,
};
@@ -656,7 +710,9 @@ function DashboardsList(): JSX.Element {
)}
</div>
{isDashboardListFetching ? (
{isDashboardListLoading ||
isFilteringDashboards ||
isDashboardListRefetching ? (
<div className="loading-dashboard-details">
<Skeleton.Input active size="large" className="skeleton-1" />
<Skeleton.Input active size="large" className="skeleton-1" />
@@ -693,7 +749,7 @@ function DashboardsList(): JSX.Element {
<ArrowUpRight size={16} className="learn-more-arrow" />
</section>
</div>
) : dashboards.length === 0 && !searchString ? (
) : dashboards?.length === 0 && !searchString ? (
<div className="dashboard-empty-state">
<img
src="/Icons/dashboards.svg"
@@ -775,7 +831,7 @@ function DashboardsList(): JSX.Element {
)}
</div>
{dashboards.length === 0 ? (
{dashboards?.length === 0 ? (
<div className="no-search">
<img src="/Icons/emptyState.svg" alt="img" className="img" />
<Typography.Text className="text">
@@ -804,7 +860,7 @@ function DashboardsList(): JSX.Element {
data-testid="sort-by-last-created"
>
Last created
{listSortOrder.columnKey === 'createdAt' && <Check size={14} />}
{sortOrder.columnKey === 'createdAt' && <Check size={14} />}
</Button>
<Button
type="text"
@@ -813,7 +869,7 @@ function DashboardsList(): JSX.Element {
data-testid="sort-by-last-updated"
>
Last updated
{listSortOrder.columnKey === 'updatedAt' && <Check size={14} />}
{sortOrder.columnKey === 'updatedAt' && <Check size={14} />}
</Button>
</div>
}
@@ -855,7 +911,11 @@ function DashboardsList(): JSX.Element {
columns={columns}
dataSource={data}
showSorterTooltip
loading={isDashboardListFetching}
loading={
isDashboardListLoading ||
isFilteringDashboards ||
isDashboardListRefetching
}
showHeader={false}
pagination={paginationConfig}
/>
@@ -904,12 +964,12 @@ function DashboardsList(): JSX.Element {
<div className="configure-preview">
<section className="header">
<img
src={dashboards[0]?.data?.image || Base64Icons[0]}
src={dashboards?.[0]?.data?.image || Base64Icons[0]}
alt="dashboard-image"
style={{ height: '14px', width: '14px' }}
/>
<Typography.Text className="title">
{dashboards[0]?.data?.title}
{dashboards?.[0]?.data?.title}
</Typography.Text>
</section>
<section className="details">
@@ -917,16 +977,16 @@ function DashboardsList(): JSX.Element {
{visibleColumns.createdAt && (
<Typography.Text className="formatted-time">
<CalendarClock size={14} />
{getFormattedTime(dashboards[0] as Dashboard, 'created_at')}
{getFormattedTime(dashboards?.[0] as Dashboard, 'created_at')}
</Typography.Text>
)}
{visibleColumns.createdBy && (
<div className="user">
<Typography.Text className="user-tag">
{dashboards[0]?.createdBy?.substring(0, 1).toUpperCase()}
{dashboards?.[0]?.createdBy?.substring(0, 1).toUpperCase()}
</Typography.Text>
<Typography.Text className="dashboard-created-by">
{dashboards[0]?.createdBy}
{dashboards?.[0]?.createdBy}
</Typography.Text>
</div>
)}
@@ -935,16 +995,16 @@ function DashboardsList(): JSX.Element {
{visibleColumns.updatedAt && (
<Typography.Text className="formatted-time">
<CalendarClock size={14} />
{onLastUpdated(dashboards[0]?.updatedAt || '')}
{onLastUpdated(dashboards?.[0]?.updatedAt || '')}
</Typography.Text>
)}
{visibleColumns.updatedBy && (
<div className="user">
<Typography.Text className="user-tag">
{dashboards[0]?.updatedBy?.substring(0, 1).toUpperCase()}
{dashboards?.[0]?.updatedBy?.substring(0, 1).toUpperCase()}
</Typography.Text>
<Typography.Text className="dashboard-created-by">
{dashboards[0]?.updatedBy}
{dashboards?.[0]?.updatedBy}
</Typography.Text>
</div>
)}

View File

@@ -1,7 +1,6 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { ReactNode, useState } from 'react';
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import type { InputRef } from 'antd';
import {
Button,
Collapse,
@@ -47,23 +46,12 @@ function Overview({
handleChangeSelectedView,
}: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
const [isAttributesExpanded, setIsAttributesExpanded] = useState<boolean>(
true,
);
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
const focusTimerRef = useRef<ReturnType<typeof setTimeout>>();
const searchInputRef = useCallback((node: InputRef | null) => {
clearTimeout(focusTimerRef.current);
if (node) {
focusTimerRef.current = setTimeout(() => node.focus(), 100);
}
}, []);
useEffect(() => (): void => clearTimeout(focusTimerRef.current), []);
const isDarkMode = useIsDarkMode();
const options: EditorProps['options'] = {
@@ -208,7 +196,7 @@ function Overview({
<>
{isSearchVisible && (
<Input
ref={searchInputRef}
autoFocus
placeholder="Search for a field..."
className="search-input"
value={fieldSearchInput}

View File

@@ -1,34 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { getColorsForSeverityLabels, isRedLike } from '../utils';
describe('getColorsForSeverityLabels', () => {
it('should return slate for blank labels', () => {
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
});
it('should return correct colors for known severity variants', () => {
expect(getColorsForSeverityLabels('INFO', 0)).toBe(Color.BG_ROBIN_600);
expect(getColorsForSeverityLabels('ERROR', 0)).toBe(Color.BG_CHERRY_600);
expect(getColorsForSeverityLabels('WARN', 0)).toBe(Color.BG_AMBER_600);
expect(getColorsForSeverityLabels('DEBUG', 0)).toBe(Color.BG_AQUA_600);
expect(getColorsForSeverityLabels('TRACE', 0)).toBe(Color.BG_FOREST_600);
expect(getColorsForSeverityLabels('FATAL', 0)).toBe(Color.BG_SAKURA_600);
});
it('should return non-red colors for unrecognized labels at any index', () => {
for (let i = 0; i < 30; i++) {
const color = getColorsForSeverityLabels('4', i);
expect(isRedLike(color)).toBe(false);
}
});
it('should return non-red colors for numeric severity text', () => {
const numericLabels = ['1', '2', '4', '9', '13', '17', '21'];
numericLabels.forEach((label) => {
const color = getColorsForSeverityLabels(label, 0);
expect(isRedLike(color)).toBe(false);
});
});
});

View File

@@ -1,16 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import { themeColors } from 'constants/theme';
import { colors } from 'lib/getRandomColor';
// Function to determine if a color is "red-like" based on its RGB values
export function isRedLike(hex: string): boolean {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return r > 180 && r > g * 1.4 && r > b * 1.4;
}
const SAFE_FALLBACK_COLORS = colors.filter((c) => !isRedLike(c));
const SEVERITY_VARIANT_COLORS: Record<string, string> = {
TRACE: Color.BG_FOREST_600,
Trace: Color.BG_FOREST_500,
@@ -76,13 +67,8 @@ export function getColorsForSeverityLabels(
label: string,
index: number,
): string {
const trimmed = label.trim();
if (!trimmed) {
return Color.BG_SLATE_300;
}
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
// Check if we have a direct mapping for this severity variant
const variantColor = SEVERITY_VARIANT_COLORS[label.trim()];
if (variantColor) {
return variantColor;
}
@@ -117,8 +103,5 @@ export function getColorsForSeverityLabels(
return Color.BG_SAKURA_500;
}
return (
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
Color.BG_SLATE_400
);
return colors[index % colors.length] || themeColors.red;
}

View File

@@ -111,19 +111,23 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
);
const itemContent = useCallback(
(index: number, log: Record<string, unknown>): JSX.Element => (
<TableRow
tableColumns={tableColumns}
index={index}
log={log}
logs={tableViewProps.logs}
hasActions
fontSize={tableViewProps.fontSize}
onShowLogDetails={onSetActiveLog}
isActiveLog={activeLog?.id === log.id}
onClearActiveLog={onCloseActiveLog}
/>
),
(index: number, log: Record<string, unknown>): JSX.Element => {
return (
<div key={log.id as string}>
<TableRow
tableColumns={tableColumns}
index={index}
log={log}
logs={tableViewProps.logs}
hasActions
fontSize={tableViewProps.fontSize}
onShowLogDetails={onSetActiveLog}
isActiveLog={activeLog?.id === log.id}
onClearActiveLog={onCloseActiveLog}
/>
</div>
);
},
[
tableColumns,
onSetActiveLog,
@@ -139,8 +143,7 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
{tableColumns
.filter((column) => column.key)
.map((column) => {
const isDragColumn =
column.key !== 'expand' && column.key !== 'state-indicator';
const isDragColumn = column.key !== 'expand';
return (
<TableHeaderCellStyled

View File

@@ -1,55 +0,0 @@
import { useState } from 'react';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import isEqual from 'lodash-es/isEqual';
export interface DashboardSortOrder {
columnKey: string;
order: string;
pagination: string;
search: string;
}
const SUPPORTED_COLUMN_KEYS = ['createdAt', 'updatedAt'];
const SUPPORTED_ORDER_KEYS = ['ascend', 'descend'];
export function useDashboardListSortOrder(): {
listSortOrder: DashboardSortOrder;
setListSortOrder: (sortOrder: DashboardSortOrder) => void;
} {
const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery();
const orderColumnParam = params.get('columnKey');
const orderQueryParam = params.get('order');
const paginationParam = params.get('page');
const searchParam = params.get('search');
const [listSortOrder, setListOrder] = useState<DashboardSortOrder>({
columnKey:
orderColumnParam && SUPPORTED_COLUMN_KEYS.includes(orderColumnParam)
? orderColumnParam
: 'updatedAt',
order:
orderQueryParam && SUPPORTED_ORDER_KEYS.includes(orderQueryParam)
? orderQueryParam
: 'descend',
pagination: paginationParam || '1',
search: searchParam || '',
});
function setListSortOrder(sortOrder: DashboardSortOrder): void {
if (!isEqual(sortOrder, listSortOrder)) {
setListOrder(sortOrder);
}
params.set('columnKey', sortOrder.columnKey);
params.set('order', sortOrder.order);
params.set('page', sortOrder.pagination || '1');
params.set('search', sortOrder.search || '');
safeNavigate({ search: params.toString() });
}
return { listSortOrder, setListSortOrder };
}

View File

@@ -1,94 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsTextSelected } from 'hooks/useIsTextSelected';
import { ILog } from 'types/api/logs/log';
import useLogDetailHandlers from '../useLogDetailHandlers';
jest.mock('hooks/logs/useActiveLog');
jest.mock('hooks/useIsTextSelected');
const mockOnSetActiveLog = jest.fn();
const mockOnClearActiveLog = jest.fn();
const mockOnAddToQuery = jest.fn();
const mockOnGroupByAttribute = jest.fn();
const mockIsTextSelected = jest.fn();
const mockLog: ILog = {
id: 'log-1',
timestamp: '2024-01-01T00:00:00Z',
date: '2024-01-01',
body: 'test log body',
severityText: 'INFO',
severityNumber: 9,
traceFlags: 0,
traceId: '',
spanID: '',
attributesString: {},
attributesInt: {},
attributesFloat: {},
resources_string: {},
scope_string: {},
attributes_string: {},
severity_text: '',
severity_number: 0,
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useIsTextSelected).mockReturnValue(mockIsTextSelected);
jest.mocked(useActiveLog).mockReturnValue({
activeLog: null,
onSetActiveLog: mockOnSetActiveLog,
onClearActiveLog: mockOnClearActiveLog,
onAddToQuery: mockOnAddToQuery,
onGroupByAttribute: mockOnGroupByAttribute,
});
});
it('should not open log detail when text is selected', () => {
mockIsTextSelected.mockReturnValue(true);
const { result } = renderHook(() => useLogDetailHandlers());
act(() => {
result.current.handleSetActiveLog(mockLog);
});
expect(mockOnSetActiveLog).not.toHaveBeenCalled();
});
it('should open log detail when no text is selected', () => {
mockIsTextSelected.mockReturnValue(false);
const { result } = renderHook(() => useLogDetailHandlers());
act(() => {
result.current.handleSetActiveLog(mockLog);
});
expect(mockOnSetActiveLog).toHaveBeenCalledWith(mockLog);
});
it('should toggle off when clicking the same active log', () => {
mockIsTextSelected.mockReturnValue(false);
jest.mocked(useActiveLog).mockReturnValue({
activeLog: mockLog,
onSetActiveLog: mockOnSetActiveLog,
onClearActiveLog: mockOnClearActiveLog,
onAddToQuery: mockOnAddToQuery,
onGroupByAttribute: mockOnGroupByAttribute,
});
const { result } = renderHook(() => useLogDetailHandlers());
act(() => {
result.current.handleSetActiveLog(mockLog);
});
expect(mockOnClearActiveLog).toHaveBeenCalled();
expect(mockOnSetActiveLog).not.toHaveBeenCalled();
});

View File

@@ -2,7 +2,6 @@ import { useCallback, useState } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import type { UseActiveLog } from 'hooks/logs/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsTextSelected } from 'hooks/useIsTextSelected';
import { ILog } from 'types/api/logs/log';
type SelectedTab = typeof VIEW_TYPES[keyof typeof VIEW_TYPES] | undefined;
@@ -29,13 +28,9 @@ function useLogDetailHandlers({
onAddToQuery,
} = useActiveLog();
const [selectedTab, setSelectedTab] = useState<SelectedTab>(defaultTab);
const isTextSelected = useIsTextSelected();
const handleSetActiveLog = useCallback(
(log: ILog, nextTab: SelectedTab = defaultTab): void => {
if (isTextSelected()) {
return;
}
if (activeLog?.id === log.id) {
onClearActiveLog();
setSelectedTab(undefined);
@@ -44,7 +39,7 @@ function useLogDetailHandlers({
onSetActiveLog(log);
setSelectedTab(nextTab ?? defaultTab);
},
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog, isTextSelected],
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog],
);
const handleCloseLogDetail = useCallback((): void => {

View File

@@ -1,10 +0,0 @@
import { useCallback } from 'react';
export function useIsTextSelected(): () => boolean {
return useCallback((): boolean => {
const selection = window.getSelection();
return (
!!selection && !selection.isCollapsed && selection.toString().length > 0
);
}, []);
}

View File

@@ -22,7 +22,9 @@ import ROUTES from 'constants/routes';
import dayjs, { Dayjs } from 'dayjs';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useTabVisibility from 'hooks/useTabFocus';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { defaultTo, isEmpty } from 'lodash-es';
@@ -48,7 +50,11 @@ import {
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from './store/dashboardVariables/dashboardVariablesStore';
import { IDashboardContext, WidgetColumnWidths } from './types';
import {
DashboardSortOrder,
IDashboardContext,
WidgetColumnWidths,
} from './types';
import { sortLayout } from './util';
const DashboardContext = createContext<IDashboardContext>({
@@ -65,7 +71,13 @@ const DashboardContext = createContext<IDashboardContext>({
layouts: [],
panelMap: {},
setPanelMap: () => {},
listSortOrder: {
columnKey: 'createdAt',
order: 'descend',
pagination: '1',
search: '',
},
setListSortOrder: () => {},
setLayouts: () => {},
setSelectedDashboard: () => {},
updatedTimeRef: {} as React.MutableRefObject<Dayjs | null>,
@@ -89,6 +101,7 @@ interface Props {
export function DashboardProvider({
children,
}: PropsWithChildren): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
const [toScrollWidgetId, setToScrollWidgetId] = useState<string>('');
@@ -109,8 +122,52 @@ export function DashboardProvider({
exact: true,
});
const isDashboardListPage = useRouteMatch<Props>({
path: ROUTES.ALL_DASHBOARD,
exact: true,
});
const { showErrorModal } = useErrorModal();
// added extra checks here in case wrong values appear use the default values rather than empty dashboards
const supportedOrderColumnKeys = ['createdAt', 'updatedAt'];
const supportedOrderKeys = ['ascend', 'descend'];
const params = useUrlQuery();
// since the dashboard provider is wrapped at the very top of the application hence it initialises these values from other pages as well.
// pick the below params from URL only if the user is on the dashboards list page.
const orderColumnParam = isDashboardListPage && params.get('columnKey');
const orderQueryParam = isDashboardListPage && params.get('order');
const paginationParam = isDashboardListPage && params.get('page');
const searchParam = isDashboardListPage && params.get('search');
const [listSortOrder, setListOrder] = useState({
columnKey: orderColumnParam
? supportedOrderColumnKeys.includes(orderColumnParam)
? orderColumnParam
: 'updatedAt'
: 'updatedAt',
order: orderQueryParam
? supportedOrderKeys.includes(orderQueryParam)
? orderQueryParam
: 'descend'
: 'descend',
pagination: paginationParam || '1',
search: searchParam || '',
});
function setListSortOrder(sortOrder: DashboardSortOrder): void {
if (!isEqual(sortOrder, listSortOrder)) {
setListOrder(sortOrder);
}
params.set('columnKey', sortOrder.columnKey as string);
params.set('order', sortOrder.order as string);
params.set('page', sortOrder.pagination || '1');
params.set('search', sortOrder.search || '');
safeNavigate({ search: params.toString() });
}
const dispatch = useDispatch<Dispatch<AppActions>>();
const globalTime = useSelector<AppState, GlobalReducer>(
@@ -445,6 +502,8 @@ export function DashboardProvider({
selectedDashboard,
dashboardId,
layouts,
listSortOrder,
setListSortOrder,
panelMap,
setLayouts,
setPanelMap,
@@ -468,6 +527,8 @@ export function DashboardProvider({
selectedDashboard,
dashboardId,
layouts,
listSortOrder,
setListSortOrder,
panelMap,
toScrollWidgetId,
updateLocalStorageDashboardVariables,

View File

@@ -4,6 +4,13 @@ import dayjs from 'dayjs';
import { SuccessResponseV2 } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
export interface DashboardSortOrder {
columnKey: string;
order: string;
pagination: string;
search: string;
}
export type WidgetColumnWidths = {
[widgetId: string]: Record<string, number>;
};
@@ -19,6 +26,8 @@ export interface IDashboardContext {
layouts: Layout[];
panelMap: Record<string, { widgets: Layout[]; collapsed: boolean }>;
setPanelMap: React.Dispatch<React.SetStateAction<Record<string, any>>>;
listSortOrder: DashboardSortOrder;
setListSortOrder: (sortOrder: DashboardSortOrder) => void;
setLayouts: React.Dispatch<React.SetStateAction<Layout[]>>;
setSelectedDashboard: React.Dispatch<
React.SetStateAction<Dashboard | undefined>

View File

@@ -0,0 +1,12 @@
package signozapiserver
import (
"net/http"
"github.com/gorilla/mux"
)
func (provider *provider) addLogspipelineRoutes(router *mux.Router) error {
router.HandleFunc("/api/v2/pipelines", provider.logspipelineHandler.ListPipelines).Methods(http.MethodGet)
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
@@ -48,6 +49,7 @@ type provider struct {
authzHandler authz.Handler
zeusHandler zeus.Handler
querierHandler querier.Handler
logspipelineHandler logspipeline.Handler
}
func NewFactory(
@@ -69,6 +71,7 @@ func NewFactory(
authzHandler authz.Handler,
zeusHandler zeus.Handler,
querierHandler querier.Handler,
logspipelineHandler logspipeline.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -93,6 +96,7 @@ func NewFactory(
authzHandler,
zeusHandler,
querierHandler,
logspipelineHandler,
)
})
}
@@ -119,6 +123,7 @@ func newProvider(
authzHandler authz.Handler,
zeusHandler zeus.Handler,
querierHandler querier.Handler,
logspipelineHandler logspipeline.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -143,6 +148,7 @@ func newProvider(
authzHandler: authzHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
logspipelineHandler: logspipelineHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -223,9 +229,14 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addLogspipelineRoutes(router); err != nil {
return err
}
return nil
}
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
return []handler.OpenAPISecurityScheme{
{Name: ctxtypes.AuthTypeAPIKey.StringValue(), Scopes: []string{role.String()}},

View File

@@ -0,0 +1,91 @@
package impllogspipeline
import (
"net/http"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct {
module logspipeline.Module
}
func NewHandler(module logspipeline.Module) logspipeline.Handler {
return &handler{module: module}
}
func (h *handler) ListPipelines(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID, errv2 := valuer.NewUUID(claims.OrgID)
if errv2 != nil {
render.Error(w, errv2)
return
}
version, err := ParseAgentConfigVersion(r)
if err != nil {
render.Error(w, err)
return
}
if version != -1 {
pipelines, err := h.module.ListPipelinesByVersion(r.Context(), orgID, version)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, pipelines)
return
}
pipelines, err := h.module.ListPipelines(r.Context(), orgID)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, pipelines)
}
func (h *handler) GetPipeline(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) CreatePipeline(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) UpdatePipeline(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) DeletePipeline(w http.ResponseWriter, r *http.Request) {
}
func ParseAgentConfigVersion(r *http.Request) (int, error) {
versionString := mux.Vars(r)["version"]
if versionString == "latest" {
return -1, nil
}
version64, err := strconv.ParseInt(versionString, 0, 8)
if err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid version number")
}
if version64 <= 0 {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid version number")
}
return int(version64), nil
}

View File

@@ -0,0 +1,322 @@
package impllogspipeline
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"go.uber.org/zap"
)
type module struct {
sqlstore sqlstore.SQLStore
}
func NewModule(sqlstore sqlstore.SQLStore) logspipeline.Module {
return &module{sqlstore: sqlstore}
}
func (m *module) ListPipelines(ctx context.Context, orgID valuer.UUID) ([]pipelinetypes.GettablePipeline, error) {
latestVersion := -1
// get latest agent config
lastestConfig, err := agentConf.GetLatestVersion(ctx, orgID, opamptypes.ElementTypeLogPipelines)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if lastestConfig != nil {
latestVersion = lastestConfig.Version
}
return m.ListPipelinesByVersion(ctx, orgID, latestVersion)
}
func (m *module) ListPipelinesByVersion(ctx context.Context, orgID valuer.UUID, version int) ([]pipelinetypes.GettablePipeline, error) {
var stored []pipelinetypes.StoreablePipeline
err := m.sqlstore.BunDB().NewSelect().
Model(&stored).
Join("JOIN agent_config_element e ON p.id = e.element_id").
Join("JOIN agent_config_version v ON v.id = e.version_id").
Where("e.element_type = ?", opamptypes.ElementTypeLogPipelines.StringValue()).
Where("v.version = ?", version).
Where("v.org_id = ?", orgID.StringValue()).
Order("p.order_id ASC").
Scan(ctx)
if err != nil {
return nil, err
}
pipelines := make([]pipelinetypes.GettablePipeline, len(stored))
if len(stored) == 0 {
return pipelines, nil
}
for i := range stored {
pipelines[i].StoreablePipeline = stored[i]
if err := pipelines[i].ParseRawConfig(); err != nil {
return nil, err
}
if err := pipelines[i].ParseFilter(); err != nil {
return nil, err
}
}
return pipelines, nil
}
func (m *module) GetPipeline(ctx context.Context, orgID valuer.UUID, id string) (*pipelinetypes.GettablePipeline, error) {
return nil, nil
}
func (m *module) CreatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error) {
storeable, err := pipeline.ToStoreablePipeline()
if err != nil {
return nil, err
}
// regenerate the id and set other fields
storeable.Identifiable.ID = valuer.GenerateUUID()
storeable.OrgID = orgID.String()
storeable.TimeAuditable = types.TimeAuditable{
CreatedAt: time.Now(),
}
storeable.UserAuditable = types.UserAuditable{
CreatedBy: claims.Email,
}
_, err = m.sqlstore.BunDB().NewInsert().
Model(&storeable).
Exec(ctx)
if err != nil {
zap.L().Error("error in inserting pipeline data", zap.Error(err))
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to insert pipeline")
}
return &pipelinetypes.GettablePipeline{
StoreablePipeline: *storeable,
Filter: pipeline.Filter,
Config: pipeline.Config,
}, nil
}
func (m *module) UpdatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error) {
if err := pipeline.IsValid(); err != nil {
return nil, err
}
storeable, err := pipeline.ToStoreablePipeline()
if err != nil {
return nil, err
}
storeable.OrgID = orgID.String()
storeable.TimeAuditable = types.TimeAuditable{
UpdatedAt: time.Now(),
}
storeable.UserAuditable = types.UserAuditable{
UpdatedBy: claims.Email,
}
// get id from storeable pipeline
id := storeable.ID.StringValue()
// depending on the order_id update the rest of the table
// example 1: total available pipelines are 6, and order_id 5 is moved to 2, then we need to update the rest of the table
// old: 1, 2, 3, 4, 5, 6
// ^ |
// |_________|
// So pipelines starting from 2nd position till 4th position shift to right (or increase their order_id) by 1 position
// example 2: total available pipelines are 6, and order_id 2 is moved to 4, then we need to update the rest of the table
// old: 1, 2, 3, 4, 5, 6
// | ^
// |_____|
// So pipelines starting from 3rd position till 4th position shift to left (or decrease their order_id) by 1 position
if err := m.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
db := m.sqlstore.BunDBCtx(ctx)
var existing pipelinetypes.StoreablePipeline
if err := db.NewSelect().
Column("order_id", "enabled").
Model(&existing).
Where("id = ?", id).
Where("org_id = ?", orgID.StringValue()).
Scan(ctx); err != nil {
return m.sqlstore.WrapNotFoundErrf(
err,
errors.CodeNotFound,
"pipeline with id %s does not exist in org %s",
id,
orgID.StringValue(),
)
}
oldOrderID := existing.OrderID
newOrderID := storeable.OrderID
// Reorder other pipelines if the order has changed.
if newOrderID != oldOrderID {
if err := reorderPipelinesInTx(ctx, db, orgID.StringValue(), oldOrderID, newOrderID); err != nil {
return err
}
}
// Preserve primary key and immutable fields.
storeable.ID = existing.ID
// Persist the updated pipeline (including its new order).
if _, err := db.NewUpdate().
Model(storeable).
Where("id = ?", id).
Where("org_id = ?", orgID.StringValue()).
Exec(ctx); err != nil {
return err
}
// Apply pipelines if the enabled state has changed
if existing.Enabled != storeable.Enabled {
if err := m.applyPipelinesInTx(ctx, orgID, claims, db); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return &pipelinetypes.GettablePipeline{
StoreablePipeline: *storeable,
Filter: pipeline.Filter,
Config: pipeline.Config,
}, nil
}
func (m *module) applyPipelinesInTx(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, tx bun.IDB) error {
// Get ids pipelines for the given org
var pipelines []pipelinetypes.StoreablePipeline
if err := tx.NewSelect().
Column("id").
Model(&pipelines).
Where("org_id = ?", orgID.StringValue()).
Scan(ctx); err != nil {
return m.sqlstore.WrapNotFoundErrf(
err,
errors.CodeNotFound,
"no pipelines found for org %s",
orgID.StringValue(),
)
}
// prepare config elements
elements := make([]string, len(pipelines))
for i, p := range pipelines {
elements[i] = p.ID.StringValue()
}
cfg, err := agentConf.StartNewVersion(ctx, orgID, valuer.MustNewUUID(claims.UserID), opamptypes.ElementTypeLogPipelines, elements)
if err != nil || cfg == nil {
return errors.WithAdditionalf(err, "failed to start new version for org %s", orgID.StringValue())
}
return nil
}
// reorderPipelinesInTx updates order_id of other pipelines in a transaction-aware way.
// It assumes that all pipelines for a given org have consecutive order_id values starting from 1.
// The logic is:
// - When moving a pipeline from a higher position to a lower position (e.g., 5 -> 2),
// all pipelines in [newOrderID, oldOrderID) are shifted right by +1.
// - When moving from a lower position to a higher position (e.g., 2 -> 4),
// all pipelines in (oldOrderID, newOrderID] are shifted left by -1.
func reorderPipelinesInTx(ctx context.Context, tx bun.IDB, orgID string, oldOrderID, newOrderID int) error {
switch {
case newOrderID < oldOrderID:
// Move up: shift affected pipelines down (order_id + 1).
_, err := tx.NewUpdate().
Model((*pipelinetypes.StoreablePipeline)(nil)).
Set("order_id = order_id + 1").
Where("org_id = ?", orgID).
Where("order_id >= ?", newOrderID).
Where("order_id < ?", oldOrderID).
Exec(ctx)
return err
case newOrderID > oldOrderID:
// Move down: shift affected pipelines up (order_id - 1).
_, err := tx.NewUpdate().
Model((*pipelinetypes.StoreablePipeline)(nil)).
Set("order_id = order_id - 1").
Where("org_id = ?", orgID).
Where("order_id > ?", oldOrderID).
Where("order_id <= ?", newOrderID).
Exec(ctx)
return err
default:
return nil
}
}
func (m *module) DeletePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) error {
if err := pipeline.IsValid(); err != nil {
return err
}
if err := m.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
db := m.sqlstore.BunDBCtx(ctx)
// Fetch existing pipeline to determine its current order_id.
var existing pipelinetypes.StoreablePipeline
if err := db.NewSelect().
Model(&existing).
Column("order_id", "enabled").
Where("id = ?", pipeline.ID).
Where("org_id = ?", orgID.StringValue()).
Scan(ctx); err != nil {
return m.sqlstore.WrapNotFoundErrf(
err,
errors.CodeNotFound,
"pipeline with id %s does not exist in org %s",
pipeline.ID,
orgID.StringValue(),
)
}
if _, err := db.NewDelete().
Model((*pipelinetypes.StoreablePipeline)(nil)).
Where("id = ?", pipeline.ID).
Where("org_id = ?", orgID.StringValue()).
Exec(ctx); err != nil {
return err
}
// Set order_ids of other pipelines by collapsing the gap left by the deleted pipeline.
if _, err := db.NewUpdate().
Model((*pipelinetypes.StoreablePipeline)(nil)).
Set("order_id = order_id - 1").
Where("org_id = ?", orgID.StringValue()).
Where("order_id > ?", existing.OrderID).
Exec(ctx); err != nil {
return err
}
// Apply pipelines if the deleted pipeline was enabled
if existing.Enabled {
if err := m.applyPipelinesInTx(ctx, orgID, claims, db); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,27 @@
package logspipeline
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
ListPipelines(ctx context.Context, orgID valuer.UUID) ([]pipelinetypes.GettablePipeline, error)
ListPipelinesByVersion(ctx context.Context, orgID valuer.UUID, version int) ([]pipelinetypes.GettablePipeline, error)
GetPipeline(ctx context.Context, orgID valuer.UUID, id string) (*pipelinetypes.GettablePipeline, error)
CreatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error)
UpdatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error)
DeletePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) error
}
type Handler interface {
ListPipelines(w http.ResponseWriter, r *http.Request)
GetPipeline(w http.ResponseWriter, r *http.Request)
CreatePipeline(w http.ResponseWriter, r *http.Request)
UpdatePipeline(w http.ResponseWriter, r *http.Request)
DeletePipeline(w http.ResponseWriter, r *http.Request)
}

View File

@@ -120,8 +120,6 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
}
}
resultData.Rows = filteredRows
case *qbtypes.ScalarData:
resultData.Data = filterScalarDataIPs(resultData.Columns, resultData.Data)
}
filteredData = append(filteredData, result)
@@ -147,39 +145,6 @@ func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
return true
}
func filterScalarDataIPs(columns []*qbtypes.ColumnDescriptor, data [][]any) [][]any {
// Find column indices for server address fields
serverColIndices := make([]int, 0)
for i, col := range columns {
if col.Name == derivedKeyHTTPHost {
serverColIndices = append(serverColIndices, i)
}
}
if len(serverColIndices) == 0 {
return data
}
filtered := make([][]any, 0, len(data))
for _, row := range data {
includeRow := true
for _, colIdx := range serverColIndices {
if colIdx < len(row) {
if strVal, ok := row[colIdx].(string); ok {
if net.ParseIP(strVal) != nil {
includeRow = false
break
}
}
}
}
if includeRow {
filtered = append(filtered, row)
}
}
return filtered
}
func shouldIncludeRow(row *qbtypes.RawRow) bool {
if row.Data != nil {
if domainVal, ok := row.Data[derivedKeyHTTPHost]; ok {

View File

@@ -117,59 +117,6 @@ func TestFilterResponse(t *testing.T) {
},
},
},
{
name: "should filter out IP addresses from scalar data",
input: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"192.168.1.1", 10},
{"example.com", 20},
{"10.0.0.1", 5},
},
},
},
},
},
},
expected: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"example.com", 20},
},
},
},
},
},
},
},
}
for _, tt := range tests {

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/logspipeline/impllogspipeline"
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -4048,26 +4049,6 @@ func (aH *APIHandler) logAggregate(w http.ResponseWriter, r *http.Request) {
aH.WriteJSON(w, r, model.GetLogsAggregatesResponse{})
}
func parseAgentConfigVersion(r *http.Request) (int, error) {
versionString := mux.Vars(r)["version"]
if versionString == "latest" {
return -1, nil
}
version64, err := strconv.ParseInt(versionString, 0, 8)
if err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid version number")
}
if version64 <= 0 {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid version number")
}
return int(version64), nil
}
func (aH *APIHandler) PreviewLogsPipelinesHandler(w http.ResponseWriter, r *http.Request) {
req := logparsingpipeline.PipelinesPreviewRequest{}
@@ -4098,7 +4079,7 @@ func (aH *APIHandler) ListLogsPipelinesHandler(w http.ResponseWriter, r *http.Re
return
}
version, err := parseAgentConfigVersion(r)
version, err := impllogspipeline.ParseAgentConfigVersion(r)
if err != nil {
render.Error(w, err)
return

View File

@@ -13,6 +13,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
"github.com/SigNoz/signoz/pkg/modules/logspipeline/impllogspipeline"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -66,6 +68,7 @@ type Modules struct {
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
Promote promote.Module
LogsPipeline logspipeline.Module
}
func NewModules(
@@ -110,5 +113,6 @@ func NewModules(
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
LogsPipeline: impllogspipeline.NewModule(sqlstore),
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
@@ -59,6 +60,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ authz.Handler }{},
struct{ zeus.Handler }{},
struct{ querier.Handler }{},
struct{ logspipeline.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -23,6 +23,7 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/global/signozglobal"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/logspipeline/impllogspipeline"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
@@ -169,7 +170,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddAnonymousPublicDashboardTransactionFactory(sqlstore),
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
)
}
@@ -255,6 +255,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.AuthzHandler,
handlers.ZeusHandler,
handlers.QuerierHandler,
impllogspipeline.NewHandler(modules.LogsPipeline),
),
)
}

View File

@@ -1,209 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateRulesV4ToV5 struct {
store sqlstore.SQLStore
telemetryStore telemetrystore.TelemetryStore
logger *slog.Logger
}
func NewMigrateRulesV4ToV5Factory(
store sqlstore.SQLStore,
telemetryStore telemetrystore.TelemetryStore,
) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_rules_post_deprecation"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRulesV4ToV5{
store: store,
telemetryStore: telemetryStore,
logger: ps.Logger,
}, nil
})
}
func (migration *migrateRulesV4ToV5) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *migrateRulesV4ToV5) getLogDuplicateKeys(ctx context.Context) ([]string, error) {
query := `
SELECT name
FROM (
SELECT DISTINCT name FROM signoz_logs.distributed_logs_attribute_keys
INTERSECT
SELECT DISTINCT name FROM signoz_logs.distributed_logs_resource_keys
)
ORDER BY name
`
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
if err != nil {
migration.logger.WarnContext(ctx, "failed to query log duplicate keys", "error", err)
return nil, nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
migration.logger.WarnContext(ctx, "failed to scan log duplicate key", "error", err)
continue
}
keys = append(keys, key)
}
return keys, nil
}
func (migration *migrateRulesV4ToV5) getTraceDuplicateKeys(ctx context.Context) ([]string, error) {
query := `
SELECT tagKey
FROM signoz_traces.distributed_span_attributes_keys
WHERE tagType IN ('tag', 'resource')
GROUP BY tagKey
HAVING COUNT(DISTINCT tagType) > 1
ORDER BY tagKey
`
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
if err != nil {
migration.logger.WarnContext(ctx, "failed to query trace duplicate keys", "error", err)
return nil, nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
migration.logger.WarnContext(ctx, "failed to scan trace duplicate key", "error", err)
continue
}
keys = append(keys, key)
}
return keys, nil
}
func (migration *migrateRulesV4ToV5) Up(ctx context.Context, db *bun.DB) error {
logsKeys, err := migration.getLogDuplicateKeys(ctx)
if err != nil {
return err
}
tracesKeys, err := migration.getTraceDuplicateKeys(ctx)
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var rules []struct {
ID string `bun:"id"`
Data map[string]any `bun:"data"`
}
err = tx.NewSelect().
Table("rule").
Column("id", "data").
Scan(ctx, &rules)
if err != nil {
if err == sql.ErrNoRows {
return nil
}
return err
}
alertsMigrator := transition.NewAlertMigrateV5(migration.logger, logsKeys, tracesKeys)
count := 0
for _, rule := range rules {
version, _ := rule.Data["version"].(string)
if version == "v5" {
continue
}
if version == "" {
migration.logger.WarnContext(ctx, "unexpected empty version for rule", "rule_id", rule.ID)
}
migration.logger.InfoContext(ctx, "migrating rule v4 to v5", "rule_id", rule.ID, "current_version", version)
// Check if the queries envelope already exists and is non-empty
hasQueriesEnvelope := false
if condition, ok := rule.Data["condition"].(map[string]any); ok {
if compositeQuery, ok := condition["compositeQuery"].(map[string]any); ok {
if queries, ok := compositeQuery["queries"].([]any); ok && len(queries) > 0 {
hasQueriesEnvelope = true
}
}
}
if hasQueriesEnvelope {
// already has queries envelope, just bump version
// this is because user made a mistake of choosing version
migration.logger.InfoContext(ctx, "rule already has queries envelope, bumping version", "rule_id", rule.ID)
rule.Data["version"] = "v5"
} else {
// old format, run full migration
migration.logger.InfoContext(ctx, "rule has old format, running full migration", "rule_id", rule.ID)
updated := alertsMigrator.Migrate(ctx, rule.Data)
if !updated {
migration.logger.WarnContext(ctx, "expected updated to be true but got false", "rule_id", rule.ID)
continue
}
rule.Data["version"] = "v5"
}
dataJSON, err := json.Marshal(rule.Data)
if err != nil {
return err
}
_, err = tx.NewUpdate().
Table("rule").
Set("data = ?", string(dataJSON)).
Where("id = ?", rule.ID).
Exec(ctx)
if err != nil {
return err
}
count++
}
if count != 0 {
migration.logger.InfoContext(ctx, "migrate v4 alerts", "count", count)
}
return tx.Commit()
}
func (migration *migrateRulesV4ToV5) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -10,6 +10,7 @@ import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -266,6 +267,37 @@ func (p *PostablePipeline) IsValid() error {
return nil
}
func (p *PostablePipeline) ToStoreablePipeline() (*StoreablePipeline, error) {
rawConfig, err := json.Marshal(p.Config)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to unmarshal postable pipeline config")
}
filter, err := json.Marshal(p.Filter)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to marshal postable pipeline filter")
}
identifier := valuer.GenerateUUID()
if p.ID != "" {
identifier, err = valuer.NewUUID(p.ID)
if err != nil {
return nil, errors.WithAdditionalf(err, "failed to parse postable pipeline id")
}
}
return &StoreablePipeline{
Identifiable: types.Identifiable{
ID: identifier,
},
OrderID: p.OrderID,
Enabled: p.Enabled,
Name: p.Name,
Alias: p.Alias,
Description: p.Description,
FilterString: string(filter),
ConfigJSON: string(rawConfig),
}, nil
}
func isValidOperator(op PipelineOperator) error {
if op.ID == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "PipelineOperator.ID is required")

View File

@@ -355,10 +355,6 @@ func (r *PostableRule) validate() error {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "composite query is required"))
}
if r.Version != "v5" {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "only version v5 is supported, got %q", r.Version))
}
if isAllQueriesDisabled(r.RuleCondition.CompositeQuery) {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "all queries are disabled in rule condition"))
}

View File

@@ -108,7 +108,6 @@ func TestParseIntoRule(t *testing.T) {
"ruleType": "threshold_rule",
"evalWindow": "5m",
"frequency": "1m",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -151,7 +150,6 @@ func TestParseIntoRule(t *testing.T) {
content: []byte(`{
"alert": "DefaultsRule",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -189,7 +187,6 @@ func TestParseIntoRule(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "PromQLRule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "promql",
@@ -259,7 +256,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "SeverityLabelTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -348,7 +344,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "NoLabelsTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -389,7 +384,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "OverwriteTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -480,7 +474,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "V2Test",
"schemaVersion": "v2",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -524,7 +517,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultSchemaTest",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -577,7 +569,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
func TestParseIntoRuleThresholdGeneration(t *testing.T) {
content := []byte(`{
"alert": "TestThresholds",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -648,7 +639,6 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
"schemaVersion": "v2",
"alert": "MultiThresholdAlert",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -742,7 +732,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -777,7 +766,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -811,7 +799,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -846,7 +833,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -880,7 +866,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -916,7 +901,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -951,7 +935,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyOutOfBoundsTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -986,7 +969,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -1021,7 +1003,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",