mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-25 09:42:25 +00:00
Compare commits
11 Commits
feat/infra
...
chore/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a85ee1602 | ||
|
|
4b0cbb787a | ||
|
|
ff028e366b | ||
|
|
c579614d56 | ||
|
|
78ba2ba356 | ||
|
|
7fd4762e2a | ||
|
|
4e4c9ce5af | ||
|
|
7605775a38 | ||
|
|
cb1a2a8a13 | ||
|
|
1a5d37b25a | ||
|
|
bc4273f2f8 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
- sqlite
|
||||
clickhouse-version:
|
||||
- 25.5.6
|
||||
- 25.10.5
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.142.0
|
||||
postgres-version:
|
||||
|
||||
@@ -82,6 +82,12 @@ 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:
|
||||
@@ -93,19 +99,19 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
exporters: [clickhousetraces, metadataexporter, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
|
||||
@@ -82,6 +82,12 @@ 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:
|
||||
@@ -93,19 +99,19 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
exporters: [clickhousetraces, metadataexporter, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
|
||||
@@ -308,3 +308,15 @@ 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'),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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,
|
||||
@@ -29,6 +27,8 @@ import {
|
||||
LogsExplorer,
|
||||
LogsIndexToFields,
|
||||
LogsSaveViews,
|
||||
MessagingQueuesMainPage,
|
||||
MeterExplorerPage,
|
||||
MetricsExplorer,
|
||||
OldLogsExplorer,
|
||||
Onboarding,
|
||||
@@ -399,28 +399,28 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA,
|
||||
exact: true,
|
||||
component: MessagingQueues,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_KAFKA',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
|
||||
exact: true,
|
||||
component: MessagingQueues,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_CELERY_TASK',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
|
||||
exact: true,
|
||||
component: MessagingQueues,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
|
||||
exact: true,
|
||||
component: MessagingQueues,
|
||||
component: MessagingQueuesMainPage,
|
||||
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
|
||||
isPrivate: true,
|
||||
},
|
||||
@@ -463,21 +463,21 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
component: MeterExplorerPage,
|
||||
key: 'METER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
component: MeterExplorerPage,
|
||||
key: 'METER_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER_VIEWS,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
component: MeterExplorerPage,
|
||||
key: 'METER_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
|
||||
export const defaultTableStyle: CSSProperties = {
|
||||
minWidth: '40rem',
|
||||
maxWidth: '60rem',
|
||||
maxWidth: '90rem',
|
||||
};
|
||||
|
||||
export const defaultListViewPanelStyle: CSSProperties = {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/* 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, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import DashboardDescription from '..';
|
||||
|
||||
@@ -17,6 +24,7 @@ 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'),
|
||||
@@ -28,6 +36,11 @@ 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',
|
||||
() =>
|
||||
@@ -45,6 +58,7 @@ 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 () => {
|
||||
@@ -124,7 +138,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
await waitFor(() => expect(lockUnlockButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('should navigate to dashboard list with correct params and exclude variables', async () => {
|
||||
it('should navigate back to the dashboard list when clicking the dashboard breadcrumb', async () => {
|
||||
const dashboardUrlWithVariables = `${DASHBOARD_PATH}?variables=%7B%22var1%22%3A%22value1%22%7D&otherParam=test`;
|
||||
const mockLocation = {
|
||||
pathname: DASHBOARD_PATH,
|
||||
@@ -133,6 +147,17 @@ 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>
|
||||
@@ -154,27 +179,55 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
),
|
||||
);
|
||||
|
||||
// Click the dashboard breadcrumb to navigate back to list
|
||||
const dashboardButton = getByText('Dashboard /');
|
||||
fireEvent.click(dashboardButton);
|
||||
await userEvent.click(dashboardButton);
|
||||
|
||||
// Verify navigation was called with correct URL
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard?columnKey=updatedAt&order=descend&page=1&search=',
|
||||
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>,
|
||||
);
|
||||
|
||||
// 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]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
|
||||
DASHBOARD_TITLE_TEXT,
|
||||
),
|
||||
);
|
||||
|
||||
// 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('');
|
||||
const dashboardButton = getByText('Dashboard /');
|
||||
await userEvent.click(dashboardButton);
|
||||
|
||||
// Should NOT have variables or other dashboard-specific params
|
||||
expect(urlParams.has('variables')).toBeFalsy();
|
||||
expect(urlParams.has('relativeTime')).toBeFalsy();
|
||||
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.ALL_DASHBOARD);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -31,6 +32,9 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = useDashboardVariablesSelector(
|
||||
(state) => state.dashboardId,
|
||||
);
|
||||
const sortedVariablesArray = useDashboardVariablesSelector(
|
||||
(state) => state.sortedVariablesArray,
|
||||
);
|
||||
@@ -96,6 +100,28 @@ 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 };
|
||||
@@ -130,10 +156,12 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Cascade: enqueue query-type descendants for refetching
|
||||
// Cascade: enqueue query-type descendants for refetching.
|
||||
// Safe to call synchronously now that the store already has the updated value.
|
||||
enqueueDescendantsOfVariable(name);
|
||||
},
|
||||
[
|
||||
dashboardId,
|
||||
dashboardVariables,
|
||||
updateLocalStorageDashboardVariables,
|
||||
updateUrlVariable,
|
||||
|
||||
@@ -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, isString } from 'lodash-es';
|
||||
import { isArray, isEmpty } 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,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
} = useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
@@ -68,81 +68,93 @@ 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)
|
||||
) {
|
||||
const newOptionsData = sortValues(
|
||||
variablesRes?.variableValues,
|
||||
variableData.sort,
|
||||
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),
|
||||
);
|
||||
} else if (
|
||||
variableData.selectedValue &&
|
||||
!sortedNewOptions.includes(variableData.selectedValue)
|
||||
) {
|
||||
isSelectedValueMissingInNewOptions = true;
|
||||
}
|
||||
|
||||
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
|
||||
// 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);
|
||||
|
||||
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
|
||||
let valueNotInList = false;
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(sortedNewOptions.map((option) => option.toString()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
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.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
sortedNewOptions.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
sortedNewOptions.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
// 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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -155,7 +167,7 @@ function QueryVariableInput({
|
||||
onValueUpdate,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* 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,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
IDashboardVariablesStoreState,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
initializeVariableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
@@ -17,6 +19,17 @@ 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();
|
||||
@@ -56,10 +69,14 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
|
||||
}));
|
||||
|
||||
// Mock VariableItem to avoid rendering complexity
|
||||
// VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests
|
||||
jest.mock('../VariableItem', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variable-item" />,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any): JSX.Element => {
|
||||
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
|
||||
return <div data-testid="variable-item" />;
|
||||
},
|
||||
}));
|
||||
|
||||
function createVariable(
|
||||
@@ -200,4 +217,162 @@ 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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* 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();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,9 @@ interface UseDashboardVariableSelectHelperReturn {
|
||||
applyDefaultIfNeeded: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => void;
|
||||
getDefaultValue: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => string | string[] | undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -248,5 +251,6 @@ export function useDashboardVariableSelectHelper({
|
||||
defaultValue,
|
||||
onChange,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 { safeNavigate } = useSafeNavigate();
|
||||
const { selectedDashboard, listSortOrder } = useDashboard();
|
||||
const navigate = useNavigate();
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
const selectedData = selectedDashboard
|
||||
? {
|
||||
@@ -24,15 +24,12 @@ function DashboardBreadcrumbs(): JSX.Element {
|
||||
const { title = '', image = Base64Icons[0] } = selectedData || {};
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
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]);
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-breadcrumbs">
|
||||
|
||||
@@ -45,6 +45,7 @@ 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';
|
||||
@@ -76,7 +77,6 @@ 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,22 +104,19 @@ function DashboardsList(): JSX.Element {
|
||||
const {
|
||||
data: dashboardListResponse,
|
||||
isLoading: isDashboardListLoading,
|
||||
isRefetching: isDashboardListRefetching,
|
||||
isFetching: isDashboardListFetching,
|
||||
error: dashboardFetchError,
|
||||
refetch: refetchDashboardList,
|
||||
} = useGetAllDashboard();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
listSortOrder: sortOrder,
|
||||
setListSortOrder: setSortOrder,
|
||||
} = useDashboard();
|
||||
const { listSortOrder, setListSortOrder } = useDashboardListSortOrder();
|
||||
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const [searchString, setSearchString] = useState<string>(
|
||||
sortOrder.search || '',
|
||||
listSortOrder.search || '',
|
||||
);
|
||||
const [action, createNewDashboard] = useComponentPermission(
|
||||
['action', 'create_new_dashboards'],
|
||||
@@ -139,7 +136,6 @@ function DashboardsList(): JSX.Element {
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const [uploadedGrafana, setUploadedGrafana] = useState<boolean>(false);
|
||||
const [isFilteringDashboards, setIsFilteringDashboards] = useState(false);
|
||||
const [isConfigureMetadataOpen, setIsConfigureMetadata] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
@@ -187,75 +183,33 @@ function DashboardsList(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
setDashboards(sortedDashboards);
|
||||
};
|
||||
|
||||
const sortDashboardsByUpdatedAt = (dashboards: Dashboard[]): void => {
|
||||
const sortedDashboards = dashboards.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
setDashboards(sortedDashboards);
|
||||
};
|
||||
|
||||
const sortHandle = (key: string): void => {
|
||||
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 {
|
||||
setSortOrder({ ...sortOrder, pagination: String(page) });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const filteredDashboards = filterDashboard(
|
||||
const dashboards = useMemo((): Dashboard[] => {
|
||||
const filtered = 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 || []);
|
||||
if (listSortOrder.columnKey === 'createdAt') {
|
||||
return filtered.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
dashboardListResponse,
|
||||
searchString,
|
||||
setSortOrder,
|
||||
sortOrder.columnKey,
|
||||
sortOrder.pagination,
|
||||
sortOrder.search,
|
||||
]);
|
||||
return filtered.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
}, [dashboardListResponse?.data, searchString, listSortOrder.columnKey]);
|
||||
|
||||
const sortHandle = (key: string): void => {
|
||||
setListSortOrder({
|
||||
columnKey: key,
|
||||
order: 'descend',
|
||||
pagination: listSortOrder.pagination || '1',
|
||||
search: listSortOrder.search || '',
|
||||
});
|
||||
};
|
||||
|
||||
function handlePageSizeUpdate(page: number): void {
|
||||
setListSortOrder({ ...listSortOrder, pagination: String(page) });
|
||||
}
|
||||
|
||||
const [newDashboardState, setNewDashboardState] = useState({
|
||||
loading: false,
|
||||
@@ -265,26 +219,25 @@ 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 {
|
||||
@@ -324,16 +277,9 @@ 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);
|
||||
setSortOrder({ ...sortOrder, search: searchText });
|
||||
setListSortOrder({ ...listSortOrder, search: searchText });
|
||||
};
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
@@ -671,8 +617,8 @@ function DashboardsList(): JSX.Element {
|
||||
showTotal: showPaginationItem,
|
||||
showSizeChanger: false,
|
||||
onChange: (page: any): void => handlePageSizeUpdate(page),
|
||||
current: Number(sortOrder.pagination),
|
||||
defaultCurrent: Number(sortOrder.pagination) || 1,
|
||||
current: Number(listSortOrder.pagination),
|
||||
defaultCurrent: Number(listSortOrder.pagination) || 1,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
|
||||
@@ -710,9 +656,7 @@ function DashboardsList(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDashboardListLoading ||
|
||||
isFilteringDashboards ||
|
||||
isDashboardListRefetching ? (
|
||||
{isDashboardListFetching ? (
|
||||
<div className="loading-dashboard-details">
|
||||
<Skeleton.Input active size="large" className="skeleton-1" />
|
||||
<Skeleton.Input active size="large" className="skeleton-1" />
|
||||
@@ -749,7 +693,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"
|
||||
@@ -831,7 +775,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">
|
||||
@@ -860,7 +804,7 @@ function DashboardsList(): JSX.Element {
|
||||
data-testid="sort-by-last-created"
|
||||
>
|
||||
Last created
|
||||
{sortOrder.columnKey === 'createdAt' && <Check size={14} />}
|
||||
{listSortOrder.columnKey === 'createdAt' && <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -869,7 +813,7 @@ function DashboardsList(): JSX.Element {
|
||||
data-testid="sort-by-last-updated"
|
||||
>
|
||||
Last updated
|
||||
{sortOrder.columnKey === 'updatedAt' && <Check size={14} />}
|
||||
{listSortOrder.columnKey === 'updatedAt' && <Check size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -911,11 +855,7 @@ function DashboardsList(): JSX.Element {
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
showSorterTooltip
|
||||
loading={
|
||||
isDashboardListLoading ||
|
||||
isFilteringDashboards ||
|
||||
isDashboardListRefetching
|
||||
}
|
||||
loading={isDashboardListFetching}
|
||||
showHeader={false}
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
@@ -964,12 +904,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">
|
||||
@@ -977,16 +917,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>
|
||||
)}
|
||||
@@ -995,16 +935,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>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useRef, 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,
|
||||
@@ -46,12 +47,23 @@ function Overview({
|
||||
handleChangeSelectedView,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
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'] = {
|
||||
@@ -196,7 +208,7 @@ function Overview({
|
||||
<>
|
||||
{isSearchVisible && (
|
||||
<Input
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
placeholder="Search for a field..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
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,
|
||||
@@ -67,8 +76,13 @@ export function getColorsForSeverityLabels(
|
||||
label: string,
|
||||
index: number,
|
||||
): string {
|
||||
// Check if we have a direct mapping for this severity variant
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[label.trim()];
|
||||
const trimmed = label.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return Color.BG_SLATE_300;
|
||||
}
|
||||
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
|
||||
if (variantColor) {
|
||||
return variantColor;
|
||||
}
|
||||
@@ -103,5 +117,8 @@ export function getColorsForSeverityLabels(
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
|
||||
return colors[index % colors.length] || themeColors.red;
|
||||
return (
|
||||
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
|
||||
Color.BG_SLATE_400
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,23 +111,19 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
);
|
||||
|
||||
const itemContent = useCallback(
|
||||
(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>
|
||||
);
|
||||
},
|
||||
(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}
|
||||
/>
|
||||
),
|
||||
[
|
||||
tableColumns,
|
||||
onSetActiveLog,
|
||||
@@ -143,7 +139,8 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
{tableColumns
|
||||
.filter((column) => column.key)
|
||||
.map((column) => {
|
||||
const isDragColumn = column.key !== 'expand';
|
||||
const isDragColumn =
|
||||
column.key !== 'expand' && column.key !== 'state-indicator';
|
||||
|
||||
return (
|
||||
<TableHeaderCellStyled
|
||||
|
||||
55
frontend/src/hooks/dashboard/useDashboardListSortOrder.ts
Normal file
55
frontend/src/hooks/dashboard/useDashboardListSortOrder.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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();
|
||||
});
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -28,9 +29,13 @@ 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);
|
||||
@@ -39,7 +44,7 @@ function useLogDetailHandlers({
|
||||
onSetActiveLog(log);
|
||||
setSelectedTab(nextTab ?? defaultTab);
|
||||
},
|
||||
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog],
|
||||
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog, isTextSelected],
|
||||
);
|
||||
|
||||
const handleCloseLogDetail = useCallback((): void => {
|
||||
|
||||
10
frontend/src/hooks/useIsTextSelected.ts
Normal file
10
frontend/src/hooks/useIsTextSelected.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useIsTextSelected(): () => boolean {
|
||||
return useCallback((): boolean => {
|
||||
const selection = window.getSelection();
|
||||
return (
|
||||
!!selection && !selection.isCollapsed && selection.toString().length > 0
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
@@ -22,9 +22,7 @@ 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';
|
||||
@@ -50,11 +48,7 @@ import {
|
||||
setDashboardVariablesStore,
|
||||
updateDashboardVariablesStore,
|
||||
} from './store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
DashboardSortOrder,
|
||||
IDashboardContext,
|
||||
WidgetColumnWidths,
|
||||
} from './types';
|
||||
import { IDashboardContext, WidgetColumnWidths } from './types';
|
||||
import { sortLayout } from './util';
|
||||
|
||||
const DashboardContext = createContext<IDashboardContext>({
|
||||
@@ -71,13 +65,7 @@ const DashboardContext = createContext<IDashboardContext>({
|
||||
layouts: [],
|
||||
panelMap: {},
|
||||
setPanelMap: () => {},
|
||||
listSortOrder: {
|
||||
columnKey: 'createdAt',
|
||||
order: 'descend',
|
||||
pagination: '1',
|
||||
search: '',
|
||||
},
|
||||
setListSortOrder: () => {},
|
||||
|
||||
setLayouts: () => {},
|
||||
setSelectedDashboard: () => {},
|
||||
updatedTimeRef: {} as React.MutableRefObject<Dayjs | null>,
|
||||
@@ -101,7 +89,6 @@ interface Props {
|
||||
export function DashboardProvider({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
|
||||
|
||||
const [toScrollWidgetId, setToScrollWidgetId] = useState<string>('');
|
||||
@@ -122,52 +109,8 @@ 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>(
|
||||
@@ -502,8 +445,6 @@ export function DashboardProvider({
|
||||
selectedDashboard,
|
||||
dashboardId,
|
||||
layouts,
|
||||
listSortOrder,
|
||||
setListSortOrder,
|
||||
panelMap,
|
||||
setLayouts,
|
||||
setPanelMap,
|
||||
@@ -527,8 +468,6 @@ export function DashboardProvider({
|
||||
selectedDashboard,
|
||||
dashboardId,
|
||||
layouts,
|
||||
listSortOrder,
|
||||
setListSortOrder,
|
||||
panelMap,
|
||||
toScrollWidgetId,
|
||||
updateLocalStorageDashboardVariables,
|
||||
|
||||
@@ -4,13 +4,6 @@ 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>;
|
||||
};
|
||||
@@ -26,8 +19,6 @@ 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>
|
||||
|
||||
@@ -120,6 +120,8 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
|
||||
}
|
||||
}
|
||||
resultData.Rows = filteredRows
|
||||
case *qbtypes.ScalarData:
|
||||
resultData.Data = filterScalarDataIPs(resultData.Columns, resultData.Data)
|
||||
}
|
||||
|
||||
filteredData = append(filteredData, result)
|
||||
@@ -145,6 +147,39 @@ 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 {
|
||||
|
||||
@@ -117,6 +117,59 @@ 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 {
|
||||
|
||||
@@ -169,6 +169,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddAnonymousPublicDashboardTransactionFactory(sqlstore),
|
||||
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
209
pkg/sqlmigration/066_migrate_rules_v4_to_v5_post_deprecation.go
Normal file
209
pkg/sqlmigration/066_migrate_rules_v4_to_v5_post_deprecation.go
Normal file
@@ -0,0 +1,209 @@
|
||||
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
|
||||
}
|
||||
@@ -355,6 +355,10 @@ 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"))
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"ruleType": "threshold_rule",
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -150,6 +151,7 @@ func TestParseIntoRule(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "DefaultsRule",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -187,6 +189,7 @@ func TestParseIntoRule(t *testing.T) {
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "PromQLRule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "promql",
|
||||
@@ -256,6 +259,7 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "SeverityLabelTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -344,6 +348,7 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "NoLabelsTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -384,6 +389,7 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "OverwriteTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -474,6 +480,7 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "V2Test",
|
||||
"schemaVersion": "v2",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -517,6 +524,7 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "DefaultSchemaTest",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -569,6 +577,7 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"alert": "TestThresholds",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -639,6 +648,7 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
"schemaVersion": "v2",
|
||||
"alert": "MultiThresholdAlert",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -732,6 +742,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -766,6 +777,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -799,6 +811,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyAboveTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -833,6 +846,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyAboveTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -866,6 +880,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowAllTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -901,6 +916,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowAllTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -935,6 +951,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyOutOfBoundsTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -969,6 +986,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "ThresholdTest",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -1003,6 +1021,7 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "ThresholdTest",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
|
||||
Reference in New Issue
Block a user