Compare commits

...

7 Commits

Author SHA1 Message Date
Abhi kumar
3562de8fbb fix: added fix for tooltip sizing (#10205)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
2026-02-05 22:46:17 +05:30
Abhi kumar
ef80fb39fd feat: added new time-series graph (#10201)
* feat: added new time-series graph

* chore: updated types for charts
2026-02-05 11:29:21 +00:00
Abhi kumar
594d4dc737 feat: added changes to compute legend items width for virtualization (#10196)
* feat: added changes to compute legend items width for virtualization

* feat: added support for single row in legends
2026-02-05 16:27:43 +05:30
Abhishek Kumar Singh
01415b58be chore: made group_wait and group_interval configuration dynamic for alert manager (#10198) 2026-02-05 09:56:27 +00:00
Ashwin Bhatkal
f7728c9019 chore: update variables store with derived values (#10194)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove redundant sort

* chore: use sorted variables array

* chore: update variables store with derived values

* chore: fix types

* chore: resolve cursor comments
2026-02-05 14:09:46 +05:30
Amlan Kumar Nandy
3fffe6e198 chore: use single reducer in create alerts flow (#10179) 2026-02-05 07:39:07 +00:00
Aditya Singh
4594f4ffe3 Fix/query label fix (#10158)
* feat: function idon func added

* feat: test update

* fix: minor refactor
2026-02-05 12:19:16 +05:30
34 changed files with 959 additions and 349 deletions

View File

@@ -11,9 +11,14 @@ import {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { QueryFunction } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { UseQueryOperations } from 'types/common/operations.types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import {
DataSource,
QueryBuilderContextType,
QueryFunctionsTypes,
} from 'types/common/queryBuilder';
import '@testing-library/jest-dom';
@@ -45,12 +50,17 @@ const mockedUseQueryOperations = jest.mocked(
describe('QueryBuilderV2 + QueryV2 - base render', () => {
let handleRunQueryMock: jest.MockedFunction<() => void>;
let handleQueryFunctionsUpdatesMock: jest.MockedFunction<() => void>;
let baseQBContext: QueryBuilderContextType;
beforeEach(() => {
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
handleRunQueryMock = jest.fn() as jest.MockedFunction<() => void>;
handleQueryFunctionsUpdatesMock = jest.fn() as jest.MockedFunction<
() => void
>;
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
@@ -91,7 +101,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
const updateQueriesData: QueryBuilderContextType['updateQueriesData'] = (q) =>
q;
mockedUseQueryBuilder.mockReturnValue(({
const baseContext = ({
currentQuery: currentQueryObj,
stagedQuery: null,
lastUsedQuery: null,
@@ -124,7 +134,10 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
} as unknown) as QueryBuilderContextType);
} as unknown) as QueryBuilderContextType;
baseQBContext = baseContext;
mockedUseQueryBuilder.mockReturnValue(baseQBContext);
mockedUseQueryOperations.mockReturnValue({
isTracePanelType: false,
@@ -139,7 +152,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
handleDeleteQuery: jest.fn(),
handleChangeQueryData: (jest.fn() as unknown) as ReturnType<UseQueryOperations>['handleChangeQueryData'],
handleChangeFormulaData: jest.fn(),
handleQueryFunctionsUpdates: jest.fn(),
handleQueryFunctionsUpdates: handleQueryFunctionsUpdatesMock,
listOfAdditionalFormulaFilters: [],
});
});
@@ -199,4 +212,56 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
expect(handleRunQueryMock).toHaveBeenCalled();
});
it('fx button is disabled when functions already exist', () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const supersetQueryBase = baseQBContext.supersetQuery as Query;
mockedUseQueryBuilder.mockReturnValueOnce({
...baseQBContext,
currentQuery: {
...currentQueryBase,
builder: {
...currentQueryBase.builder,
queryData: [
{
...currentQueryBase.builder.queryData[0],
functions: [
{ name: QueryFunctionsTypes.TIME_SHIFT, args: [] } as QueryFunction,
],
},
],
},
},
supersetQuery: {
...supersetQueryBase,
builder: {
...supersetQueryBase.builder,
queryData: [
{
...supersetQueryBase.builder.queryData[0],
functions: [
{ name: QueryFunctionsTypes.TIME_SHIFT, args: [] } as QueryFunction,
],
},
],
},
},
});
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
const fxButton = document.querySelector('.function-btn') as HTMLButtonElement;
expect(fxButton).toBeInTheDocument();
expect(fxButton).toBeDisabled();
const deleteButton = document.querySelector(
'.query-function-delete-btn',
) as HTMLButtonElement;
expect(deleteButton).toBeInTheDocument();
userEvent.click(deleteButton);
waitFor(() => {
expect(fxButton).not.toBeDisabled();
});
});
});

View File

@@ -338,7 +338,7 @@ describe('CreateAlertV2 utils', () => {
const props = getCreateAlertLocalStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
basicAlertState: {
basic: {
...INITIAL_ALERT_STATE,
name: 'test-alert',
labels: {
@@ -348,10 +348,10 @@ describe('CreateAlertV2 utils', () => {
yAxisUnit: UniversalYAxisUnit.MINUTES,
},
// as we have already verified these utils in their respective tests
thresholdState: expect.any(Object),
advancedOptionsState: expect.any(Object),
evaluationWindowState: expect.any(Object),
notificationSettingsState: expect.any(Object),
threshold: expect.any(Object),
advancedOptions: expect.any(Object),
evaluationWindow: expect.any(Object),
notificationSettings: expect.any(Object),
});
});
});

View File

@@ -196,3 +196,11 @@ export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
routingPolicies: false,
};
export const INITIAL_CREATE_ALERT_STATE = {
basic: INITIAL_ALERT_STATE,
threshold: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
};

View File

@@ -17,26 +17,22 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { INITIAL_CREATE_ALERT_STATE } from './constants';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './constants';
import {
AdvancedOptionsAction,
AlertThresholdAction,
AlertThresholdMatchType,
CreateAlertAction,
CreateAlertSlice,
EvaluationWindowAction,
ICreateAlertContextProps,
ICreateAlertProviderProps,
NotificationSettingsAction,
} from './types';
import {
advancedOptionsReducer,
alertCreationReducer,
alertThresholdReducer,
buildInitialAlertDef,
evaluationWindowReducer,
createAlertReducer,
getInitialAlertTypeFromURL,
notificationSettingsReducer,
} from './utils';
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
@@ -65,10 +61,65 @@ export function CreateAlertProvider(
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const [alertState, setAlertState] = useReducer(alertCreationReducer, {
...INITIAL_ALERT_STATE,
yAxisUnit: currentQuery.unit,
});
const [createAlertState, setCreateAlertState] = useReducer(
createAlertReducer,
{
...INITIAL_CREATE_ALERT_STATE,
basic: {
...INITIAL_CREATE_ALERT_STATE.basic,
yAxisUnit: currentQuery.unit,
},
},
);
const setAlertState = useCallback(
(action: CreateAlertAction) => {
setCreateAlertState({
slice: CreateAlertSlice.BASIC,
action,
});
},
[setCreateAlertState],
);
const setThresholdState = useCallback(
(action: AlertThresholdAction) => {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action,
});
},
[setCreateAlertState],
);
const setEvaluationWindow = useCallback(
(action: EvaluationWindowAction) => {
setCreateAlertState({
slice: CreateAlertSlice.EVALUATION_WINDOW,
action,
});
},
[setCreateAlertState],
);
const setAdvancedOptions = useCallback(
(action: AdvancedOptionsAction) => {
setCreateAlertState({
slice: CreateAlertSlice.ADVANCED_OPTIONS,
action,
});
},
[setCreateAlertState],
);
const setNotificationSettings = useCallback(
(action: NotificationSettingsAction) => {
setCreateAlertState({
slice: CreateAlertSlice.NOTIFICATION_SETTINGS,
action,
});
},
[setCreateAlertState],
);
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
@@ -104,92 +155,56 @@ export function CreateAlertProvider(
[redirectWithQueryBuilderData],
);
const [thresholdState, setThresholdState] = useReducer(
alertThresholdReducer,
INITIAL_ALERT_THRESHOLD_STATE,
);
const [evaluationWindow, setEvaluationWindow] = useReducer(
evaluationWindowReducer,
INITIAL_EVALUATION_WINDOW_STATE,
);
const [advancedOptions, setAdvancedOptions] = useReducer(
advancedOptionsReducer,
INITIAL_ADVANCED_OPTIONS_STATE,
);
const [notificationSettings, setNotificationSettings] = useReducer(
notificationSettingsReducer,
INITIAL_NOTIFICATION_SETTINGS_STATE,
);
useEffect(() => {
setThresholdState({
type: 'RESET',
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'RESET',
},
});
if (thresholdsFromURL) {
try {
const thresholds = JSON.parse(thresholdsFromURL);
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholds,
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_THRESHOLDS',
payload: thresholds,
},
});
} catch (error) {
console.error('Error parsing thresholds from URL:', error);
}
setEvaluationWindow({
type: 'SET_INITIAL_STATE_FOR_METER',
setCreateAlertState({
slice: CreateAlertSlice.EVALUATION_WINDOW,
action: {
type: 'SET_INITIAL_STATE_FOR_METER',
},
});
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
},
});
}
}, [alertType, thresholdsFromURL]);
useEffect(() => {
if (isEditMode && initialAlertState) {
setAlertState({
setCreateAlertState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.basicAlertState,
});
setThresholdState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.thresholdState,
});
setEvaluationWindow({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.evaluationWindowState,
});
setAdvancedOptions({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.advancedOptionsState,
});
setNotificationSettings({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.notificationSettingsState,
payload: initialAlertState,
});
}
}, [initialAlertState, isEditMode]);
const discardAlertRule = useCallback(() => {
setAlertState({
type: 'RESET',
});
setThresholdState({
type: 'RESET',
});
setEvaluationWindow({
type: 'RESET',
});
setAdvancedOptions({
type: 'RESET',
});
setNotificationSettings({
setCreateAlertState({
type: 'RESET',
});
handleAlertTypeChange(AlertTypes.METRICS_BASED_ALERT);
@@ -212,17 +227,17 @@ export function CreateAlertProvider(
const contextValue: ICreateAlertContextProps = useMemo(
() => ({
alertState,
alertState: createAlertState.basic,
setAlertState,
alertType,
setAlertType: handleAlertTypeChange,
thresholdState,
thresholdState: createAlertState.threshold,
setThresholdState,
evaluationWindow,
evaluationWindow: createAlertState.evaluationWindow,
setEvaluationWindow,
advancedOptions,
advancedOptions: createAlertState.advancedOptions,
setAdvancedOptions,
notificationSettings,
notificationSettings: createAlertState.notificationSettings,
setNotificationSettings,
discardAlertRule,
createAlertRule,
@@ -234,13 +249,14 @@ export function CreateAlertProvider(
isEditMode: isEditMode || false,
}),
[
alertState,
createAlertState,
setAlertState,
setThresholdState,
setEvaluationWindow,
setAdvancedOptions,
setNotificationSettings,
alertType,
handleAlertTypeChange,
thresholdState,
evaluationWindow,
advancedOptions,
notificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,

View File

@@ -9,8 +9,6 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { Labels } from 'types/api/alerts/def';
import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types';
export interface ICreateAlertContextProps {
alertState: AlertState;
setAlertState: Dispatch<CreateAlertAction>;
@@ -52,7 +50,7 @@ export interface ICreateAlertContextProps {
export interface ICreateAlertProviderProps {
children: React.ReactNode;
initialAlertType: AlertTypes;
initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn;
initialAlertState?: CreateAlertState;
isEditMode?: boolean;
ruleId?: string;
}
@@ -272,3 +270,31 @@ export type NotificationSettingsAction =
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
| { type: 'RESET' };
export type CreateAlertState = {
basic: AlertState;
threshold: AlertThresholdState;
advancedOptions: AdvancedOptionsState;
evaluationWindow: EvaluationWindowState;
notificationSettings: NotificationSettingsState;
};
export enum CreateAlertSlice {
BASIC = 'basic',
THRESHOLD = 'threshold',
ADVANCED_OPTIONS = 'advancedOptions',
EVALUATION_WINDOW = 'evaluationWindow',
NOTIFICATION_SETTINGS = 'notificationSettings',
}
export type CreateAlertReducerAction =
| { slice: CreateAlertSlice.BASIC; action: CreateAlertAction }
| { slice: CreateAlertSlice.THRESHOLD; action: AlertThresholdAction }
| { slice: CreateAlertSlice.ADVANCED_OPTIONS; action: AdvancedOptionsAction }
| { slice: CreateAlertSlice.EVALUATION_WINDOW; action: EvaluationWindowAction }
| {
slice: CreateAlertSlice.NOTIFICATION_SETTINGS;
action: NotificationSettingsAction;
}
| { type: 'RESET' }
| { type: 'SET_INITIAL_STATE'; payload: CreateAlertState };

View File

@@ -18,6 +18,7 @@ import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_CREATE_ALERT_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './constants';
@@ -28,6 +29,9 @@ import {
AlertThresholdAction,
AlertThresholdState,
CreateAlertAction,
CreateAlertReducerAction,
CreateAlertSlice,
CreateAlertState,
EvaluationWindowAction,
EvaluationWindowState,
NotificationSettingsAction,
@@ -251,3 +255,57 @@ export const notificationSettingsReducer = (
return state;
}
};
export const createAlertReducer = (
state: CreateAlertState,
action: CreateAlertReducerAction,
): CreateAlertState => {
// Global actions
if ('type' in action) {
switch (action.type) {
case 'RESET':
return INITIAL_CREATE_ALERT_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
}
// Slice actions
switch (action.slice) {
case CreateAlertSlice.BASIC:
return { ...state, basic: alertCreationReducer(state.basic, action.action) };
case CreateAlertSlice.THRESHOLD:
return {
...state,
threshold: alertThresholdReducer(state.threshold, action.action),
};
case CreateAlertSlice.ADVANCED_OPTIONS:
return {
...state,
advancedOptions: advancedOptionsReducer(
state.advancedOptions,
action.action,
),
};
case CreateAlertSlice.EVALUATION_WINDOW:
return {
...state,
evaluationWindow: evaluationWindowReducer(
state.evaluationWindow,
action.action,
),
};
case CreateAlertSlice.NOTIFICATION_SETTINGS:
return {
...state,
notificationSettings: notificationSettingsReducer(
state.notificationSettings,
action.action,
),
};
default:
return state;
}
};

View File

@@ -21,11 +21,11 @@ import {
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
CreateAlertState,
EvaluationWindowState,
NotificationSettingsState,
} from './context/types';
import { EVALUATION_WINDOW_TIMEFRAME } from './EvaluationSettings/constants';
import { GetCreateAlertLocalStateFromAlertDefReturn } from './types';
export function Spinner(): JSX.Element | null {
const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState();
@@ -265,14 +265,14 @@ export function getThresholdStateFromAlertDef(
export function getCreateAlertLocalStateFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): GetCreateAlertLocalStateFromAlertDefReturn {
): CreateAlertState {
if (!alertDef) {
return {
basicAlertState: INITIAL_ALERT_STATE,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptionsState: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindowState: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettingsState: INITIAL_NOTIFICATION_SETTINGS_STATE,
basic: INITIAL_ALERT_STATE,
threshold: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
};
}
// Basic alert state
@@ -294,10 +294,10 @@ export function getCreateAlertLocalStateFromAlertDef(
);
return {
basicAlertState,
thresholdState,
advancedOptionsState,
evaluationWindowState,
notificationSettingsState,
basic: basicAlertState,
threshold: thresholdState,
advancedOptions: advancedOptionsState,
evaluationWindow: evaluationWindowState,
notificationSettings: notificationSettingsState,
};
}

View File

@@ -22,7 +22,7 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Trash2 } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { TVariableMode } from './types';

View File

@@ -1,8 +1,11 @@
import { memo, useEffect, useState } from 'react';
import { memo, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import {
useDashboardVariables,
useDashboardVariablesSelector,
} from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
@@ -12,13 +15,7 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import DynamicVariableSelection from './DynamicVariableSelection';
import {
buildDependencies,
buildDependencyGraph,
buildParentDependencyGraph,
IDependencyData,
onUpdateVariableNode,
} from './util';
import { onUpdateVariableNode } from './util';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
@@ -35,11 +32,11 @@ function DashboardVariableSelection(): JSX.Element | null {
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [dependencyData, setDependencyData] = useState<IDependencyData | null>(
null,
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
const dependencyData = useDashboardVariablesSelector(
(state) => state.dependencyData,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
@@ -47,24 +44,6 @@ function DashboardVariableSelection(): JSX.Element | null {
);
useEffect(() => {
const tableRowData = [];
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(dashboardVariables)) {
const { id } = value;
tableRowData.push({
key,
name: key,
...dashboardVariables[key],
id,
});
}
tableRowData.sort((a, b) => a.order - b.order);
setVariablesTableData(tableRowData);
// Initialize variables with default values if not in URL
initializeDefaultVariables(
dashboardVariables,
@@ -73,30 +52,6 @@ function DashboardVariableSelection(): JSX.Element | null {
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
useEffect(() => {
if (variablesTableData.length > 0) {
const depGrp = buildDependencies(variablesTableData);
const { order, graph, hasCycle, cycleNodes } = buildDependencyGraph(depGrp);
const parentDependencyGraph = buildParentDependencyGraph(graph);
// cleanup order to only include variables that are of type 'QUERY'
const cleanedOrder = order.filter((variable) => {
const variableData = variablesTableData.find(
(v: IDashboardVariable) => v.name === variable,
);
return variableData?.type === 'QUERY';
});
setDependencyData({
order: cleanedOrder,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
});
}
}, [dashboardVariables, variablesTableData]);
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
// also trigger when the global time changes
useEffect(
@@ -186,45 +141,30 @@ function DashboardVariableSelection(): JSX.Element | null {
}
};
if (!dashboardVariables) {
return null;
}
const orderBasedSortedVariables = variablesTableData.sort(
(a: { order: number }, b: { order: number }) => a.order - b.order,
);
return (
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) =>
variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={`${variable.name}${variable.id}${variable.order}`}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
/>
) : (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
),
)}
{sortedVariablesArray.map((variable) => {
const key = `${variable.name}${variable.id}${variable.order}`;
return variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
/>
) : (
<VariableItem
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
);
})}
</Row>
);
}

View File

@@ -16,6 +16,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
@@ -24,7 +25,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
import { areArraysEqual, checkAPIInvocation } from './util';
import './DashboardVariableSelection.styles.scss';

View File

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as uuidv4 } from 'uuid';

View File

@@ -1,6 +1,9 @@
import { OptionData } from 'components/NewSelect/types';
import { isEmpty, isNull } from 'lodash-es';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import {
IDashboardVariables,
IDependencyData,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export function areArraysEqual(
@@ -97,14 +100,6 @@ export const buildDependencies = (
return graph;
};
export interface IDependencyData {
order: string[];
graph: VariableGraph;
parentDependencyGraph: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
export const buildParentDependencyGraph = (
graph: VariableGraph,
): VariableGraph => {

View File

@@ -0,0 +1,104 @@
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import Tooltip from 'lib/uPlotV2/components/Tooltip/Tooltip';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import _noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
export default function TimeSeries({
legendConfig = { position: LegendPosition.BOTTOM },
config,
data,
width: containerWidth,
height: containerHeight,
disableTooltip = false,
canPinTooltip = false,
timezone,
yAxisUnit,
decimalPrecision,
syncMode,
syncKey,
onDestroy = _noop,
children,
layoutChildren,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[config, legendConfig.position],
);
return (
<PlotContextProvider>
<ChartLayout
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;
onDestroy(plot);
}}
data-testid={testId}
>
{children}
{!disableTooltip && (
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
render={(props: TooltipRenderArgs): React.ReactNode => (
<Tooltip
{...props}
timezone={timezone}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
/>
)}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -0,0 +1,29 @@
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
interface BaseChartProps {
width: number;
height: number;
disableTooltip?: boolean;
timezone: string;
syncMode?: DashboardCursorSync;
syncKey?: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
interface TimeSeriesChartProps extends BaseChartProps {
config: UPlotConfigBuilder;
legendConfig: LegendConfig;
data: uPlot.AlignedData;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
layoutChildren?: React.ReactNode;
'data-testid'?: string;
}
export type ChartProps = TimeSeriesChartProps;

View File

@@ -5,25 +5,32 @@ export interface ChartDimensions {
height: number;
legendWidth: number;
legendHeight: number;
legendsPerSet: number;
averageLegendWidth: number;
}
const AVG_CHAR_WIDTH = 8;
const LEGEND_WIDTH_PERCENTILE = 0.85;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const LEGEND_GAP = 16;
const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 36;
const LEGEND_LINE_HEIGHT = 28;
/**
* Average text width from series labels (for legendsPerSet).
* Calculates the average width of the legend items based on the labels of the series.
* @param legends - The labels of the series.
* @returns The average width of the legend items.
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return DEFAULT_AVG_LABEL_LENGTH;
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
}
const averageLabelLength =
legends.reduce((sum, l) => sum + l.length, 0) / legends.length;
return averageLabelLength * AVG_CHAR_WIDTH;
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
const percentileLength = lengths[Math.max(0, index)];
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
}
/**
@@ -64,7 +71,7 @@ export function calculateChartDimensions({
height: 0,
legendWidth: 0,
legendHeight: 0,
legendsPerSet: 0,
averageLegendWidth: 0,
};
}
@@ -85,13 +92,15 @@ export function calculateChartDimensions({
legendWidth: rightLegendWidth,
legendHeight: containerHeight,
// Single vertical list on the right.
legendsPerSet: 1,
averageLegendWidth: rightLegendWidth,
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.min(approxLegendItemWidth, 400);
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
const legendItemsPerRow = Math.max(
1,
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
@@ -114,17 +123,11 @@ export function calculateChartDimensions({
maxAllowedLegendHeight,
);
// How many legend items per row in the Legend component.
const legendsPerSet = Math.ceil(
(containerWidth + LEGEND_GAP) /
(Math.min(MAX_LEGEND_WIDTH, approxLegendItemWidth) + LEGEND_GAP),
);
return {
width: containerWidth,
height: Math.max(0, containerHeight - bottomLegendHeight),
legendWidth: containerWidth,
legendHeight: bottomLegendHeight,
legendsPerSet,
averageLegendWidth: legendItemWidth,
};
}

View File

@@ -11,6 +11,7 @@ export interface ChartLayoutProps {
children: (props: {
chartWidth: number;
chartHeight: number;
averageLegendWidth: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
containerWidth: number;
@@ -56,6 +57,7 @@ export default function ChartLayout({
{children({
chartWidth: chartDimensions.width,
chartHeight: chartDimensions.height,
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
</div>
<div
@@ -65,7 +67,7 @@ export default function ChartLayout({
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.legendsPerSet)}
{legendComponent(chartDimensions.averageLegendWidth)}
</div>
</div>
{layoutChildren}

View File

@@ -4,7 +4,7 @@ import { ToggleGraphProps } from 'components/Graph/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -40,6 +40,7 @@
font-size: 12px;
font-style: normal;
font-weight: 400;
pointer-events: none;
// line-height: 18px;
color: var(--bg-sakura-400) !important;

View File

@@ -96,6 +96,7 @@ export default function QueryFunctions({
const isDarkMode = useIsDarkMode();
const hasAnomalyFunction = functions.some((func) => func.name === 'anomaly');
const hasFunctions = functions.length > 0;
const handleAddNewFunction = (): void => {
const defaultFunctionStruct =
@@ -180,10 +181,14 @@ export default function QueryFunctions({
<div
className={cx(
'query-functions-container',
functions && functions.length > 0 ? 'hasFunctions' : '',
hasFunctions ? 'hasFunctions' : '',
)}
>
<Button className="periscope-btn function-btn">
<Button
className="periscope-btn function-btn"
disabled={hasFunctions}
onClick={handleAddNewFunction}
>
<FunctionIcon
className="function-icon"
fillColor={!isDarkMode ? '#0B0C0E' : 'white'}

View File

@@ -12,7 +12,7 @@ import {
initialQueriesMap,
initialQueryBuilderFormValues,
} from 'constants/queryBuilder';
import { IUseDashboardVariablesReturn } from 'hooks/dashboard/useDashboardVariables';
import { IUseDashboardVariablesReturn } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import useGetResolvedText from '../useGetResolvedText';

View File

@@ -1,21 +1,40 @@
import { useSyncExternalStore } from 'react';
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
dashboardVariablesStore,
IDashboardVariables,
} from '../../providers/Dashboard/store/dashboardVariablesStore';
IDashboardVariablesStoreState,
IUseDashboardVariablesReturn,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
export interface IUseDashboardVariablesReturn {
dashboardVariables: IDashboardVariables;
}
/**
* Generic selector hook for dashboard variables store
* Allows granular subscriptions to any part of the store state
*
* @example
* ! Select top-level field
* const variables = useDashboardVariablesSelector(s => s.variables);
*
* ! Select specific variable
* const fooVar = useDashboardVariablesSelector(s => s.variables['foo']);
*
* ! Select derived value
* const hasVariables = useDashboardVariablesSelector(s => Object.keys(s.variables).length > 0);
*/
export const useDashboardVariablesSelector = <T>(
selector: (state: IDashboardVariablesStoreState) => T,
): T => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
const dashboardVariables = useSyncExternalStore(
dashboardVariablesStore.subscribe,
dashboardVariablesStore.getSnapshot,
const getSnapshot = useCallback(
() => selectorRef.current(dashboardVariablesStore.getSnapshot()),
[],
);
return {
dashboardVariables,
};
return useSyncExternalStore(dashboardVariablesStore.subscribe, getSnapshot);
};
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
return { dashboardVariables };
};

View File

@@ -1,5 +1,5 @@
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import store from 'store';
export const getDashboardVariables = (

View File

@@ -1,7 +1,18 @@
.legend-search-container {
flex-shrink: 0;
width: 100%;
padding-right: 8px;
.legend-search-input {
font-size: 12px;
}
}
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
width: 100%;
@@ -17,6 +28,38 @@
height: 100%;
width: 100%;
.virtuoso-grid-list {
min-width: 0;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
row-gap: 4px;
column-gap: 12px;
}
.virtuoso-grid-item {
min-width: 0;
}
&.legend-virtuoso-container-right {
.virtuoso-grid-list {
grid-template-columns: 1fr;
}
}
&.legend-virtuoso-container-single-row {
.virtuoso-grid-list {
grid-template-columns: repeat(
auto-fit,
minmax(var(--legend-average-width, 240px), max-content)
);
justify-content: center;
}
}
&::-webkit-scrollbar {
width: 0.3rem;
}
@@ -58,9 +101,15 @@
align-items: center;
gap: 6px;
padding: 4px 8px;
max-width: 100%;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
&.legend-item-right {
width: 100%;
}
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;

View File

@@ -1,23 +1,21 @@
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Tooltip as AntdTooltip } from 'antd';
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input, Tooltip as AntdTooltip } from 'antd';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { LegendProps } from '../types';
import { LegendPosition, LegendProps } from '../types';
import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
export const MAX_LEGEND_WIDTH = 320;
const LEGENDS_PER_SET_DEFAULT = 5;
export const MAX_LEGEND_WIDTH = 240;
export default function Legend({
position = LegendPosition.BOTTOM,
config,
legendsPerSet = LEGENDS_PER_SET_DEFAULT,
averageLegendWidth = MAX_LEGEND_WIDTH,
}: LegendProps): JSX.Element {
const {
legendItemsMap,
@@ -33,54 +31,54 @@ export default function Legend({
focusedSeriesIndex,
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
// Chunk legend items into rows of LEGENDS_PER_ROW items each
const legendRows = useMemo(() => {
const legendItems = Object.values(legendItemsMap);
const legendItems = useMemo(() => Object.values(legendItemsMap), [
legendItemsMap,
]);
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
if (i % legendsPerSet === 0) {
acc.push([]);
}
acc[acc.length - 1].push(curr);
return acc;
}, [] as LegendItem[][]);
}, [legendItemsMap, legendsPerSet]);
const isSingleRow = useMemo(() => {
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
return false;
}
const containerWidth = legendContainerRef.current.clientWidth;
const renderLegendRow = useCallback(
(rowIndex: number, row: LegendItem[]): JSX.Element => (
<div
key={rowIndex}
className={cx(
'legend-row',
`legend-row-${position.toLowerCase()}`,
legendRows.length === 1 && position === LegendPosition.BOTTOM
? 'legend-single-row'
: '',
)}
>
{row.map((item) => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
<div
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
style={{ maxWidth: `min(${MAX_LEGEND_WIDTH}px, 100%)` }}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
))}
</div>
const totalLegendWidth = legendItems.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, legendContainerRef, legendItems.length, position]);
const visibleLegendItems = useMemo(() => {
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
return legendItems;
}
const query = legendSearchQuery.trim().toLowerCase();
return legendItems.filter((item) =>
item.label?.toLowerCase().includes(query),
);
}, [position, legendSearchQuery, legendItems]);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
<div
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
),
[focusedSeriesIndex, position, legendRows],
[focusedSeriesIndex, position],
);
return (
@@ -90,11 +88,29 @@ export default function Legend({
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
>
<Virtuoso
className="legend-virtuoso-container"
data={legendRows}
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
{position === LegendPosition.RIGHT && (
<div className="legend-search-container">
<Input
allowClear
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
className="legend-search-input"
/>
</div>
)}
<VirtuosoGrid
className={cx(
'legend-virtuoso-container',
`legend-virtuoso-container-${position.toLowerCase()}`,
{ 'legend-virtuoso-container-single-row': isSingleRow },
)}
data={visibleLegendItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>
</div>
);

View File

@@ -5,7 +5,7 @@
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
border-radius: 6px;
padding: 1rem 1rem 0.5rem 1rem;
padding: 1rem 0.5rem 0.5rem 1rem;
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
@@ -15,6 +15,12 @@
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
.uplot-tooltip-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
}
.uplot-tooltip-header {
@@ -22,18 +28,18 @@
font-weight: 500;
}
.uplot-tooltip-list-container {
height: 100%;
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
}
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}

View File

@@ -75,7 +75,7 @@ export interface LegendConfig {
export interface LegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
legendsPerSet?: number;
averageLegendWidth?: number;
}
export interface TooltipContentItem {

View File

@@ -45,8 +45,11 @@ import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { useDashboardVariables } from '../../hooks/dashboard/useDashboardVariables';
import { setDashboardVariablesStore } from './store/dashboardVariablesStore';
import { useDashboardVariablesSelector } from '../../hooks/dashboard/useDashboardVariables';
import {
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from './store/dashboardVariables/dashboardVariablesStore';
import {
DashboardSortOrder,
IDashboardContext,
@@ -198,14 +201,23 @@ export function DashboardProvider({
: isDashboardWidgetPage?.params.dashboardId) || '';
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard>();
const dashboardVariables = useDashboardVariables();
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
useEffect(() => {
const existingVariables = dashboardVariables;
const updatedVariables = selectedDashboard?.data.variables || {};
if (!isEqual(existingVariables, updatedVariables)) {
setDashboardVariablesStore(updatedVariables);
if (savedDashboardId !== dashboardId) {
setDashboardVariablesStore({
dashboardId,
variables: updatedVariables,
});
} else if (!isEqual(existingVariables, updatedVariables)) {
updateDashboardVariablesStore({
dashboardId,
variables: updatedVariables,
});
}
}, [selectedDashboard]);

View File

@@ -0,0 +1,57 @@
import createStore from '../store';
import { IDashboardVariablesStoreState } from './dashboardVariablesStoreTypes';
import {
computeDerivedValues,
updateDerivedValues,
} from './dashboardVariablesStoreUtils';
const initialState: IDashboardVariablesStoreState = {
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
};
export const dashboardVariablesStore = createStore<IDashboardVariablesStoreState>(
initialState,
);
/**
* Set dashboard variables (replaces all variables)
*/
export function setDashboardVariablesStore({
dashboardId,
variables,
}: {
dashboardId: string;
variables: IDashboardVariablesStoreState['variables'];
}): void {
dashboardVariablesStore.set(() => {
return {
dashboardId,
variables,
...computeDerivedValues(variables),
} as IDashboardVariablesStoreState;
});
}
/**
* Update specific dashboard variables (merges with existing)
*/
export function updateDashboardVariablesStore({
dashboardId,
variables,
}: {
dashboardId: string;
variables: IDashboardVariablesStoreState['variables'];
}): void {
dashboardVariablesStore.update((draft) => {
if (draft.dashboardId !== dashboardId) {
// If dashboardId doesn't match, we replace the entire state
draft.dashboardId = dashboardId;
}
draft.variables = variables;
updateDerivedValues(draft);
});
}

View File

@@ -0,0 +1,31 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export type VariableGraph = Record<string, string[]>;
export interface IDependencyData {
order: string[];
graph: VariableGraph;
parentDependencyGraph: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
export type IDashboardVariables = Record<string, IDashboardVariable>;
export interface IDashboardVariablesStoreState {
// dashboard id
dashboardId: string;
// Raw variables keyed by id/name
variables: IDashboardVariables;
// Derived: sorted array of variables by order
sortedVariablesArray: IDashboardVariable[];
// Derived: dependency data for QUERY variables
dependencyData: IDependencyData | null;
}
export interface IUseDashboardVariablesReturn {
dashboardVariables: IDashboardVariablesStoreState['variables'];
}

View File

@@ -0,0 +1,89 @@
import {
buildDependencies,
buildDependencyGraph,
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import {
IDashboardVariables,
IDashboardVariablesStoreState,
IDependencyData,
} from './dashboardVariablesStoreTypes';
/**
* Build a sorted array of variables by their order property
*/
export function buildSortedVariablesArray(
variables: IDashboardVariables,
): IDashboardVariable[] {
const sortedVariablesArray: IDashboardVariable[] = [];
Object.values(variables).forEach((value) => {
sortedVariablesArray.push({ ...value });
});
sortedVariablesArray.sort((a, b) => a.order - b.order);
return sortedVariablesArray;
}
/**
* Build dependency data from sorted variables array
* This includes the dependency graph, topological order, and cycle detection
*/
export function buildDependencyData(
sortedVariablesArray: IDashboardVariable[],
): IDependencyData | null {
if (sortedVariablesArray.length === 0) {
return null;
}
const dependencies = buildDependencies(sortedVariablesArray);
const {
order,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
} = buildDependencyGraph(dependencies);
// Filter order to only include QUERY type variables
const queryVariableOrder = order.filter((variable: string) => {
const variableData = sortedVariablesArray.find((v) => v.name === variable);
return variableData?.type === 'QUERY';
});
return {
order: queryVariableOrder,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
};
}
/**
* Compute derived values from variables
* This is a composition of buildSortedVariablesArray and buildDependencyData
*/
export function computeDerivedValues(
variables: IDashboardVariablesStoreState['variables'],
): Pick<
IDashboardVariablesStoreState,
'sortedVariablesArray' | 'dependencyData'
> {
const sortedVariablesArray = buildSortedVariablesArray(variables);
const dependencyData = buildDependencyData(sortedVariablesArray);
return { sortedVariablesArray, dependencyData };
}
/**
* Update derived values in the store state (for use with immer)
*/
export function updateDerivedValues(
draft: IDashboardVariablesStoreState,
): void {
draft.sortedVariablesArray = buildSortedVariablesArray(draft.variables);
draft.dependencyData = buildDependencyData(draft.sortedVariablesArray);
}

View File

@@ -1,14 +0,0 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import createStore from './store';
// export type IDashboardVariables = DashboardData['variables'];
export type IDashboardVariables = Record<string, IDashboardVariable>;
export const dashboardVariablesStore = createStore<IDashboardVariables>({});
export function setDashboardVariablesStore(
variables: Partial<IDashboardVariables>,
): void {
dashboardVariablesStore.set(() => ({ ...variables }));
}

View File

@@ -569,8 +569,8 @@ func (d *Dispatcher) getOrCreateRoute(receiver string) *dispatch.Route {
route := &dispatch.Route{
RouteOpts: dispatch.RouteOpts{
Receiver: receiver,
GroupWait: 30 * time.Second,
GroupInterval: 5 * time.Minute,
GroupWait: d.route.RouteOpts.GroupWait,
GroupInterval: d.route.RouteOpts.GroupInterval,
GroupByAll: false,
},
Matchers: labels.Matchers{{

View File

@@ -1183,8 +1183,8 @@ func TestDispatcherRaceOnFirstAlertNotDeliveredWhenGroupWaitIsZero(t *testing.T)
route:
group_by: ['alertname']
group_wait: 1h
group_interval: 1h
group_wait: 30s
group_interval: 5m
receiver: 'slack'`
conf, err := config.Load(confData)
if err != nil {
@@ -1308,3 +1308,95 @@ func TestDispatcher_DoMaintenance(t *testing.T) {
require.False(t, isMuted)
require.Empty(t, mutedBy)
}
func TestDispatcher_GetOrCreateRoute(t *testing.T) {
testCases := []struct {
name string
confData string
expectedReceiver string
expectedGroupWait time.Duration
expectedGroupInterval time.Duration
expectedGroupByAll bool
expectedMatchersLen int
expectedMatcherName string
expectedMatcherValue string
}{
{
name: "create route for slack receiver",
confData: `receivers:
- name: 'slack'
- name: 'email'
- name: 'pagerduty'
route:
group_by: ['alertname']
group_wait: 1m
group_interval: 1m
receiver: 'slack'`,
expectedReceiver: "slack",
expectedGroupWait: 1 * time.Minute,
expectedGroupInterval: 1 * time.Minute,
expectedGroupByAll: false,
expectedMatchersLen: 1,
expectedMatcherName: "__receiver__",
expectedMatcherValue: "slack",
},
{
name: "no group_wait and group_interval use default values",
confData: `receivers:
- name: 'slack'
route:
group_by: ['alertname']
receiver: 'slack'`,
expectedReceiver: "slack",
expectedGroupWait: 30 * time.Second,
expectedGroupInterval: 5 * time.Minute,
expectedGroupByAll: false,
expectedMatchersLen: 1,
expectedMatcherName: "__receiver__",
expectedMatcherValue: "slack",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
conf, err := config.Load(tc.confData)
if err != nil {
t.Fatal(err)
}
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, nil, logger, nil)
if err != nil {
t.Fatal(err)
}
defer alerts.Close()
timeout := func(d time.Duration) time.Duration { return time.Duration(0) }
recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*alertmanagertypes.Alert)}
metrics := NewDispatcherMetrics(false, prometheus.NewRegistry())
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
nfManager, err := rulebasednotification.New(context.Background(), providerSettings, nfmanager.Config{}, store)
if err != nil {
t.Fatal(err)
}
d := NewDispatcher(alerts, route, recorder, marker, timeout, nil, logger, metrics, nfManager, "test-org")
// setup the dispatcher for tests
d.receiverRoutes = map[string]*dispatch.Route{}
newRoute := d.getOrCreateRoute(tc.expectedReceiver)
require.Equal(t, tc.expectedReceiver, newRoute.RouteOpts.Receiver)
require.Equal(t, tc.expectedGroupWait, newRoute.RouteOpts.GroupWait)
require.Equal(t, tc.expectedGroupInterval, newRoute.RouteOpts.GroupInterval)
require.Equal(t, tc.expectedGroupByAll, newRoute.RouteOpts.GroupByAll)
require.Equal(t, tc.expectedMatchersLen, len(newRoute.Matchers))
require.Equal(t, tc.expectedMatcherName, newRoute.Matchers[0].Name)
require.Equal(t, tc.expectedMatcherValue, newRoute.Matchers[0].Value)
})
}
}