Compare commits

...

57 Commits

Author SHA1 Message Date
Aditya Singh
001f14c017 Merge branch 'SIG-5603-dyn-var' of github.com:SigNoz/signoz into feat/cross-filtering-2 2025-09-05 20:53:18 +05:30
Aditya Singh
aa847b71ad Merge branch 'main' of github.com:SigNoz/signoz into feat/cross-filtering-2 2025-09-05 20:42:31 +05:30
SagarRajput-7
9ad6ab49f0 feat: added validation in variable edit panel 2025-09-05 17:47:07 +05:30
SagarRajput-7
8f6b853bb9 feat: added changes under flag to handle variable specific removal for removeKeysFromExpression func 2025-09-05 17:46:34 +05:30
SagarRajput-7
4e4f7cc521 feat: changed color for apply to all modal 2025-09-05 17:45:09 +05:30
SagarRajput-7
d794404f31 feat: rewrite functionality around add and remove panels 2025-09-05 17:45:09 +05:30
SagarRajput-7
7d81f1e665 feat: improved performance around multiselect component and added confirm modal for apply to all 2025-09-05 17:45:09 +05:30
SagarRajput-7
3b8033c7ec feat: checked for variable id instead of variable key for refetch 2025-09-05 17:45:09 +05:30
SagarRajput-7
70e6b61660 feat: added more space for search in multiselect component 2025-09-05 17:45:09 +05:30
SagarRajput-7
2b4cd5d1fb feat: reverted only - all updated area implementation 2025-09-05 17:45:09 +05:30
SagarRajput-7
73c279cac9 feat: fixed inconsist search implementations 2025-09-05 17:45:09 +05:30
SagarRajput-7
73ce96e3a6 feat: fixed infinite loop because of dependency of frequently changing object ref in var table 2025-09-05 17:45:09 +05:30
SagarRajput-7
8217e1e0cb feat: trucate + n more tooltip content to 10 2025-09-05 17:45:09 +05:30
SagarRajput-7
e24da43559 feat: handled all state distinction and carry forward in existing variables 2025-09-05 17:45:09 +05:30
SagarRajput-7
6efbce3ea1 feat: fix dropdown closing doesn't reset us back to our all available values when we have a search 2025-09-05 17:45:09 +05:30
SagarRajput-7
eb063f7ac0 feat: modified only/all click behaviour and set all selection always true for dynamic variable 2025-09-05 17:45:09 +05:30
SagarRajput-7
b01f8ae170 feat: aded variable name auto-update based on attribute name entered for dynamic variables 2025-09-05 17:45:09 +05:30
SagarRajput-7
10167f7cd1 feat: resolved variable tables infinite loop update error 2025-09-05 17:45:09 +05:30
SagarRajput-7
a98b56d994 feat: optimized localstorage for all selection in dynamic variable and updated __all__ case 2025-09-05 17:45:09 +05:30
SagarRajput-7
6eb2546398 feat: added check to prevent api and updates calls with same payload 2025-09-05 17:45:09 +05:30
SagarRajput-7
47b447099d feat: added beta and not rec. tag in variable tabs 2025-09-05 17:45:09 +05:30
SagarRajput-7
35a1875d45 feat: added option for regex in the component, disabled for now 2025-09-05 17:45:09 +05:30
SagarRajput-7
d971224169 feat: change value to searchtext in values API 2025-09-05 17:45:09 +05:30
SagarRajput-7
c72ef90209 feat: added empty name validation in variable creation 2025-09-05 17:45:09 +05:30
SagarRajput-7
6c41aa1420 feat: fixed variable tabel reordering issue 2025-09-05 17:45:09 +05:30
SagarRajput-7
d2a175db9c feat: updated panel wait and refetch logic and ALL option selection 2025-09-05 17:45:09 +05:30
SagarRajput-7
5a149a9a4f fix: fixed typechecks 2025-09-05 17:45:09 +05:30
SagarRajput-7
0319e1b816 feat: sanitized data storage and removed duplicates 2025-09-05 17:45:09 +05:30
SagarRajput-7
215707304a feat: added relatedValues and existing query in param related changes 2025-09-05 17:45:09 +05:30
SagarRajput-7
cb22545031 feat: added retries for dyn variable and fixed on-enter selection issue 2025-09-05 17:45:09 +05:30
SagarRajput-7
9f40bd6a9f feat: implemented where clause suggestion in new qb v5 2025-09-05 17:45:09 +05:30
SagarRajput-7
3a78b13e0c feat: added test cases for dynamic variable and add/remove panel feat 2025-09-05 17:45:09 +05:30
SagarRajput-7
2323ce4aeb feat: correct the variable addition to panel format for new qb expression 2025-09-05 17:45:09 +05:30
SagarRajput-7
72272799ee feat: added type in the variables in query_range payload for dynamic 2025-09-05 17:45:09 +05:30
SagarRajput-7
73d635149f fix: added migration to filter expression for crud operations of variable 2025-09-05 17:45:09 +05:30
SagarRajput-7
9bd55dfa6c feat: light-mode styles 2025-09-05 17:45:09 +05:30
SagarRajput-7
822338ace8 feat: added button loader for apply-all 2025-09-05 17:45:09 +05:30
SagarRajput-7
37bb8e95a8 feat: refectch only related and affected panels in case of dynamic variables 2025-09-05 17:45:09 +05:30
SagarRajput-7
7eaab9cd21 feat: added apply to all and variable removal logical 2025-09-05 17:45:08 +05:30
SagarRajput-7
7c97b8f880 feat: show labels in widget selector 2025-09-05 17:45:08 +05:30
SagarRajput-7
6f1dd4d10a feat: added widgetselector on variable creation 2025-09-05 17:45:08 +05:30
SagarRajput-7
8a9f67b17c feat: added ability to add/remove variable filter to one or more existing panels 2025-09-05 17:45:08 +05:30
SagarRajput-7
d16e26b5e4 feat: fixed test cases 2025-09-05 17:44:39 +05:30
SagarRajput-7
36dd024f69 feat: corrected the regex matcher for resolved titles 2025-09-05 17:44:39 +05:30
SagarRajput-7
12f61bdccf feat: code refactor 2025-09-05 17:44:39 +05:30
SagarRajput-7
46307ed4f4 feat: added test case for querybuildersearchv2 suggestion changes 2025-09-05 17:44:39 +05:30
SagarRajput-7
8e20150e48 feat: added test cases for hooks and api call functions 2025-09-05 17:44:39 +05:30
SagarRajput-7
15f857bced feat: added dynamic variable suggestion in where clause 2025-09-05 17:44:39 +05:30
SagarRajput-7
e4b0388de5 feat: fixed test case 2025-09-05 17:43:27 +05:30
SagarRajput-7
bcd2ebed47 feat: fix typo 2025-09-05 17:43:27 +05:30
SagarRajput-7
dae61cfa7a feat: fix lint and test cases 2025-09-05 17:43:26 +05:30
SagarRajput-7
c6b6e84db6 feat: added dynamic variable to the dashboard details (#7755)
* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases
2025-09-05 17:43:26 +05:30
SagarRajput-7
bf02c6b500 feat: added dynamic variables creation flow (#7541)
* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction
2025-09-05 17:43:26 +05:30
Abhi kumar
abeadc7672 fix: backward compatibility for explorer in case of aggregateAttribute is not present (#9000) 2025-09-04 13:21:16 +00:00
SagarRajput-7
faadc60c74 fix: fixed table panels not sorting, due to mismatch in lookup (id vs name) for aggregations (#9002)
* fix: fixed table panels not sorting, due to mismatch in id for aggregations

* fix: added test cases for the sort and util for qbv5 aggregation
2025-09-04 18:44:38 +05:30
Vibhu Pandey
360e8309c8 feat(password): implement strong controls for password (#8983)
## 📄 Summary

implement strong controls for password. Now the password requirement is : 

password must be at least 12 characters long, should contain at least one uppercase letter [A-Z], one lowercase letter [a-z], one number [0-9], and one symbol
2025-09-04 17:22:28 +05:30
SagarRajput-7
27580b62ba fix: fixed full view height for table panel (#9004) 2025-09-04 10:40:30 +00:00
83 changed files with 4808 additions and 823 deletions

View File

@@ -2,6 +2,7 @@ FROM node:18-bullseye AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 yarn install
RUN CI=1 yarn build

View File

@@ -13,11 +13,11 @@ import (
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/gorilla/mux"
"go.uber.org/zap"
)
@@ -192,14 +192,14 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
))
}
password, err := types.NewFactorPassword(uuid.NewString())
password := types.MustGenerateFactorPassword(newUser.ID.StringValue())
integrationUser, err := ah.Signoz.Modules.User.CreateUserWithPassword(ctx, newUser, password)
err = ah.Signoz.Modules.User.CreateUser(ctx, newUser, user.WithFactorPassword(password))
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}
return integrationUser, nil
return newUser, nil
}
func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (

View File

@@ -14,11 +14,11 @@ export const getFieldKeys = async (
const params: Record<string, string> = {};
if (signal) {
params.signal = signal;
params.signal = encodeURIComponent(signal);
}
if (name) {
params.name = name;
params.name = encodeURIComponent(name);
}
const response = await ApiBaseInstance.get('/fields/keys', { params });

View File

@@ -11,22 +11,23 @@ import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
export const getFieldValues = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
value?: string,
searchText?: string,
startUnixMilli?: number,
endUnixMilli?: number,
existingQuery?: string,
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = signal;
params.signal = encodeURIComponent(signal);
}
if (name) {
params.name = name;
params.name = encodeURIComponent(name);
}
if (value) {
params.value = value;
if (searchText) {
params.searchText = encodeURIComponent(searchText);
}
if (startUnixMilli) {
@@ -37,19 +38,35 @@ export const getFieldValues = async (
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
}
if (existingQuery) {
params.existingQuery = existingQuery;
}
const response = await ApiBaseInstance.get('/fields/values', { params });
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {
const allValues: string[] = [];
Object.values(response.data.data.values).forEach((valueArray: any) => {
if (Array.isArray(valueArray)) {
allValues.push(...valueArray.map(String));
}
});
Object.entries(response.data.data.values).forEach(
([key, valueArray]: [string, any]) => {
// Skip RelatedValues as they should be kept separate
if (key === 'relatedValues') {
return;
}
if (Array.isArray(valueArray)) {
allValues.push(...valueArray.map(String));
}
},
);
// Add a normalized values array to the response
response.data.data.normalizedValues = allValues;
// Add relatedValues to the response as per FieldValueResponse
if (response.data.data.values.relatedValues) {
response.data.data.relatedValues = response.data.data.values.relatedValues;
}
}
return {

View File

@@ -503,7 +503,7 @@ export const prepareQueryRangePayloadV5 = ({
value,
type: dynamicVariables
?.find((v) => v.name === key)
?.type.toLowerCase() as VariableType,
?.type?.toLowerCase() as VariableType,
};
return acc;
}, {} as Record<string, VariableItem>),

View File

@@ -13,7 +13,6 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -72,8 +71,6 @@ function Metrics({
[hostName, timeRange.startTime, timeRange.endTime, dotMetricsEnabled],
);
const { dynamicVariables } = useGetDynamicVariables();
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: ['host-metrics', payload, ENTITY_VERSION_V4, 'HOST'],
@@ -81,8 +78,7 @@ function Metrics({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4, dynamicVariables, signal),
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),

View File

@@ -23,6 +23,7 @@ import React, {
useRef,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { popupContainer } from 'utils/selectPopupContainer';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
@@ -66,6 +67,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onDropdownVisibleChange,
showIncompleteDataMessage = false,
showLabels = false,
enableRegexOption = false,
...rest
}) => {
// ===== State & Refs =====
@@ -546,12 +548,37 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Reset active index when search changes if dropdown is open
if (isOpen && trimmedValue) {
setActiveIndex(0);
setActiveIndex(-1);
// see if the trimmed value matched any option and set that active index
const matchedOption = filteredOptions.find(
(option) =>
option.label.toLowerCase() === trimmedValue.toLowerCase() ||
option.value?.toLowerCase() === trimmedValue.toLowerCase(),
);
if (matchedOption) {
setActiveIndex(1);
} else {
// check if the trimmed value is a regex pattern and set that active index
const isRegex =
trimmedValue.startsWith('.*') && trimmedValue.endsWith('.*');
if (isRegex && enableRegexOption) {
setActiveIndex(0);
} else {
setActiveIndex(enableRegexOption ? 1 : 0);
}
}
}
if (onSearch) onSearch(trimmedValue);
},
[onSearch, isOpen, selectedValues, onChange],
[
onSearch,
isOpen,
selectedValues,
onChange,
filteredOptions,
enableRegexOption,
],
);
// ===== UI & Rendering Functions =====
@@ -819,7 +846,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
// Add Regex to flat list
if (!isEmpty(searchText)) {
if (!isEmpty(searchText) && enableRegexOption) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
@@ -1361,6 +1388,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
extendSelection,
onDropdownVisibleChange,
handleSelectAll,
enableRegexOption,
],
);
@@ -1411,7 +1439,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const customOptions: OptionData[] = [];
// add regex options first since they appear first in the UI
if (!isEmpty(searchText)) {
if (!isEmpty(searchText) && enableRegexOption) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
@@ -1434,8 +1462,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
});
}
// Now add all custom options at the beginning
const enhancedNonSectionOptions = [...customOptions, ...nonSectionOptions];
// Now add all custom options at the beginning, removing duplicates based on value
const allOptions = [...customOptions, ...nonSectionOptions];
const seenValues = new Set<string>();
const enhancedNonSectionOptions = allOptions.filter((option) => {
const value = option.value || '';
if (seenValues.has(value)) {
return false;
}
seenValues.add(value);
return true;
});
const allOptionValues = getAllAvailableValues(processedOptions);
const allOptionsSelected =
@@ -1527,7 +1564,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{/* Non-section options when not searching */}
{enhancedNonSectionOptions.length > 0 && (
<div className="no-section-options">
{mapOptions(enhancedNonSectionOptions)}
<Virtuoso
style={{
minHeight: Math.min(300, enhancedNonSectionOptions.length * 40),
maxHeight: enhancedNonSectionOptions.length * 40,
}}
data={enhancedNonSectionOptions}
itemContent={(index, item): React.ReactNode =>
(mapOptions([item]) as unknown) as React.ReactElement
}
totalCount={enhancedNonSectionOptions.length}
itemSize={(): number => 40}
overscan={5}
/>
</div>
)}
@@ -1540,10 +1589,24 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{section.label}
</div>
<div role="group" aria-label={`${section.label} options`}>
{section.options && mapOptions(section.options)}
<Virtuoso
style={{
minHeight: Math.min(300, (section.options?.length || 0) * 40),
maxHeight: (section.options?.length || 0) * 40,
}}
data={section.options || []}
itemContent={(index, item): React.ReactNode =>
(mapOptions([item]) as unknown) as React.ReactElement
}
totalCount={section.options?.length || 0}
itemSize={(): number => 40}
overscan={5}
/>
</div>
</div>
) : null,
) : (
<div key={section.label} />
),
)}
{/* Navigation help footer */}
@@ -1573,15 +1636,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
{onRetry && (
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
onRetry();
}}
/>
</div>
)}
</div>
)}
@@ -1626,6 +1691,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
enableRegexOption,
]);
// Custom handler for dropdown visibility changes

View File

@@ -555,15 +555,17 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
{onRetry && (
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
onRetry();
}}
/>
</div>
)}
</div>
)}

View File

@@ -78,6 +78,7 @@ $custom-border-color: #2c3044;
scrollbar-width: thin;
background-color: var(--bg-ink-400);
border-color: var(--bg-slate-400);
cursor: text;
&::-webkit-scrollbar {
width: 6px;
@@ -93,6 +94,16 @@ $custom-border-color: #2c3044;
}
}
// Ensure adequate space for input area
.ant-select-selection-search {
min-width: 60px !important;
flex: 1 1 auto;
.ant-select-selection-search-input {
min-width: 60px !important;
cursor: text;
}
}
&.ant-select-focused {
.ant-select-selector {
border-color: var(--bg-robin-500);
@@ -396,6 +407,7 @@ $custom-border-color: #2c3044;
.select-group {
margin-bottom: 12px;
overflow: hidden;
margin-top: 4px;
.group-label {
font-weight: 500;
@@ -678,6 +690,7 @@ $custom-border-color: #2c3044;
.ant-select-selector {
background-color: var(--bg-vanilla-100);
border-color: #e9e9e9;
cursor: text; // Make entire selector clickable for input focus
&::-webkit-scrollbar-thumb {
background-color: #ccc;
@@ -688,6 +701,20 @@ $custom-border-color: #2c3044;
}
}
.ant-select-selection-search {
min-width: 60px !important;
flex: 1 1 auto;
.ant-select-selection-search-input {
min-width: 60px !important;
cursor: text;
}
}
.ant-select-selector {
cursor: text;
}
.ant-select-selection-placeholder {
color: rgba(0, 0, 0, 0.45);
}

View File

@@ -61,4 +61,5 @@ export interface CustomMultiSelectProps
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
showLabels?: boolean;
enableRegexOption?: boolean;
}

View File

@@ -1,4 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { uniqueOptions } from 'container/NewDashboard/DashboardVariablesSelection/util';
import { OptionData } from './types';
export const SPACEKEY = ' ';
@@ -98,8 +100,10 @@ export const prioritizeOrAddOptionForMultiSelect = (
label: labels?.[value] ?? value, // Use provided label or default to value
}));
const flatOutSelectedOptions = uniqueOptions([...newOptions, ...foundOptions]);
// Add found & new options to the top
return [...newOptions, ...foundOptions, ...filteredOptions];
return [...flatOutSelectedOptions, ...filteredOptions];
};
/**

View File

@@ -32,12 +32,14 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Info, TriangleAlert } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
IDetailedError,
IQueryContext,
IValidationResult,
} from 'types/antlrQueryTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -161,13 +163,15 @@ function QuerySearch({
const { handleRunQuery } = useQueryBuilder();
// const {
// data: queryKeySuggestions,
// refetch: refetchQueryKeySuggestions,
// } = useGetQueryKeySuggestions({
// signal: dataSource,
// name: searchText || '',
// });
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
);
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
@@ -982,6 +986,25 @@ function QuerySearch({
option.label.toLowerCase().includes(searchText),
);
// Add dynamic variables suggestions for the current key
const variableName = dynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === keyName,
)?.name;
if (variableName) {
const variableValue = `$${variableName}`;
const variableOption = {
label: variableValue,
type: 'variable',
apply: variableValue,
};
// Add variable suggestion at the beginning if it matches the search text
if (variableValue.toLowerCase().includes(searchText.toLowerCase())) {
options = [variableOption, ...options];
}
}
// Trigger fetch only if needed
const shouldFetch =
// Fetch only if key is available
@@ -1034,6 +1057,9 @@ function QuerySearch({
} else if (option.type === 'array') {
// Arrays are already formatted as arrays
processedOption.apply = option.label;
} else if (option.type === 'variable') {
// Variables should be used as-is (they already have the $ prefix)
processedOption.apply = option.label;
}
return processedOption;

View File

@@ -1,12 +1,19 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable import/no-unresolved */
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
convertAggregationToExpression,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
removeKeysFromExpression,
} from '../utils';
describe('convertFiltersToExpression', () => {
@@ -769,3 +776,420 @@ describe('convertFiltersToExpression', () => {
expect(result.filter.expression).toBe("service.name = 'old-service'");
});
});
describe('convertAggregationToExpression', () => {
const mockAttribute: BaseAutocompleteData = {
id: 'test-id',
key: 'test_metric',
type: 'string',
dataType: DataTypes.String,
};
it('should return undefined when no aggregateOperator is provided', () => {
const result = convertAggregationToExpression({
aggregateOperator: '',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
});
expect(result).toBeUndefined();
});
it('should convert metrics aggregation with required temporality field', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
timeAggregation: 'avg',
spaceAggregation: 'max',
alias: 'test_alias',
reduceTo: 'sum',
temporality: 'delta',
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'avg',
spaceAggregation: 'max',
reduceTo: 'sum',
temporality: 'delta',
},
]);
});
it('should handle noop operators by converting to count', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'noop',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
timeAggregation: 'noop',
spaceAggregation: 'noop',
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'count',
spaceAggregation: 'count',
},
]);
});
it('should handle missing attribute key gracefully', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: { ...mockAttribute, key: '' },
dataSource: DataSource.METRICS,
});
expect(result).toEqual([
{
metricName: '',
timeAggregation: 'sum',
spaceAggregation: 'sum',
},
]);
});
it('should convert traces aggregation to expression format', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'count',
aggregateAttribute: mockAttribute,
dataSource: DataSource.TRACES,
alias: 'trace_alias',
});
expect(result).toEqual([
{
expression: 'count(test_metric)',
alias: 'trace_alias',
},
]);
});
it('should convert logs aggregation to expression format', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'avg',
aggregateAttribute: mockAttribute,
dataSource: DataSource.LOGS,
alias: 'log_alias',
});
expect(result).toEqual([
{
expression: 'avg(test_metric)',
alias: 'log_alias',
},
]);
});
it('should handle aggregation without attribute key for traces/logs', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'count',
aggregateAttribute: { ...mockAttribute, key: '' },
dataSource: DataSource.TRACES,
});
expect(result).toEqual([
{
expression: 'count()',
},
]);
});
it('should handle missing alias for traces/logs', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: mockAttribute,
dataSource: DataSource.LOGS,
});
expect(result).toEqual([
{
expression: 'sum(test_metric)',
},
]);
});
it('should use aggregateOperator as fallback for time and space aggregation', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'max',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'max',
spaceAggregation: 'max',
},
]);
});
it('should handle undefined aggregateAttribute parameter with metrics', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'sum',
aggregateAttribute: mockAttribute,
dataSource: DataSource.METRICS,
});
expect(result).toEqual([
{
metricName: 'test_metric',
timeAggregation: 'sum',
spaceAggregation: 'sum',
reduceTo: undefined,
temporality: undefined,
},
]);
});
it('should handle undefined aggregateAttribute parameter with traces', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'noop',
aggregateAttribute: (undefined as unknown) as BaseAutocompleteData,
dataSource: DataSource.TRACES,
});
expect(result).toEqual([
{
expression: 'count()',
},
]);
});
it('should handle undefined aggregateAttribute parameter with logs', () => {
const result = convertAggregationToExpression({
aggregateOperator: 'noop',
aggregateAttribute: (undefined as unknown) as BaseAutocompleteData,
dataSource: DataSource.LOGS,
});
expect(result).toEqual([
{
expression: 'count()',
},
]);
});
});
describe('removeKeysFromExpression', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Backward compatibility (removeOnlyVariableExpressions = false)', () => {
it('should remove simple key-value pair from expression', () => {
const expression = "service.name = 'api-gateway' AND status = 'success'";
const result = removeKeysFromExpression(expression, ['service.name']);
expect(result).toBe("status = 'success'");
});
it('should remove multiple keys from expression', () => {
const expression =
"service.name = 'api-gateway' AND status = 'success' AND region = 'us-east-1'";
const result = removeKeysFromExpression(expression, [
'service.name',
'status',
]);
expect(result).toBe("region = 'us-east-1'");
});
it('should handle empty expression', () => {
const result = removeKeysFromExpression('', ['service.name']);
expect(result).toBe('');
});
it('should handle empty keys array', () => {
const expression = "service.name = 'api-gateway'";
const result = removeKeysFromExpression(expression, []);
expect(result).toBe(expression);
});
it('should handle key not found in expression', () => {
const expression = "service.name = 'api-gateway'";
const result = removeKeysFromExpression(expression, ['nonexistent.key']);
expect(result).toBe(expression);
});
// todo: Sagar check this - this is expected or not
// it('should remove last occurrence when multiple occurrences exist', () => {
// // This tests the original behavior - should remove the last occurrence
// const expression =
// "deployment.environment = $deployment.environment deployment.environment = 'default'";
// const result = removeKeysFromExpression(
// expression,
// ['deployment.environment'],
// false,
// );
// // Should remove the literal value (last occurrence), leaving the variable
// expect(result).toBe('deployment.environment = $deployment.environment');
// });
});
describe('Variable expression targeting (removeOnlyVariableExpressions = true)', () => {
it('should remove only variable expressions (values starting with $)', () => {
const expression =
"deployment.environment = $deployment.environment deployment.environment = 'default'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
// Should remove the variable expression, leaving the literal value
expect(result).toBe("deployment.environment = 'default'");
});
it('should not remove literal values when targeting variable expressions', () => {
const expression = "service.name = 'api-gateway' AND status = 'success'";
const result = removeKeysFromExpression(expression, ['service.name'], true);
// Should not remove anything since no variable expressions exist
expect(result).toBe("service.name = 'api-gateway' AND status = 'success'");
});
it('should remove multiple variable expressions', () => {
const expression =
"deployment.environment = $deployment.environment service.name = $service.name status = 'success'";
const result = removeKeysFromExpression(
expression,
['deployment.environment', 'service.name'],
true,
);
expect(result).toBe("status = 'success'");
});
it('should handle mixed variable and literal expressions correctly', () => {
const expression =
"deployment.environment = $deployment.environment service.name = 'api-gateway' region = $region";
const result = removeKeysFromExpression(
expression,
['deployment.environment', 'region'],
true,
);
// Should only remove variable expressions, leaving literal value
expect(result).toBe("service.name = 'api-gateway'");
});
it('should handle complex expressions with operators', () => {
const expression =
"deployment.environment IN [$env1, $env2] AND service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
});
describe('Edge cases and robustness', () => {
it('should handle case insensitive key matching', () => {
const expression = 'Service.Name = $Service.Name';
const result = removeKeysFromExpression(expression, ['service.name'], true);
expect(result).toBe('');
});
it('should clean up trailing AND/OR operators', () => {
const expression =
"deployment.environment = $deployment.environment AND service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
it('should clean up leading AND/OR operators', () => {
const expression =
"service.name = 'api-gateway' AND deployment.environment = $deployment.environment";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
it('should handle expressions with only variable assignments', () => {
const expression = 'deployment.environment = $deployment.environment';
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe('');
});
it('should handle whitespace around operators', () => {
const expression =
"deployment.environment = $deployment.environment AND service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result.trim()).toBe("service.name = 'api-gateway'");
});
});
describe('Real-world scenarios', () => {
it('should handle multiple variable instances of same key', () => {
const expression =
"deployment.environment = $env1 deployment.environment = $env2 deployment.environment = 'default'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
// Should remove one occurence as this case in itself is invalid to have multiple variable expressions for the same key
expect(result).toBe(
"deployment.environment = $env1 deployment.environment = 'default'",
);
});
it('should handle OR operators in expressions', () => {
const expression =
"deployment.environment = $deployment.environment OR service.name = 'api-gateway'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
expect(result).toBe("service.name = 'api-gateway'");
});
it('should maintain expression validity after removal', () => {
const expression =
"deployment.environment = $deployment.environment AND service.name = 'api-gateway' AND status = 'success'";
const result = removeKeysFromExpression(
expression,
['deployment.environment'],
true,
);
// Should maintain valid AND structure
expect(result).toBe("service.name = 'api-gateway' AND status = 'success'");
// Verify the result can be parsed by extractQueryPairs
const pairs = extractQueryPairs(result);
expect(pairs).toHaveLength(2);
});
});
});

View File

@@ -22,7 +22,7 @@ import {
TraceAggregation,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { unquote } from 'utils/stringUtils';
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
@@ -477,11 +477,13 @@ export const convertFiltersToExpressionWithExistingQuery = (
*
* @param expression - The full query string.
* @param keysToRemove - An array of keys (case-insensitive) that should be removed from the expression.
* @param removeOnlyVariableExpressions - When true, only removes key-value pairs where the value is a variable (starts with $). When false, uses the original behavior.
* @returns A new expression string with the specified keys and their associated clauses removed.
*/
export const removeKeysFromExpression = (
expression: string,
keysToRemove: string[],
removeOnlyVariableExpressions = false,
): string => {
if (!keysToRemove || keysToRemove.length === 0) {
return expression;
@@ -497,9 +499,20 @@ export const removeKeysFromExpression = (
let queryPairsMap: Map<string, IQueryPair>;
if (existingQueryPairs.length > 0) {
// Filter query pairs based on the removeOnlyVariableExpressions flag
const filteredQueryPairs = removeOnlyVariableExpressions
? existingQueryPairs.filter((pair) => {
const pairKey = pair.key?.trim().toLowerCase();
const matchesKey = pairKey === `${key}`.trim().toLowerCase();
if (!matchesKey) return false;
const value = pair.value?.toString().trim();
return value && value.includes('$');
})
: existingQueryPairs;
// Build a map for quick lookup of query pairs by their lowercase trimmed keys
queryPairsMap = new Map(
existingQueryPairs.map((pair) => {
filteredQueryPairs.map((pair) => {
const key = pair.key.trim().toLowerCase();
return [key, pair];
}),
@@ -535,6 +548,12 @@ export const removeKeysFromExpression = (
}
}
});
// Clean up any remaining trailing AND/OR operators and extra whitespace
updatedExpression = updatedExpression
.replace(/\s+(AND|OR)\s*$/i, '') // Remove trailing AND/OR
.replace(/^(AND|OR)\s+/i, '') // Remove leading AND/OR
.trim();
}
return updatedExpression;
@@ -591,14 +610,25 @@ export const convertHavingToExpression = (
* @returns New aggregation format based on data source
*
*/
export const convertAggregationToExpression = (
aggregateOperator: string,
aggregateAttribute: BaseAutocompleteData,
dataSource: DataSource,
timeAggregation?: string,
spaceAggregation?: string,
alias?: string,
): (TraceAggregation | LogAggregation | MetricAggregation)[] | undefined => {
export const convertAggregationToExpression = ({
aggregateOperator,
aggregateAttribute,
dataSource,
timeAggregation,
spaceAggregation,
alias,
reduceTo,
temporality,
}: {
aggregateOperator: string;
aggregateAttribute: BaseAutocompleteData;
dataSource: DataSource;
timeAggregation?: string;
spaceAggregation?: string;
alias?: string;
reduceTo?: ReduceOperators;
temporality?: string;
}): (TraceAggregation | LogAggregation | MetricAggregation)[] | undefined => {
// Skip if no operator or attribute key
if (!aggregateOperator) {
return undefined;
@@ -616,7 +646,9 @@ export const convertAggregationToExpression = (
if (dataSource === DataSource.METRICS) {
return [
{
metricName: aggregateAttribute.key,
metricName: aggregateAttribute?.key || '',
reduceTo,
temporality,
timeAggregation: (normalizedTimeAggregation || normalizedOperator) as any,
spaceAggregation: (normalizedSpaceAggregation || normalizedOperator) as any,
} as MetricAggregation,
@@ -624,7 +656,9 @@ export const convertAggregationToExpression = (
}
// For traces and logs, use expression format
const expression = `${normalizedOperator}(${aggregateAttribute.key})`;
const expression = aggregateAttribute?.key
? `${normalizedOperator}(${aggregateAttribute?.key})`
: `${normalizedOperator}()`;
if (dataSource === DataSource.TRACES) {
return [

View File

@@ -30,7 +30,7 @@
height: calc(100% - 40px);
}
.list-graph-container {
.full-view-graph-container {
height: calc(100% - 40px);
overflow-y: auto;
}

View File

@@ -295,7 +295,7 @@ function FullView({
disabled: isDashboardLocked,
'height-widget':
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
'list-graph-container': isListView,
'full-view-graph-container': isListView,
})}
ref={fullViewRef}
>

View File

@@ -4,8 +4,6 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
@@ -55,6 +53,7 @@ function GridCardGraph({
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
widgetsHavingDynamicVariables,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@@ -65,18 +64,13 @@ function GridCardGraph({
toScrollWidgetId,
setToScrollWidgetId,
setDashboardQueryRangeCalled,
variablesToGetUpdated,
} = useDashboard();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { dynamicVariables } = useGetDynamicVariables();
const dynamicVariableToWidgetsMap = useMemo(
() => createDynamicVariableToWidgetsMap(dynamicVariables, [widget]),
[dynamicVariables, widget],
);
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
const startTime = searchParams.get(QueryParams.startTime);
@@ -203,6 +197,27 @@ function GridCardGraph({
[requestData.query],
);
// Bring back dependency on variable chaining for panels to refetch,
// but only for non-dynamic variables. We derive a stable token from
// the head of the variablesToGetUpdated queue when it's non-dynamic.
const nonDynamicVariableChainToken = useMemo(() => {
if (!variablesToGetUpdated || variablesToGetUpdated.length === 0) {
return undefined;
}
if (!variables) {
return undefined;
}
const headName = variablesToGetUpdated[0];
const variableObj = Object.values(variables).find(
(variable) => variable?.name === headName,
);
if (variableObj && variableObj.type !== 'DYNAMIC') {
return headName;
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variablesToGetUpdated, variables]);
const queryResponse = useGetQueryRange(
{
...requestData,
@@ -231,8 +246,8 @@ function GridCardGraph({
? Object.entries(variables).reduce((acc, [id, variable]) => {
if (
variable.type !== 'DYNAMIC' ||
(dynamicVariableToWidgetsMap?.[id] &&
dynamicVariableToWidgetsMap?.[id].includes(widget.id))
(widgetsHavingDynamicVariables?.[variable.id] &&
widgetsHavingDynamicVariables?.[variable.id].includes(widget.id))
) {
return { ...acc, [id]: variable.selectedValue };
}
@@ -242,6 +257,9 @@ function GridCardGraph({
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
? [customTimeRange.startTime, customTimeRange.endTime]
: []),
// Include non-dynamic variable chaining token to drive refetches
// only when a non-dynamic variable is at the head of the queue
...(nonDynamicVariableChainToken ? [nonDynamicVariableChainToken] : []),
],
retry(failureCount, error): boolean {
if (
@@ -254,7 +272,7 @@ function GridCardGraph({
return failureCount < 2;
},
keepPreviousData: true,
enabled: queryEnabledCondition,
enabled: queryEnabledCondition && !nonDynamicVariableChainToken,
refetchOnMount: false,
onError: (error) => {
const errorMessage =

View File

@@ -71,6 +71,7 @@ export interface GridCardGraphProps {
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
widgetsHavingDynamicVariables?: Record<string, string[]>;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@@ -12,6 +12,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { DEFAULT_ROW_NAME } from 'container/NewDashboard/DashboardDescription/utils';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -35,7 +36,7 @@ import { ItemCallback, Layout } from 'react-grid-layout';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { UpdateTimeInterval } from 'store/actions';
import { Widgets } from 'types/api/dashboard/getAll';
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
@@ -98,6 +99,22 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
Record<string, { widgets: Layout[]; collapsed: boolean }>
>({});
const widgetsHavingDynamicVariables = useMemo(() => {
const dynamicVariables = Object.values(
selectedDashboard?.data?.variables || {},
)?.filter((variable: IDashboardVariable) => variable.type === 'DYNAMIC');
const widgets =
selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || [];
return createDynamicVariableToWidgetsMap(
dynamicVariables,
widgets as Widgets[],
);
}, [selectedDashboard]);
useEffect(() => {
setCurrentPanelMap(panelMap);
}, [panelMap]);
@@ -586,6 +603,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
enableDrillDown={enableDrillDown}
widgetsHavingDynamicVariables={widgetsHavingDynamicVariables}
/>
</Card>
</CardContainer>

View File

@@ -2,13 +2,14 @@ import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useCallback } from 'react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useMemo } from 'react';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
@@ -35,7 +36,15 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const queryRangeMutation = useMutation(getSubstituteVars);
const { dynamicVariables } = useGetDynamicVariables();
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
);
const getUpdatedQuery = useCallback(
async ({

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
export const tableDataMultipleQueriesSuccessResponse = {
columns: [
{
@@ -210,3 +211,278 @@ export const expectedOutputWithLegends = {
},
],
};
// QB v5 Aggregations Mock Data
export const tableDataQBv5MultiAggregations = {
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{
name: 'host.name',
queryName: 'A',
isValueColumn: false,
id: 'host.name',
},
{
name: 'count()',
queryName: 'A',
isValueColumn: true,
id: 'A.count()',
},
{
name: 'count_distinct(app.ads.count)',
queryName: 'A',
isValueColumn: true,
id: 'A.count_distinct(app.ads.count)',
},
{
name: 'count()',
queryName: 'B',
isValueColumn: true,
id: 'B.count()',
},
{
name: 'count_distinct(app.ads.count)',
queryName: 'B',
isValueColumn: true,
id: 'B.count_distinct(app.ads.count)',
},
{
name: 'count()',
queryName: 'C',
isValueColumn: true,
id: 'C.count()',
},
{
name: 'count_distinct(app.ads.count)',
queryName: 'C',
isValueColumn: true,
id: 'C.count_distinct(app.ads.count)',
},
],
rows: [
{
data: {
'service.name': 'frontend-proxy',
'host.name': 'test-host.name',
'A.count()': 144679,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 144679,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 144679,
'C.count_distinct(app.ads.count)': 0,
},
},
{
data: {
'service.name': 'frontend',
'host.name': 'test-host.name',
'A.count()': 142311,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 142311,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 142311,
'C.count_distinct(app.ads.count)': 0,
},
},
],
};
export const widgetQueryQBv5MultiAggregations = {
clickhouse_sql: [
{
name: 'A',
legend: 'p99',
disabled: false,
query: '',
},
{
name: 'B',
legend: '',
disabled: false,
query: '',
},
{
name: 'C',
legend: 'max',
disabled: false,
query: '',
},
],
promql: [
{
name: 'A',
query: '',
legend: 'p99',
disabled: false,
},
{
name: 'B',
query: '',
legend: '',
disabled: false,
},
{
name: 'C',
query: '',
legend: 'max',
disabled: false,
},
],
builder: {
queryData: [
{
dataSource: 'metrics',
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: 'float64',
id: 'signoz_latency--float64--ExponentialHistogram--true',
key: 'signoz_latency',
type: 'ExponentialHistogram',
},
timeAggregation: '',
spaceAggregation: 'p90',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [],
groupBy: [
{
dataType: 'string',
key: 'service.name',
type: 'tag',
id: 'service.name--string--tag--false',
},
{
dataType: 'string',
key: 'host.name',
type: 'tag',
id: 'host.name--string--tag--false',
},
],
legend: 'p99',
reduceTo: 'avg',
},
{
dataSource: 'metrics',
queryName: 'B',
aggregateOperator: 'rate',
aggregateAttribute: {
dataType: 'float64',
id: 'system_disk_operations--float64--Sum--true',
key: 'system_disk_operations',
type: 'Sum',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'B',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [],
groupBy: [
{
dataType: 'string',
key: 'service.name',
type: 'tag',
id: 'service.name--string--tag--false',
},
{
dataType: 'string',
key: 'host.name',
type: 'tag',
id: 'host.name--string--tag--false',
},
],
legend: '',
reduceTo: 'avg',
},
{
dataSource: 'metrics',
queryName: 'C',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: 'float64',
id: 'signoz_latency--float64--ExponentialHistogram--true',
key: 'signoz_latency',
type: 'ExponentialHistogram',
},
timeAggregation: '',
spaceAggregation: 'p90',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'C',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [],
groupBy: [
{
dataType: 'string',
key: 'service.name',
type: 'tag',
id: 'service.name--string--tag--false',
},
{
dataType: 'string',
key: 'host.name',
type: 'tag',
id: 'host.name--string--tag--false',
},
],
legend: 'max',
reduceTo: 'avg',
},
],
queryFormulas: [],
},
id: 'qb-v5-multi-aggregations-test',
queryType: 'builder',
};
export const expectedOutputQBv5MultiAggregations = {
dataSource: [
{
'service.name': 'frontend-proxy',
'host.name': 'test-host.name',
'A.count()': 144679,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 144679,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 144679,
'C.count_distinct(app.ads.count)': 0,
},
{
'service.name': 'frontend',
'host.name': 'test-host.name',
'A.count()': 142311,
'A.count_distinct(app.ads.count)': 0,
'B.count()': 142311,
'B.count_distinct(app.ads.count)': 0,
'C.count()': 142311,
'C.count_distinct(app.ads.count)': 0,
},
],
};

View File

@@ -6,8 +6,11 @@ import {
sortFunction,
} from '../utils';
import {
expectedOutputQBv5MultiAggregations,
expectedOutputWithLegends,
tableDataMultipleQueriesSuccessResponse,
tableDataQBv5MultiAggregations,
widgetQueryQBv5MultiAggregations,
widgetQueryWithLegend,
} from './response';
@@ -67,6 +70,7 @@ describe('Table Panel utils', () => {
isValueColumn: true,
name: 'A',
queryName: 'A',
id: 'A',
};
// A has value and value is considered bigger than n/a hence 1
expect(sortFunction(rowA, rowB, item)).toBe(1);
@@ -128,3 +132,96 @@ describe('Table Panel utils', () => {
expect(sortFunction(rowA, rowB, item)).toBe(0);
});
});
describe('Table Panel utils with QB v5 aggregations', () => {
it('createColumnsAndDataSource function - QB v5 multi-aggregations', () => {
const data = tableDataQBv5MultiAggregations;
const query = widgetQueryQBv5MultiAggregations as Query;
const { columns, dataSource } = createColumnsAndDataSource(data, query);
// Verify column structure for multi-aggregations
expect(columns).toHaveLength(8);
expect(columns[0].title).toBe('service.name');
expect(columns[1].title).toBe('host.name');
// All columns with queryName 'A' get the legend 'p99'
expect(columns[2].title).toBe('p99'); // A.count() uses legend from query A
expect(columns[3].title).toBe('p99'); // A.count_distinct() uses legend from query A
expect(columns[4].title).toBe('count()'); // B.count() uses column name (no legend)
expect(columns[5].title).toBe('count_distinct(app.ads.count)'); // B.count_distinct() uses column name
expect(columns[6].title).toBe('max'); // C.count() uses legend from query C
expect(columns[7].title).toBe('max'); // C.count_distinct() uses legend from query C
// Verify dataIndex mapping
expect((columns[0] as any).dataIndex).toBe('service.name');
expect((columns[2] as any).dataIndex).toBe('A.count()');
expect((columns[3] as any).dataIndex).toBe('A.count_distinct(app.ads.count)');
// Verify dataSource structure
expect(dataSource).toStrictEqual(
expectedOutputQBv5MultiAggregations.dataSource,
);
});
it('getQueryLegend function - QB v5 multi-query support', () => {
const query = widgetQueryQBv5MultiAggregations as Query;
expect(getQueryLegend(query, 'A')).toBe('p99');
expect(getQueryLegend(query, 'B')).toBeUndefined();
expect(getQueryLegend(query, 'C')).toBe('max');
expect(getQueryLegend(query, 'D')).toBeUndefined();
});
it('sorter function - QB v5 multi-aggregation columns', () => {
const item = {
isValueColumn: true,
name: 'count()',
queryName: 'A',
id: 'A.count()',
};
// Test numeric sorting
expect(
sortFunction(
{ 'A.count()': 100, key: '1', timestamp: 1000 },
{ 'A.count()': 200, key: '2', timestamp: 1000 },
item,
),
).toBe(-100);
// Test n/a handling
expect(
sortFunction(
{ 'A.count()': 'n/a', key: '1', timestamp: 1000 },
{ 'A.count()': 100, key: '2', timestamp: 1000 },
item,
),
).toBe(-1);
expect(
sortFunction(
{ 'A.count()': 100, key: '1', timestamp: 1000 },
{ 'A.count()': 'n/a', key: '2', timestamp: 1000 },
item,
),
).toBe(1);
// Test string sorting
expect(
sortFunction(
{ 'A.count()': 'read', key: '1', timestamp: 1000 },
{ 'A.count()': 'write', key: '2', timestamp: 1000 },
item,
),
).toBe(-1);
// Test equal values
expect(
sortFunction(
{ 'A.count()': 'n/a', key: '1', timestamp: 1000 },
{ 'A.count()': 'n/a', key: '2', timestamp: 1000 },
item,
),
).toBe(0);
});
});

View File

@@ -156,11 +156,14 @@ export function sortFunction(
name: string;
queryName: string;
isValueColumn: boolean;
id: string;
},
): number {
const colId = item.id;
const colName = item.name;
// assumption :- number values is bigger than 'n/a'
const valueA = Number(a[`${item.name}_without_unit`] ?? a[item.name]);
const valueB = Number(b[`${item.name}_without_unit`] ?? b[item.name]);
const valueA = Number(a[`${colId}_without_unit`] ?? a[colId] ?? a[colName]);
const valueB = Number(b[`${colId}_without_unit`] ?? b[colId] ?? b[colName]);
// if both the values are numbers then return the difference here
if (!isNaN(valueA) && !isNaN(valueB)) {
@@ -178,10 +181,11 @@ export function sortFunction(
}
// if both of them are strings do the localecompare
return ((a[item.name] as string) || '').localeCompare(
(b[item.name] as string) || '',
return ((a[colId] as string) || (a[colName] as string) || '').localeCompare(
(b[colId] as string) || (b[colName] as string) || '',
);
}
export function createColumnsAndDataSource(
data: TableData,
currentQuery: Query,
@@ -206,7 +210,7 @@ export function createColumnsAndDataSource(
width: QUERY_TABLE_CONFIG.width,
isValueColumn: item.isValueColumn,
queryName: item.queryName,
render: renderColumnCell && renderColumnCell[item.name],
render: renderColumnCell && renderColumnCell[item.id],
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
};

View File

@@ -15,7 +15,6 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -99,8 +98,6 @@ function EntityMetrics<T>({
],
);
const { dynamicVariables } = useGetDynamicVariables();
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [queryKey, payload, ENTITY_VERSION_V4, category],
@@ -108,8 +105,7 @@ function EntityMetrics<T>({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4, dynamicVariables, signal),
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),

View File

@@ -50,8 +50,10 @@ jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
}));
const mockUseQueries = jest.fn();
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
useQuery: (config: any): any => mockUseQuery(config),
}));
jest.mock('hooks/useDarkMode', () => ({
@@ -302,6 +304,20 @@ describe('EntityMetrics', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueries.mockReturnValue(mockQueries);
mockUseQuery.mockReturnValue({
data: {
data: {
data: {
variables: {},
title: 'Test Dashboard',
},
id: 'test-dashboard-id',
},
},
isLoading: false,
isError: false,
refetch: jest.fn(),
});
});
it('should render metrics with data', () => {

View File

@@ -4,6 +4,12 @@
font-style: italic;
}
.apply-to-all-variable-name {
font-weight: 700;
color: var(--bg-robin-400);
font-style: italic;
}
.dashboard-variable-settings-table {
.variable-name-drag {
display: flex;

View File

@@ -1,42 +1,47 @@
.dynamic-variable-container {
display: grid;
grid-template-columns: 1fr 32px 200px;
gap: 32px;
align-items: center;
width: 100%;
margin: 24px 0;
.ant-select {
.ant-select-selector {
.dynamic-variable-config-container {
display: grid;
grid-template-columns: 1fr 32px 200px;
gap: 32px;
align-items: center;
width: 100%;
.ant-select {
.ant-select-selector {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
}
}
.ant-input {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
max-width: 300px;
}
}
.ant-input {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
max-width: 300px;
}
.dynamic-variable-from-text {
font-family: 'Space Mono';
font-size: 13px;
font-weight: 500;
white-space: nowrap;
.dynamic-variable-from-text {
font-family: 'Space Mono';
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
}
}
.lightMode {
.dynamic-variable-container {
.ant-select {
.ant-select-selector {
.dynamic-variable-config-container {
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
}
}
.ant-input {
border: 1px solid var(--bg-vanilla-300);
}
}
.ant-input {
border: 1px solid var(--bg-vanilla-300);
}
}
}

View File

@@ -25,6 +25,7 @@ enum AttributeSource {
function DynamicVariable({
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue,
errorAttributeKeyMessage,
}: {
setDynamicVariablesSelectedValue: Dispatch<
SetStateAction<
@@ -41,6 +42,7 @@ function DynamicVariable({
value: string;
}
| undefined;
errorAttributeKeyMessage?: string;
}): JSX.Element {
const sources = [
AttributeSource.ALL_SOURCES,
@@ -53,7 +55,7 @@ function DynamicVariable({
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
const [selectedAttribute, setSelectedAttribute] = useState<string>();
const [apiSearchText, setApiSearchText] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>();
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const [filteredAttributes, setFilteredAttributes] = useState<
@@ -142,38 +144,53 @@ function DynamicVariable({
dynamicVariablesSelectedValue?.value,
]);
const errorMessage = (error as any)?.message;
const errorText = (error as any)?.message || errorMessage;
return (
<div className="dynamic-variable-container">
<CustomSelect
placeholder="Select an Attribute"
options={Object.keys(filteredAttributes).map((key) => ({
label: key,
value: key,
}))}
loading={isLoading}
status={errorMessage ? 'error' : undefined}
onChange={(value): void => {
setSelectedAttribute(value);
}}
showSearch
errorMessage={errorMessage as any}
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
onSearch={handleSearch}
onRetry={(): void => {
refetch();
}}
/>
<Typography className="dynamic-variable-from-text">from</Typography>
<Select
placeholder="Source"
defaultValue={AttributeSource.ALL_SOURCES}
options={sources.map((source) => ({ label: source, value: source }))}
onChange={(value): void => setAttributeSource(value as AttributeSource)}
value={attributeSource || dynamicVariablesSelectedValue?.value}
/>
<div className="dynamic-variable-config-container">
<CustomSelect
placeholder="Select an Attribute"
options={Object.keys(filteredAttributes).map((key) => ({
label: key,
value: key,
}))}
loading={isLoading}
status={errorText ? 'error' : undefined}
onChange={(value): void => {
setSelectedAttribute(value);
}}
showSearch
errorMessage={errorText as any}
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
onSearch={handleSearch}
onRetry={(): void => {
// reset error message
setErrorMessage(undefined);
refetch();
}}
/>
<Typography className="dynamic-variable-from-text">from</Typography>
<Select
placeholder="Source"
defaultValue={AttributeSource.ALL_SOURCES}
options={sources.map((source) => ({ label: source, value: source }))}
onChange={(value): void => setAttributeSource(value as AttributeSource)}
value={attributeSource || dynamicVariablesSelectedValue?.value}
/>
</div>
{errorAttributeKeyMessage && (
<div>
<Typography.Text type="warning">
{errorAttributeKeyMessage}
</Typography.Text>
</div>
)}
</div>
);
}
DynamicVariable.defaultProps = {
errorAttributeKeyMessage: '',
};
export default DynamicVariable;

View File

@@ -25,6 +25,7 @@ describe('DynamicVariable Component', () => {
const DEFAULT_PROPS = {
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue: undefined,
errorAttributeKeyMessage: '',
};
const mockFieldKeysResponse = {

View File

@@ -99,7 +99,8 @@
}
.variable-type-btn-group {
display: flex;
display: grid;
grid-template-columns: max-content max-content max-content max-content;
height: 32px;
flex-shrink: 0;
border-radius: 2px;
@@ -112,12 +113,14 @@
height: 32px;
flex-shrink: 0;
border-radius: 2px 0px 0px 2px;
width: 100%;
}
.variable-type-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.variable-type-btn + .variable-type-btn {

View File

@@ -1,4 +1,5 @@
import { QueryClient, QueryClientProvider } from 'react-query';
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
IDashboardVariable,
@@ -26,6 +27,7 @@ jest.mock('uuid', () => ({
const onCancel = jest.fn();
const onSave = jest.fn();
const validateName = jest.fn(() => true);
const validateAttributeKey = jest.fn(() => true);
// Mode constant
const VARIABLE_MODE = 'ADD';
@@ -35,6 +37,8 @@ const TEXT = {
INCLUDE_ALL_VALUES: 'Include an option for ALL values',
ENABLE_MULTI_VALUES: 'Enable multiple values to be checked',
VARIABLE_EXISTS: 'Variable name already exists',
VARIABLE_WHITESPACE: 'Variable name cannot contain whitespaces',
ATTRIBUTE_KEY_EXISTS: 'A variable with this attribute key already exists',
SORT_VALUES: 'Sort Values',
DEFAULT_VALUE: 'Default Value',
ALL_VARIABLES: 'All variables',
@@ -43,6 +47,7 @@ const TEXT = {
QUERY: 'Query',
TEXTBOX: 'Textbox',
CUSTOM: 'Custom',
DYNAMIC: 'Dynamic',
};
// Common test constants
@@ -75,23 +80,6 @@ const TEST_VAR_DESCRIPTIONS = {
const SAVE_BUTTON_TEXT = 'Save Variable';
const UNIQUE_NAME_PLACEHOLDER = 'Unique name of the variable';
// Create QueryClient for wrapping the component
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Wrapper component with QueryClientProvider
const wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
);
// Basic variable data for testing
const basicVariableData: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
@@ -108,6 +96,7 @@ const renderVariableItem = (
variableData: IDashboardVariable = basicVariableData,
existingVariables: Record<string, IDashboardVariable> = {},
validateNameFn = validateName,
validateAttributeKeyFn = validateAttributeKey,
): void => {
render(
<VariableItem
@@ -116,9 +105,9 @@ const renderVariableItem = (
onCancel={onCancel}
onSave={onSave}
validateName={validateNameFn}
validateAttributeKey={validateAttributeKeyFn}
mode={VARIABLE_MODE}
/>,
{ wrapper } as any,
);
};
@@ -203,6 +192,184 @@ describe('VariableItem Component', () => {
// Error should not be visible
expect(screen.queryByText(TEXT.VARIABLE_EXISTS)).not.toBeInTheDocument();
});
test('shows error when variable name contains whitespace', () => {
renderVariableItem({ ...basicVariableData, name: '' });
// Enter a name with whitespace
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'variable name' } });
// Error message should be displayed
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
// Save button should be disabled
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).toBeDisabled();
});
test('allows variable name without whitespace', () => {
renderVariableItem({ ...basicVariableData, name: '' });
// Enter a valid name without whitespace
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'variable.name' } });
// Error should not be visible
expect(screen.queryByText(TEXT.VARIABLE_WHITESPACE)).not.toBeInTheDocument();
});
test('validates whitespace in auto-generated name for dynamic variables', () => {
// Create a dynamic variable with empty name
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: '',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service name', // Contains whitespace
dynamicVariablesSource: 'All Sources',
};
renderVariableItem(dynamicVariable);
// Error message should be displayed for auto-generated name
expect(screen.getByText(TEXT.VARIABLE_WHITESPACE)).toBeInTheDocument();
});
});
describe('Dynamic Variable Attribute Key Validation', () => {
test('shows error when attribute key already exists', async () => {
// Mock validateAttributeKey to return false (attribute key exists)
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All Sources',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Switch to Dynamic type to trigger the validation
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
if (dynamicButton) {
fireEvent.click(dynamicButton);
}
// Error message should be displayed
await waitFor(() => {
expect(screen.getByText(TEXT.ATTRIBUTE_KEY_EXISTS)).toBeInTheDocument();
});
// Save button should be disabled
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).toBeDisabled();
});
test('allows saving when attribute key is unique', async () => {
// Mock validateAttributeKey to return true (attribute key is unique)
const mockValidateAttributeKey = jest.fn().mockReturnValue(true);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All Sources',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Switch to Dynamic type
const dynamicButton = findButtonByText(TEXT.DYNAMIC);
if (dynamicButton) {
fireEvent.click(dynamicButton);
}
// Error should not be visible
await waitFor(() => {
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
});
// Save button should not be disabled due to attribute key error
const saveButton = screen.getByRole('button', { name: /save variable/i });
expect(saveButton).not.toBeDisabled();
});
test('allows same attribute key for current variable being edited', async () => {
// Mock validateAttributeKey to return true for same variable
const mockValidateAttributeKey = jest.fn().mockImplementation(
(attributeKey, currentVariableId) =>
// Allow if it's the same variable ID
currentVariableId === TEST_VAR_IDS.VAR1,
);
// Create a dynamic variable
const dynamicVariable: IDashboardVariable = {
...basicVariableData,
id: TEST_VAR_IDS.VAR1,
name: 'test-variable',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'All Sources',
};
renderVariableItem(
dynamicVariable,
{},
validateName,
mockValidateAttributeKey,
);
// Error should not be visible
await waitFor(() => {
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
});
});
test('does not validate attribute key for non-dynamic variables', async () => {
// Mock validateAttributeKey to return false (would show error for dynamic)
const mockValidateAttributeKey = jest.fn().mockReturnValue(false);
// Create a non-dynamic variable
const queryVariable: IDashboardVariable = {
...basicVariableData,
name: 'test-variable',
type: 'QUERY',
};
renderVariableItem(
queryVariable,
{},
validateName,
mockValidateAttributeKey,
);
// No error should be displayed for query variables
expect(
screen.queryByText(TEXT.ATTRIBUTE_KEY_EXISTS),
).not.toBeInTheDocument();
// validateAttributeKey should not be called for non-dynamic variables
expect(mockValidateAttributeKey).not.toHaveBeenCalled();
});
});
describe('Variable Type Switching', () => {
@@ -324,6 +491,7 @@ describe('VariableItem Component', () => {
multiSelect: false,
showALLOption: false,
}),
expect.anything(), // widgetIds prop
);
});
});

View File

@@ -7,11 +7,16 @@ import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQ
import cx from 'classnames';
import Editor from 'components/Editor';
import { CustomSelect } from 'components/NewSelect';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
createDynamicVariableToWidgetsMap,
getWidgetsHavingDynamicVariableAttribute,
} from 'hooks/dashboard/utils';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { map } from 'lodash-es';
import { isEmpty, map } from 'lodash-es';
import {
ArrowLeft,
Check,
@@ -21,7 +26,8 @@ import {
Pyramid,
X,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -30,6 +36,7 @@ import {
TSortVariableValuesType,
TVariableQueryType,
VariableSortTypeArr,
Widgets,
} from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
@@ -50,8 +57,16 @@ interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onCancel: () => void;
onSave: (mode: TVariableMode, variableData: IDashboardVariable) => void;
onSave: (
mode: TVariableMode,
variableData: IDashboardVariable,
widgetIds?: string[],
) => void;
validateName: (arg0: string) => boolean;
validateAttributeKey: (
attributeKey: string,
currentVariableId?: string,
) => boolean;
mode: TVariableMode;
}
function VariableItem({
@@ -60,11 +75,16 @@ function VariableItem({
onCancel,
onSave,
validateName,
validateAttributeKey,
mode,
}: VariableItemProps): JSX.Element {
const [variableName, setVariableName] = useState<string>(
variableData.name || '',
);
const [
hasUserManuallyChangedName,
setHasUserManuallyChangedName,
] = useState<boolean>(false);
const [variableDescription, setVariableDescription] = useState<string>(
variableData.description || '',
);
@@ -102,6 +122,16 @@ function VariableItem({
setDynamicVariablesSelectedValue,
] = useState<{ name: string; value: string }>();
// Error messages
const [errorName, setErrorName] = useState<boolean>(false);
const [errorNameMessage, setErrorNameMessage] = useState<string>('');
const [errorAttributeKey, setErrorAttributeKey] = useState<boolean>(false);
const [
errorAttributeKeyMessage,
setErrorAttributeKeyMessage,
] = useState<string>('');
const [errorPreview, setErrorPreview] = useState<string | null>(null);
useEffect(() => {
if (
variableData.dynamicVariablesAttribute &&
@@ -116,9 +146,71 @@ function VariableItem({
variableData.dynamicVariablesAttribute,
variableData.dynamicVariablesSource,
]);
// Error messages
const [errorName, setErrorName] = useState<boolean>(false);
const [errorPreview, setErrorPreview] = useState<string | null>(null);
// Validate attribute key uniqueness for dynamic variables
useEffect(() => {
if (queryType === 'DYNAMIC' && dynamicVariablesSelectedValue?.name) {
if (
!validateAttributeKey(dynamicVariablesSelectedValue.name, variableData.id)
) {
setErrorAttributeKey(true);
setErrorAttributeKeyMessage(
'A variable with this attribute key already exists',
);
} else {
setErrorAttributeKey(false);
setErrorAttributeKeyMessage('');
}
} else {
setErrorAttributeKey(false);
setErrorAttributeKeyMessage('');
}
}, [
queryType,
dynamicVariablesSelectedValue?.name,
validateAttributeKey,
variableData.id,
]);
// Auto-set variable name to selected attribute name in creation mode when user hasn't manually changed it
useEffect(() => {
if (
mode === 'ADD' && // Only in creation mode
queryType === 'DYNAMIC' && // Only for dynamic variables
dynamicVariablesSelectedValue?.name && // Attribute is selected
!hasUserManuallyChangedName // User hasn't manually changed the name
) {
const newName = dynamicVariablesSelectedValue.name;
setVariableName(newName);
// Trigger validation for the auto-set name
if (/\s/.test(newName)) {
setErrorName(true);
setErrorNameMessage('Variable name cannot contain whitespaces');
} else if (!validateName(newName)) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
} else {
setErrorName(false);
setErrorNameMessage('');
}
}
}, [
mode,
queryType,
dynamicVariablesSelectedValue?.name,
hasUserManuallyChangedName,
validateName,
]);
const REQUIRED_NAME_MESSAGE = 'Variable name is required';
// Initialize error state for empty name
useEffect(() => {
if (!variableName.trim()) {
setErrorName(true);
}
}, [variableName]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -142,11 +234,40 @@ function VariableItem({
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { selectedDashboard } = useDashboard();
useEffect(() => {
if (queryType === 'DYNAMIC') {
setSelectedWidgets(variableData?.dynamicVariablesWidgetIds || []);
const dynamicVariables = Object.values(
selectedDashboard?.data?.variables || {},
)?.filter((variable: IDashboardVariable) => variable.type === 'DYNAMIC');
const widgets =
selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || [];
const widgetsHavingDynamicVariables = createDynamicVariableToWidgetsMap(
dynamicVariables,
widgets as Widgets[],
);
if (variableData?.id && variableData.id in widgetsHavingDynamicVariables) {
setSelectedWidgets(widgetsHavingDynamicVariables[variableData.id] || []);
} else if (dynamicVariablesSelectedValue?.name) {
const widgets = getWidgetsHavingDynamicVariableAttribute(
dynamicVariablesSelectedValue?.name,
(selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || []) as Widgets[],
variableData.name,
);
setSelectedWidgets(widgets || []);
}
}, [queryType, variableData?.dynamicVariablesWidgetIds]);
}, [
dynamicVariablesSelectedValue?.name,
selectedDashboard,
variableData.id,
variableData.name,
]);
useEffect(() => {
if (queryType === 'CUSTOM') {
@@ -188,7 +309,42 @@ function VariableItem({
queryType,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
dynamicVariablesSelectedValue,
]);
const variableValue = useMemo(() => {
if (variableMultiSelect) {
let value = variableData.selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableDefaultValue) {
value = variableDefaultValue;
} else {
value = previewValues;
}
} else if (variableDefaultValue) {
value = variableDefaultValue;
} else {
value = previewValues?.[0];
}
}
return value;
}
if (isEmpty(variableData.selectedValue)) {
if (variableDefaultValue) {
return variableDefaultValue;
}
return previewValues?.[0]?.toString();
}
return variableData.selectedValue || variableDefaultValue;
}, [
variableMultiSelect,
variableData.selectedValue,
variableData.showALLOption,
variableDefaultValue,
previewValues,
]);
const handleSave = (): void => {
@@ -201,7 +357,7 @@ function VariableItem({
customValue: variableCustomValue,
textboxValue: variableTextboxValue,
multiSelect: variableMultiSelect,
showALLOption: variableShowALLOption,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
...(queryType === 'TEXTBOX' && {
selectedValue: (variableData.selectedValue ||
@@ -217,10 +373,8 @@ function VariableItem({
dynamicVariablesAttribute: dynamicVariablesSelectedValue?.name,
dynamicVariablesSource: dynamicVariablesSelectedValue?.value,
}),
...(queryType === 'DYNAMIC' && {
dynamicVariablesWidgetIds:
selectedWidgets?.length > 0 ? selectedWidgets : [],
}),
selectedValue: variableValue,
allSelected: variableData.allSelected,
};
const allVariables = [...Object.values(existingVariables), newVariable];
@@ -237,7 +391,7 @@ function VariableItem({
return;
}
onSave(mode, newVariable);
onSave(mode, newVariable, selectedWidgets);
};
// Fetches the preview values for the SQL variable query
@@ -315,17 +469,34 @@ function VariableItem({
placeholder="Unique name of the variable"
value={variableName}
className="name-input"
onChange={(e): void => {
setVariableName(e.target.value);
setErrorName(
!validateName(e.target.value) && e.target.value !== variableData.name,
);
onChange={({ target: { value } }): void => {
setVariableName(value);
setHasUserManuallyChangedName(true); // Mark that user has manually changed the name
// Check for empty name
if (!value.trim()) {
setErrorName(true);
setErrorNameMessage(REQUIRED_NAME_MESSAGE);
}
// Check for whitespace in name
else if (/\s/.test(value)) {
setErrorName(true);
setErrorNameMessage('Variable name cannot contain whitespaces');
}
// Check for duplicate name
else if (!validateName(value) && value !== variableData.name) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
}
// No errors
else {
setErrorName(false);
setErrorNameMessage('');
}
}}
/>
<div>
<Typography.Text type="warning">
{errorName ? 'Variable name already exists' : ''}
</Typography.Text>
<Typography.Text type="warning">{errorNameMessage}</Typography.Text>
</div>
</div>
</VariableItemRow>
@@ -359,9 +530,16 @@ function VariableItem({
onClick={(): void => {
setQueryType('DYNAMIC');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Dynamic
<Tag bordered={false} className="sidenav-beta-tag" color="geekblue">
Beta
</Tag>
</Button>
<Button
type="text"
@@ -373,6 +551,10 @@ function VariableItem({
onClick={(): void => {
setQueryType('TEXTBOX');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Textbox
@@ -387,6 +569,10 @@ function VariableItem({
onClick={(): void => {
setQueryType('CUSTOM');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Custom
@@ -402,9 +588,16 @@ function VariableItem({
onClick={(): void => {
setQueryType('QUERY');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Query
<Tag bordered={false} className="sidenav-beta-tag" color="warning">
Not Recommended
</Tag>
</Button>
</div>
</VariableItemRow>
@@ -413,6 +606,7 @@ function VariableItem({
<DynamicVariable
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
errorAttributeKeyMessage={errorAttributeKeyMessage}
/>
</div>
)}
@@ -561,7 +755,7 @@ function VariableItem({
}}
/>
</VariableItemRow>
{variableMultiSelect && (
{variableMultiSelect && queryType !== 'DYNAMIC' && (
<VariableItemRow className="all-option-section">
<LabelContainer>
<Typography className="typography-variables">
@@ -623,7 +817,7 @@ function VariableItem({
<Button
type="primary"
onClick={handleSave}
disabled={errorName}
disabled={errorName || errorAttributeKey}
icon={<Check size={14} />}
className="footer-btn-save"
>

View File

@@ -2,6 +2,7 @@ import { CustomMultiSelect } from 'components/NewSelect';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import React from 'react';
export function WidgetSelector({
selectedWidgets,
@@ -21,7 +22,7 @@ export function WidgetSelector({
// and excluding row widgets since they are not panels that can have variables
const widgets = Object.values(
(selectedDashboard?.data?.widgets || []).reduce(
(acc: Record<string, any>, widget) => {
(acc: Record<string, any>, widget: any) => {
if (
widget.id &&
layoutIds.has(widget.id) &&
@@ -35,14 +36,26 @@ export function WidgetSelector({
),
);
// Filter selectedWidgets to only include widgets that are present in the current layout
const validSelectedWidgets = selectedWidgets.filter((widgetId) =>
layoutIds.has(widgetId),
);
// Update selectedWidgets if any invalid widgets were removed
React.useEffect(() => {
if (validSelectedWidgets.length !== selectedWidgets.length) {
setSelectedWidgets(validSelectedWidgets);
}
}, [validSelectedWidgets, selectedWidgets.length, setSelectedWidgets]);
return (
<CustomMultiSelect
placeholder="Select Panels"
options={widgets.map((widget) => ({
options={widgets.map((widget: any) => ({
label: generateGridTitle(widget.title),
value: widget.id,
}))}
value={selectedWidgets}
value={validSelectedWidgets}
onChange={(value): void => setSelectedWidgets(value as string[])}
showLabels
/>

View File

@@ -83,8 +83,8 @@ const updateSingleWidget = (
...widget.query,
builder: {
...widget.query.builder,
queryData: widget.query.builder.queryData.map(
(queryData) => updateQueryFilters(queryData, filter), // todo - Sagar: check for multiple query or not
queryData: widget.query.builder.queryData.map((queryData) =>
updateQueryFilters(queryData, filter),
),
},
},
@@ -127,6 +127,7 @@ const removeIfPresent = (
expression: removeKeysFromExpression(
queryData.filter?.expression ?? '',
filter.key?.key ? [filter.key.key] : [],
true,
),
},
};
@@ -147,8 +148,8 @@ const updateAfterRemoval = (
...widget.query,
builder: {
...widget.query.builder,
queryData: widget.query.builder.queryData.map(
(queryData) => removeIfPresent(queryData, filter), // todo - Sagar: check for multiple query or not
queryData: widget.query.builder.queryData.map((queryData) =>
removeIfPresent(queryData, filter),
),
},
},

View File

@@ -16,19 +16,13 @@ import { Button, Modal, Row, Space, Table, Typography } from 'antd';
import { RowProps } from 'antd/lib';
import { convertVariablesToDbFormat } from 'container/NewDashboard/DashboardVariablesSelection/util';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Trash2 } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dashboard,
IDashboardVariable,
Widgets,
} from 'types/api/dashboard/getAll';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { TVariableMode } from './types';
import VariableItem from './VariableItem/VariableItem';
@@ -59,8 +53,10 @@ function TableRow({ children, ...props }: RowProps): JSX.Element {
// eslint-disable-next-line react/jsx-props-no-spreading
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, (child) => {
if ((child as React.ReactElement).key === 'name') {
return React.cloneElement(child as React.ReactElement, {
const childElement = child as React.ReactElement;
if (childElement.key === 'name') {
return React.cloneElement(childElement, {
key: 'name-with-drag',
children: (
<div className="variable-name-drag">
<HolderOutlined
@@ -75,7 +71,7 @@ function TableRow({ children, ...props }: RowProps): JSX.Element {
});
}
return child;
return childElement;
})}
</tr>
);
@@ -88,6 +84,8 @@ function VariablesSetting({
}): JSX.Element {
const variableToDelete = useRef<IDashboardVariable | null>(null);
const [deleteVariableModal, setDeleteVariableModal] = useState(false);
const variableToApplyToAll = useRef<IDashboardVariable | null>(null);
const [applyToAllModal, setApplyToAllModal] = useState(false);
const { t } = useTranslation(['dashboard']);
@@ -95,7 +93,9 @@ function VariablesSetting({
const { notifications } = useNotifications();
const { variables = {}, widgets = [] } = selectedDashboard?.data || {};
const variables = useMemo(() => selectedDashboard?.data?.variables || {}, [
selectedDashboard?.data?.variables,
]);
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
@@ -134,6 +134,8 @@ function VariablesSetting({
const updateMutation = useUpdateDashboard();
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
useEffect(() => {
const tableRowData = [];
const variableOrderArr = [];
@@ -169,40 +171,10 @@ function VariablesSetting({
setExistingVariableNamesMap(variableNamesMap);
}, [variables]);
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
const { dynamicVariables } = useGetDynamicVariables();
const dynamicVariableToWidgetsMap = useMemo(
() =>
createDynamicVariableToWidgetsMap(
dynamicVariables,
(widgets as Widgets[]) || [],
),
[dynamicVariables, widgets],
);
// initialize and adjust dynamicVariablesWidgetIds values for all variables
useEffect(() => {
const newVariablesArr = Object.values(variables).map(
(variable: IDashboardVariable) => {
if (variable.type === 'DYNAMIC') {
return {
...variable,
dynamicVariablesWidgetIds: dynamicVariableToWidgetsMap[variable.id] || [],
};
}
return variable;
},
);
setVariablesTableData(newVariablesArr);
}, [variables, dynamicVariableToWidgetsMap]);
const updateVariables = (
updatedVariablesData: Dashboard['data']['variables'],
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!selectedDashboard) {
@@ -211,9 +183,11 @@ function VariablesSetting({
const newDashboard =
(currentRequestedId &&
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
addDynamicVariableToPanels(
selectedDashboard,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
selectedDashboard;
@@ -251,6 +225,7 @@ function VariablesSetting({
const onVariableSaveHandler = (
mode: TVariableMode,
variableData: IDashboardVariable,
widgetIds?: string[],
applyToAll?: boolean,
): void => {
const updatedVariableData = {
@@ -275,7 +250,7 @@ function VariablesSetting({
const variables = convertVariablesToDbFormat(newVariablesArr);
setVariablesTableData(newVariablesArr);
updateVariables(variables, variableData?.id, applyToAll);
updateVariables(variables, variableData?.id, widgetIds, applyToAll);
onDoneVariableViewMode();
};
@@ -301,9 +276,46 @@ function VariablesSetting({
setDeleteVariableModal(false);
};
const onApplyToAllHandler = (variable: IDashboardVariable): void => {
variableToApplyToAll.current = variable;
setApplyToAllModal(true);
};
const handleApplyToAllConfirm = (): void => {
if (variableToApplyToAll.current) {
onVariableSaveHandler(
variableViewMode || 'EDIT',
variableToApplyToAll.current,
[],
true,
);
}
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const handleApplyToAllCancel = (): void => {
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const validateVariableName = (name: string): boolean =>
!existingVariableNamesMap[name];
const validateAttributeKey = (
attributeKey: string,
currentVariableId?: string,
): boolean => {
// Check if any other dynamic variable already uses this attribute key
const isDuplicateAttributeKey = Object.values(variables).some(
(variable: IDashboardVariable) =>
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute === attributeKey &&
variable.id !== currentVariableId, // Exclude current variable being edited
);
return !isDuplicateAttributeKey;
};
const columns = [
{
title: 'Variable',
@@ -324,9 +336,7 @@ function VariablesSetting({
{variable.type === 'DYNAMIC' && (
<Button
type="text"
onClick={(): void =>
onVariableSaveHandler(variableViewMode || 'EDIT', variable, true)
}
onClick={(): void => onApplyToAllHandler(variable)}
className="apply-to-all-button"
loading={updateMutation.isLoading}
>
@@ -411,6 +421,7 @@ function VariablesSetting({
onSave={onVariableSaveHandler}
onCancel={onDoneVariableViewMode}
validateName={validateVariableName}
validateAttributeKey={validateAttributeKey}
mode={variableViewMode}
/>
) : (
@@ -477,6 +488,24 @@ function VariablesSetting({
?
</Typography.Text>
</Modal>
<Modal
title="Apply variable to all panels"
centered
open={applyToAllModal}
onOk={handleApplyToAllConfirm}
onCancel={handleApplyToAllCancel}
okText="Apply to all"
cancelText="Cancel"
>
<Typography.Text>
Are you sure you want to apply variable{' '}
<span className="apply-to-all-variable-name">
{variableToApplyToAll?.current?.name}
</span>{' '}
to all panels? This action may affect panels where this variable is not
applicable.
</Typography.Text>
</Modal>
</>
);
}

View File

@@ -100,6 +100,9 @@ function DashboardVariableSelection(): JSX.Element | null {
[JSON.stringify(dependencyData?.order), minTime, maxTime],
);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.
// This makes localStorage much lighter and more efficient.
const onValueUpdate = (
name: string,
id: string,
@@ -109,7 +112,11 @@ function DashboardVariableSelection(): JSX.Element | null {
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
if (id) {
updateLocalStorageDashboardVariables(name, value, allSelected);
// For dynamic variables, only store in localStorage when NOT allSelected
// This makes localStorage much lighter by avoiding storing all individual values
const variable = variables?.[id] || variables?.[name];
const isDynamic = variable?.type === 'DYNAMIC';
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
if (selectedDashboard) {
setSelectedDashboard((prev) => {
@@ -180,7 +187,7 @@ function DashboardVariableSelection(): JSX.Element | null {
orderBasedSortedVariables.map((variable) =>
variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={`${variable.name}${variable.id}}${variable.order}`}
key={`${variable.name}${variable.id}${variable.order}`}
existingVariables={variables}
variableData={{
name: variable.name,

View File

@@ -21,7 +21,11 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual } from './util';
import {
areArraysEqual,
getOptionsForDynamicVariable,
uniqueValues,
} from './util';
import { getSelectValue } from './VariableItem';
interface DynamicVariableSelectionProps {
@@ -53,10 +57,18 @@ function DynamicVariableSelection({
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) return 'no_variables';
@@ -79,6 +91,67 @@ function DynamicVariableSelection({
(state) => state.globalTime,
);
// existing query is the query made from the other dynamic variables around this one with there current values
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
const existingQuery = useMemo(() => {
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
return '';
}
const queryParts: string[] = [];
Object.entries(existingVariables).forEach(([, variable]) => {
// Skip the current variable being processed
if (variable.id === variableData.id) {
return;
}
// Only include dynamic variables that have selected values and are not selected as ALL
if (
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute &&
variable.selectedValue &&
!isEmpty(variable.selectedValue) &&
(variable.showALLOption ? !variable.allSelected : true)
) {
const attribute = variable.dynamicVariablesAttribute;
const values = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = values
.filter((value) => value !== null && value !== undefined && value !== '')
.map((value) => value.toString());
if (validValues.length > 0) {
// Format values for query - wrap strings in quotes, keep numbers as is
const formattedValues = validValues.map((value) => {
// Check if value is a number
const numValue = Number(value);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return value; // Keep as number
}
// Escape single quotes and wrap in quotes
return `'${value.replace(/'/g, "\\'")}'`;
});
if (formattedValues.length === 1) {
queryParts.push(`${attribute} = ${formattedValues[0]}`);
} else {
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
}
}
}
});
return queryParts.join(' AND ');
}, [
existingVariables,
variableData.id,
variableData.dynamicVariablesAttribute,
]);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
@@ -86,6 +159,7 @@ function DynamicVariableSelection({
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
],
{
enabled: variableData.type === 'DYNAMIC',
@@ -101,11 +175,43 @@ function DynamicVariableSelection({
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
),
onSuccess: (data) => {
setOptionsData(data.payload?.normalizedValues || []);
setIsComplete(data.payload?.complete || false);
setFilteredOptionsData(data.payload?.normalizedValues || []);
const newNormalizedValues = data.payload?.normalizedValues || [];
const newRelatedValues = data.payload?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.payload?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Only run auto-check logic when necessary to avoid performance issues
if (variableData.allSelected && isDropdownOpen) {
// Build the latest full list from API (normalized + related)
const latestValues = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
// Update temp selection to exactly reflect latest API values when ALL is active
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
},
onError: (error: any) => {
if (error) {
@@ -140,20 +246,33 @@ function DynamicVariableSelection({
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// Build union of available options shown in dropdown (normalized + related)
const allAvailableOptionStrings = [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
];
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
optionsData.every((v) => value.includes(v.toString())),
Array.isArray(value) &&
!value.every((v) => optionsData.includes(v.toString())),
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
}
},
[variableData, onValueUpdate, optionsData],
[variableData, onValueUpdate, optionsData, relatedValues],
);
useEffect(() => {
@@ -170,11 +289,23 @@ function DynamicVariableSelection({
debouncedApiSearchText,
]);
// Build a memoized list of all currently available option strings (normalized + related)
const allAvailableOptionStrings = useMemo(
() => [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
],
[optionsData, relatedValues],
);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
@@ -185,11 +316,16 @@ function DynamicVariableSelection({
}
});
setFilteredOptionsData(localFilteredOptionsData);
setRelatedValues(
originalRelatedValues.filter((value) =>
value.toLowerCase().includes(text.toLowerCase()),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData],
[isComplete, optionsData, originalRelatedValues],
);
const { selectedValue } = variableData;
@@ -206,41 +342,90 @@ function DynamicVariableSelection({
: selectedValueStringified;
// Add a handler for tracking temporary selection changes
const handleTempChange = (inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
};
const handleTempChange = useCallback(
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
const sanitizedValue = uniqueValues(value);
setTempSelection(sanitizedValue);
},
[variableData.multiSelect],
);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Update dropdown open state for auto-checking
setIsDropdownOpen(visible);
// Initialize temp selection when opening dropdown
if (visible) {
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
// set all options from the optionsData and the selectedValue, make sure to remove duplicates
const allOptions = [
...new Set([
...optionsData.map((option) => option.toString()),
...(variableData.selectedValue
? Array.isArray(variableData.selectedValue)
? variableData.selectedValue.map((v) => v.toString())
: [variableData.selectedValue.toString()]
: []),
]),
];
setTempSelection(allOptions);
// When ALL is selected, set selection to exactly the latest available values
const latestAll = [...allAvailableOptionStrings];
setTempSelection(latestAll);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Call handleChange with the temporarily stored selection
handleChange(tempSelection);
// Only call handleChange if there's actually a change in the selection
const currentValue = variableData.selectedValue;
// Helper function to check if arrays have the same elements regardless of order
const areArraysEqualIgnoreOrder = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return areArraysEqual(sortedA, sortedB);
};
// If ALL was selected before and remains ALL after, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === allAvailableOptionStrings.length &&
allAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
const hasChanged =
tempSelection !== currentValue &&
!(
Array.isArray(tempSelection) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(tempSelection, currentValue)
);
if (hasChanged) {
handleChange(tempSelection);
}
setTempSelection(undefined);
}
// Always reset filtered data when dropdown closes, regardless of tempSelection state
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
};
useEffect(
() => (): void => {
// Cleanup on unmount
setTempSelection(undefined);
setFilteredOptionsData([]);
setRelatedValues([]);
setApiSearchText('');
},
[],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
@@ -249,6 +434,10 @@ function DynamicVariableSelection({
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else if (variableData.allSelected) {
// If ALL is selected but no stored values, derive from available options
// This handles the case where we don't store values in localStorage for ALL
value = allAvailableOptionStrings;
} else {
value = optionsData;
}
@@ -273,9 +462,11 @@ function DynamicVariableSelection({
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
variableData.allSelected,
selectedValue,
tempSelection,
optionsData,
allAvailableOptionStrings,
]);
useEffect(() => {
@@ -306,15 +497,11 @@ function DynamicVariableSelection({
<div className="variable-value">
{variableData.multiSelect ? (
<CustomMultiSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
options={filteredOptionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
key={variableData.id}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
defaultValue={variableData.defaultValue}
onChange={handleTempChange}
bordered={false}
@@ -336,11 +523,20 @@ function DynamicVariableSelection({
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => (
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
<span>+ {omittedValues.length} </span>
</Tooltip>
)}
maxTagPlaceholder={(omittedValues): JSX.Element => {
const maxDisplayValues = 10;
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
const hasMore = omittedValues.length > maxDisplayValues;
const tooltipText =
valuesToShow.map(({ value }) => value).join(', ') +
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
@@ -348,17 +544,14 @@ function DynamicVariableSelection({
maxTagTextLength={30}
onSearch={handleSearch}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
key={variableData.id}
onChange={handleChange}
bordered={false}
placeholder="Select value"
@@ -369,15 +562,16 @@ function DynamicVariableSelection({
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={filteredOptionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
value={selectValue}
defaultValue={variableData.defaultValue}
errorMessage={errorMessage}
onSearch={handleSearch}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}

View File

@@ -126,6 +126,7 @@ function VariableItem({
valueNotInList = true;
}
}
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
@@ -133,31 +134,32 @@ function VariableItem({
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
const value = variableData.selectedValue;
let allSelected = false;
// The default value for multi-select is ALL and first value for
// single select
// console.log(valueNotInList);
// if (valueNotInList) {
// if (variableData.multiSelect) {
// value = newOptionsData;
// allSelected = true;
// } else {
// [value] = newOptionsData;
// }
// } else
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
selectedValue.length === newOptionsData.length &&
newOptionsData.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 && variableData?.name && variableData?.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (variableData && variableData?.name && variableData?.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
@@ -181,7 +183,7 @@ function VariableItem({
}
};
const { isLoading } = useQuery(
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
@@ -245,10 +247,14 @@ function VariableItem({
return;
}
if (variableData.name) {
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
// Check if ALL is effectively selected by comparing with available options
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
// For ALL selection, pass null to avoid storing values
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
@@ -262,6 +268,7 @@ function VariableItem({
variableData.id,
onValueUpdate,
optionsData,
variableData.showALLOption,
],
);
@@ -419,17 +426,30 @@ function VariableItem({
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => (
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
<span>+ {omittedValues.length} </span>
</Tooltip>
)}
maxTagPlaceholder={(omittedValues): JSX.Element => {
const maxDisplayValues = 10;
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
const hasMore = omittedValues.length > maxDisplayValues;
const tooltipText =
valuesToShow.map(({ value }) => value).join(', ') +
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
) : (
<CustomSelect
@@ -455,6 +475,10 @@ function VariableItem({
}))}
value={selectValue}
errorMessage={errorMessage}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
))
)}

View File

@@ -8,6 +8,8 @@ import { v4 as uuidv4 } from 'uuid';
import { convertVariablesToDbFormat } from './util';
// Note: This logic completely mimics the logic in DashboardVariableSelection.tsx
// but is separated to avoid unnecessary logic addition.
interface UseDashboardVariableUpdateReturn {
onValueUpdate: (
name: string,
@@ -27,6 +29,7 @@ interface UseDashboardVariableUpdateReturn {
updateVariables: (
updatedVariablesData: Dashboard['data']['variables'],
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,
) => void;
}
@@ -50,7 +53,12 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
haveCustomValuesSelected?: boolean,
): void => {
if (id) {
updateLocalStorageDashboardVariables(name, value, allSelected);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.
// This makes localStorage much lighter and more efficient.
// currently all the variables are dynamic
const isDynamic = true;
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
if (selectedDashboard) {
setSelectedDashboard((prev) => {
@@ -100,6 +108,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
(
updatedVariablesData: Dashboard['data']['variables'],
currentRequestedId?: string,
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!selectedDashboard) {
@@ -111,6 +120,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
addDynamicVariableToPanels(
selectedDashboard,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
selectedDashboard;
@@ -216,7 +226,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
// Convert to dashboard format and update
const updatedVariables = convertVariablesToDbFormat(tableRowData);
updateVariables(updatedVariables, newVariable.id);
updateVariables(updatedVariables, newVariable.id, [], false);
},
[selectedDashboard, updateVariables],
);

View File

@@ -1,3 +1,4 @@
import { OptionData } from 'components/NewSelect/types';
import { isEmpty, isNull } from 'lodash-es';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
@@ -294,3 +295,74 @@ export const checkAPIInvocation = (
variablesToGetUpdated[0] === variableData.name
);
};
export const getOptionsForDynamicVariable = (
normalizedValues: (string | number | boolean)[],
relatedValues: string[],
): OptionData[] => {
const options: OptionData[] = [];
if (relatedValues.length > 0) {
// Add Related Values group
options.push({
label: 'Related Values',
value: 'relatedValues',
options: relatedValues.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
});
// Add All Values group (complete union - shows everything)
options.push({
label: 'All Values',
value: 'allValues',
options: normalizedValues.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
});
return options;
}
return normalizedValues.map((option) => ({
label: option.toString(),
value: option.toString(),
}));
};
export const uniqueOptions = (options: OptionData[]): OptionData[] => {
const uniqueOptions: OptionData[] = [];
const seenValues = new Set<string>();
options.forEach((option) => {
const value = option.value || '';
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueOptions.push(option);
});
return uniqueOptions;
};
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
return uniqueValues;
}
return values;
};

View File

@@ -0,0 +1,475 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import {
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import * as ReactRedux from 'react-redux';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DashboardVariablesSelection/DynamicVariableSelection';
// Mock the getFieldValues API
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
}));
describe('Dynamic Variable Default Behavior', () => {
const mockOnValueUpdate = jest.fn();
const mockApiResponse = {
statusCode: 200,
error: null,
message: 'success',
payload: {
values: {
stringValues: ['frontend', 'backend', 'database', 'cache'],
},
normalizedValues: ['frontend', 'backend', 'database', 'cache'],
complete: true,
},
};
let queryClient: QueryClient;
const renderWithQueryClient = (component: React.ReactElement): RenderResult =>
render(
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
);
beforeEach(() => {
// Mock scrollIntoView for JSDOM environment
window.HTMLElement.prototype.scrollIntoView = jest.fn();
jest.clearAllMocks();
// Create a new QueryClient for each test
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Mock getFieldValues API to return our test data
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
});
});
describe('Single Select Default Values', () => {
it('should use default value when no previous selection exists', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: 'backend' as any,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should call onValueUpdate with default value
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'service',
'var1',
'backend',
true,
false,
);
});
it('should preserve previous selection over default value', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: 'backend' as any,
selectedValue: 'frontend',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should NOT call onValueUpdate since previous selection exists
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
// Check if the previous selection 'frontend' is displayed in the UI
// For single select, the value should be visible in the select component
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
// Open dropdown to check if 'frontend' is selected
fireEvent.mouseDown(selectElement);
// Check if 'frontend' option is present and selected in dropdown
const frontendOption = screen.getByRole('option', { name: 'frontend' });
expect(frontendOption).toHaveClass('selected');
// Verify that 'backend' (default value) is NOT present in the dropdown
// since previous selection 'frontend' takes priority
expect(screen.queryByText('backend')).not.toBeInTheDocument();
});
it('should use first value when no default and no previous selection', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: undefined,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Component should render without errors
expect(screen.getByText('$service')).toBeInTheDocument();
// Check if the dropdown is present
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
// Wait for API call to complete and data to be loaded
await waitFor(() => {
expect(getFieldValues).toHaveBeenCalledWith(
'traces',
'service.name',
'',
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
);
});
// Open dropdown to check available options
fireEvent.mouseDown(selectElement);
// Wait for dropdown to populate with API data
await waitFor(() => {
expect(
screen.getByRole('option', { name: 'frontend' }),
).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: 'backend' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'database' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'cache' })).toBeInTheDocument();
// Check if the first value 'frontend' is selected (active)
const frontendOption = screen.getByRole('option', { name: 'frontend' });
expect(frontendOption).toHaveClass('active');
});
});
describe('Multi Select Default Values - ALL Enabled', () => {
it('should use default value when no previous selection exists', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: ['backend', 'database'] as any,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var1',
['backend', 'database'],
true,
true,
);
});
it('should preserve previous selection over default', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: ['backend'] as any,
selectedValue: ['frontend', 'cache'],
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should NOT call onValueUpdate since previous selection exists
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
// The component shows "ALL" because the previous selection ['frontend', 'cache']
// is treated as all available values (the component considers this equivalent to all selected)
expect(screen.getByText('ALL')).toBeInTheDocument();
// Verify that the ALL selection wrapper is present
const allSelectedWrapper = screen
.getByText('ALL')
.closest('.custom-multiselect-wrapper');
expect(allSelectedWrapper).toHaveClass('all-selected');
// Verify that individual values are not displayed when ALL is shown
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
expect(screen.queryByText('cache')).not.toBeInTheDocument();
expect(screen.queryByText('backend')).not.toBeInTheDocument();
// Open the dropdown to see which specific options are selected in the dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for API data to be loaded and dropdown to populate
await waitFor(
() => {
// Check if any options are visible in dropdown - there might be checkboxes or selectable items
const dropdownElements = screen.queryAllByRole('option');
// If options are available, verify the selection states
if (dropdownElements.length > 0) {
// Frontend and cache should be selected (checked) in dropdown
// Backend (default) should NOT be selected since previous selection takes priority
const frontendOption = screen.queryByRole('option', { name: 'frontend' });
const cacheOption = screen.queryByRole('option', { name: 'cache' });
const backendOption = screen.queryByRole('option', { name: 'backend' });
if (frontendOption) expect(frontendOption).toHaveClass('selected');
if (cacheOption) expect(cacheOption).toHaveClass('selected');
if (backendOption) expect(backendOption).not.toHaveClass('selected');
}
},
{ timeout: 1000 },
);
});
it('should default to ALL when no default and no previous selection', () => {
const variableData: IDashboardVariable = {
id: 'var21',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: undefined,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Open dropdown to check if ALL option is selected
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check if ALL option is present in dropdown
const allOption = screen.getByText('ALL');
expect(allOption).toBeInTheDocument();
// Verify that the ALL option is available for selection
const allOptionContainer = allOption.closest(
'.option-item, .ant-select-item-option',
);
expect(allOptionContainer).toBeInTheDocument();
// Check if the checkbox exists (it should be unchecked initially)
const checkbox = allOptionContainer?.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
// Should call onValueUpdate with all values (ALL selection)
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var21',
[], // Empty array when allSelected is true
true, // allSelected = true
false,
);
});
});
describe('Multi Select Default Values - ALL Disabled', () => {
it('should use default value over first value when provided', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: false,
defaultValue: ['database', 'cache'] as any,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should call onValueUpdate with default values
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var1',
['database', 'cache'],
true,
true,
);
});
});
describe('ALL Option Special Value', () => {
it('should display ALL correctly when all values are selected', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
allSelected: true,
selectedValue: ['frontend', 'backend', 'database', 'cache'],
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Component should render without errors
expect(screen.getByText('$services')).toBeInTheDocument();
// Check if ALL is displayed in the UI (in the main selection area)
const allTextElement = screen.getByText('ALL');
expect(allTextElement).toBeInTheDocument();
// Verify that the ALL selection wrapper is present and has correct class
const allSelectedWrapper = allTextElement.closest(
'.custom-multiselect-wrapper',
);
expect(allSelectedWrapper).toHaveClass('all-selected');
// Open dropdown to check if ALL option is selected/active
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for API data to be loaded and dropdown to populate
await waitFor(() => {
expect(getFieldValues).toHaveBeenCalledWith(
'traces',
'service.name',
'',
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
);
});
// Check if ALL option is present in dropdown and selected
// Use getAllByText to get all ALL elements and find the one in dropdown
const allElements = screen.getAllByText('ALL');
expect(allElements.length).toBeGreaterThan(1); // Should have ALL in UI and dropdown
// Find the ALL option in the dropdown (should have the 'all-option' class)
const dropdownAllOption = screen.getByRole('option', { name: 'ALL' });
expect(dropdownAllOption).toBeInTheDocument();
expect(dropdownAllOption).toHaveClass('all-option');
expect(dropdownAllOption).toHaveClass('selected');
// Check if the checkbox for ALL option is checked
const checkbox = dropdownAllOption.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
expect(checkbox.checked).toBe(true);
});
});
});

View File

@@ -0,0 +1,155 @@
/* eslint-disable react/jsx-props-no-spreading */
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
// Import dependency building functions
import {
buildDependencies,
buildDependencyGraph,
} from '../DashboardVariablesSelection/util';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
function createMockStore(globalTime?: any): any {
return configureStore([])(() => ({
globalTime: globalTime || {
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
},
}));
}
// Mock the dashboard provider
const mockDashboard = {
data: {
variables: {
env: {
id: 'env',
name: 'env',
type: 'DYNAMIC',
selectedValue: 'production',
order: 1,
dynamicVariablesAttribute: 'environment',
dynamicVariablesSource: 'Traces',
},
service: {
id: 'service',
name: 'service',
type: 'QUERY',
queryValue: 'SELECT DISTINCT service_name WHERE env = $env',
selectedValue: 'api-service',
order: 2,
},
},
},
};
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
const mockSetVariablesToGetUpdated = jest.fn();
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
variablesToGetUpdated: ['env'], // Stable initial value
setVariablesToGetUpdated: mockSetVariablesToGetUpdated,
}),
}));
interface TestWrapperProps {
store: any;
children: React.ReactNode;
}
function TestWrapper({ store, children }: TestWrapperProps): JSX.Element {
return (
<Provider store={store || createMockStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
);
}
TestWrapper.displayName = 'TestWrapper';
describe('Dynamic Variables Integration Tests', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Variable Dependencies and Updates', () => {
it('should build dependency graph correctly for variables', async () => {
// Convert mock dashboard variables to array format expected by the functions
const { variables } = mockDashboard.data as any;
const variablesArray = Object.values(variables) as any[];
// Test the actual dependency building logic
const dependencies = buildDependencies(variablesArray);
const dependencyData = buildDependencyGraph(dependencies);
// Verify the dependency graph structure
expect(dependencies).toBeDefined();
expect(dependencyData).toBeDefined();
expect(dependencyData.order).toBeDefined();
expect(dependencyData.graph).toBeDefined();
expect(dependencyData.hasCycle).toBeDefined();
// Verify that service depends on env (based on queryValue containing $env)
// The dependencies object shows which variables depend on each variable
// So dependencies.env should contain 'service' because service references $env
expect(dependencies.env).toContain('service');
// Verify the topological order (env should come before service)
expect(dependencyData.order).toContain('env');
expect(dependencyData.order).toContain('service');
expect(dependencyData.order.indexOf('env')).toBeLessThan(
dependencyData.order.indexOf('service'),
);
// Verify no cycles in this simple case
expect(dependencyData.hasCycle).toBe(false);
});
it('should handle circular dependency detection', () => {
// Create variables with circular dependency
const circularVariables = [
{
id: 'var1',
name: 'var1',
type: 'QUERY',
queryValue: 'SELECT * WHERE field = $var2',
order: 1,
},
{
id: 'var2',
name: 'var2',
type: 'QUERY',
queryValue: 'SELECT * WHERE field = $var1',
order: 2,
},
];
// Test the actual circular dependency detection logic
const dependencies = buildDependencies(circularVariables as any);
const dependencyData = buildDependencyGraph(dependencies);
// Verify that circular dependency is detected
expect(dependencyData.hasCycle).toBe(true);
expect(dependencyData.cycleNodes).toBeDefined();
expect(dependencyData.cycleNodes).toContain('var1');
expect(dependencyData.cycleNodes).toContain('var2');
// Verify the dependency structure
expect(dependencies.var1).toContain('var2');
expect(dependencies.var2).toContain('var1');
// Verify that topological order is incomplete due to cycle
expect(dependencyData.order.length).toBeLessThan(2);
});
});
});

View File

@@ -0,0 +1,528 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import {
Dashboard,
IDashboardVariable,
Widgets,
} from 'types/api/dashboard/getAll';
import { useAddDynamicVariableToPanels } from '../../../hooks/dashboard/useAddDynamicVariableToPanels';
import { WidgetSelector } from '../DashboardSettings/Variables/VariableItem/WidgetSelector';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Constants to avoid duplication
const CPU_USAGE_TEXT = 'CPU Usage';
const MEMORY_USAGE_TEXT = 'Memory Usage';
const ROW_WIDGET_TEXT = 'Row Widget';
// Helper function to create variable config
const createVariableConfig = (
name: string,
attribute: string,
): IDashboardVariable => ({
id: `var_${name}`,
name,
type: 'DYNAMIC',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
dynamicVariablesAttribute: attribute,
});
const mockDashboard = {
data: {
widgets: [
{
id: 'widget1',
title: CPU_USAGE_TEXT,
panelTypes: 'GRAPH',
},
{
id: 'widget2',
title: MEMORY_USAGE_TEXT,
panelTypes: 'TABLE',
},
{
id: 'widget3',
title: ROW_WIDGET_TEXT,
panelTypes: 'ROW', // Should be filtered out
},
],
layout: [{ i: 'widget1' }, { i: 'widget2' }, { i: 'widget3' }],
},
};
// Mock dependencies
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: mockDashboard,
}),
}));
jest.mock('constants/queryBuilder', () => ({
PANEL_GROUP_TYPES: {
ROW: 'ROW',
},
PANEL_TYPES: {
TIME_SERIES: 'graph',
VALUE: 'value',
TABLE: 'table',
LIST: 'list',
TRACE: 'trace',
BAR: 'bar',
PIE: 'pie',
HISTOGRAM: 'histogram',
EMPTY_WIDGET: 'EMPTY_WIDGET',
},
initialQueriesMap: {
metrics: {
builder: {
queryData: [{}],
},
},
logs: {
builder: {
queryData: [{}],
},
},
traces: {
builder: {
queryData: [{}],
},
},
},
}));
jest.mock('container/GridPanelSwitch/utils', () => ({
generateGridTitle: (title: string): string => title || 'Untitled Panel',
}));
describe('Panel Management Tests', () => {
describe('WidgetSelector Component', () => {
const mockSetSelectedWidgets = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should display panel titles using generateGridTitle', () => {
render(
<WidgetSelector
selectedWidgets={[]}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
// Open the dropdown to see options
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Should show panel titles (excluding ROW widgets) in dropdown
expect(screen.getByText(CPU_USAGE_TEXT)).toBeInTheDocument();
expect(screen.getByText(MEMORY_USAGE_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ROW_WIDGET_TEXT)).not.toBeInTheDocument();
});
it('should filter out row widgets and widgets without layout', () => {
const modifiedDashboard = {
...mockDashboard,
data: {
...mockDashboard.data,
widgets: [
...mockDashboard.data.widgets,
{
id: 'widget4',
title: 'Orphaned Widget',
panelTypes: 'GRAPH',
}, // No layout entry
],
},
};
// Temporarily mock the dashboard
jest.doMock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: modifiedDashboard,
}),
}));
render(
<WidgetSelector
selectedWidgets={[]}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
// Should not show orphaned widget
expect(screen.queryByText('Orphaned Widget')).not.toBeInTheDocument();
});
it('should show selected widgets correctly', () => {
render(
<WidgetSelector
selectedWidgets={['widget1', 'widget2']}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
// Component should show ALL text when all widgets are selected
expect(screen.getByText('ALL')).toBeInTheDocument();
// Check if the dropdown shows selected state correctly
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Should show the selected panels in the dropdown
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
// Check if the specific options (CPU Usage, Memory Usage) are properly selected/checked
const cpuOption = screen.getByText('CPU Usage');
const memoryOption = screen.getByText('Memory Usage');
// Find the specific checkboxes for CPU Usage and Memory Usage
// Navigate from the text to find the associated checkbox
const cpuContainer = cpuOption.closest(
'.ant-select-item-option, .option-item',
);
const memoryContainer = memoryOption.closest(
'.ant-select-item-option, .option-item',
);
const cpuCheckbox = cpuContainer?.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const memoryCheckbox = memoryContainer?.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
// Verify that the specific checkboxes for our selected widgets are checked
expect(cpuCheckbox).toBeInTheDocument();
expect(memoryCheckbox).toBeInTheDocument();
expect(cpuCheckbox.checked).toBe(true);
expect(memoryCheckbox.checked).toBe(true);
// Also verify the checkbox wrappers have the checked class
const cpuCheckboxWrapper = cpuCheckbox?.closest('.ant-checkbox');
const memoryCheckboxWrapper = memoryCheckbox?.closest('.ant-checkbox');
expect(cpuCheckboxWrapper).toHaveClass('ant-checkbox-checked');
expect(memoryCheckboxWrapper).toHaveClass('ant-checkbox-checked');
// Additional verification: ensure these are the correct options by checking their labels
expect(cpuOption).toBeInTheDocument();
expect(memoryOption).toBeInTheDocument();
});
});
describe('useAddDynamicVariableToPanels Hook', () => {
it('should add tag filters to specific selected panels', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
],
},
} as any;
const variableConfig = createVariableConfig('service', 'service.name');
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig,
[],
false,
);
// Verify tag filter was added to the specific widget using new filter expression format
const widget = updatedDashboard?.data?.widgets?.[0] as any;
const queryData = widget?.query?.builder?.queryData?.[0];
// Check that filter expression contains the variable reference
expect(queryData.filter.expression).toContain('service.name in $service');
// Check that filters array also contains the filter item
const filters = queryData.filters.items;
expect(filters).toContainEqual({
id: expect.any(String),
key: {
id: expect.any(String),
key: 'service.name',
dataType: 'string',
isColumn: false,
isJSON: false,
type: '',
},
op: 'IN',
value: '$service',
});
});
it('should apply to all panels when applyToAll is true', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
{
id: 'widget2',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
],
},
} as any;
const variableConfig = createVariableConfig('service', 'service.name');
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig,
[],
true, // Apply to all
);
// Both widgets should have the filter expression
const widget1QueryData = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0];
const widget2QueryData = (updatedDashboard?.data?.widgets?.[1] as Widgets)
?.query?.builder?.queryData?.[0];
// Check filter expressions
expect(widget1QueryData?.filter?.expression).toContain(
'service.name in $service',
);
expect(widget2QueryData?.filter?.expression).toContain(
'service.name in $service',
);
// Check filters arrays
const widget1Filters = widget1QueryData?.filters?.items;
const widget2Filters = widget2QueryData?.filters?.items;
expect(widget1Filters).toContainEqual({
id: expect.any(String),
key: {
id: expect.any(String),
key: 'service.name',
dataType: 'string',
isColumn: false,
isJSON: false,
type: '',
},
op: 'IN',
value: '$service',
});
expect(widget2Filters).toContainEqual({
id: expect.any(String),
key: {
id: expect.any(String),
key: 'service.name',
dataType: 'string',
isColumn: false,
isJSON: false,
type: '',
},
op: 'IN',
value: '$service',
});
});
it('should validate tag filter format with variable name', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filters: { items: [] },
filter: { expression: '' },
},
],
},
},
},
],
},
} as any;
const variableConfig = {
name: 'custom_service_var',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesWidgetIds: ['widget1'],
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
[],
false,
);
const filters = (updatedDashboard?.data?.widgets?.[0] as Widgets)?.query
?.builder?.queryData?.[0]?.filters?.items;
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0]?.filter?.expression;
expect(filterExpression).toContain('service.name in $custom_service_var');
expect(filters).toContainEqual(
expect.objectContaining({
value: '$custom_service_var', // Should use exact variable name
}),
);
});
it('should handle empty widget selection gracefully', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: { widgets: [] },
} as any;
const variableConfig = {
name: 'service',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesWidgetIds: [], // Empty selection
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
[],
false,
);
// Should return dashboard unchanged
expect(updatedDashboard).toEqual(dashboard);
});
it('should handle undefined dashboard gracefully', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const variableConfig = {
name: 'service',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesWidgetIds: ['widget1'],
};
const updatedDashboard = addDynamicVariableToPanels(
undefined,
variableConfig as any,
[],
false,
);
// Should return undefined
expect(updatedDashboard).toBeUndefined();
});
it('should preserve existing filters when adding new variable filter', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: {
expression: "service.name IN ['adservice']",
},
},
],
},
},
},
],
},
} as any;
const variableConfig = {
name: 'host.name',
dynamicVariablesAttribute: 'host.name',
dynamicVariablesWidgetIds: ['widget1'],
description: '',
type: 'DYNAMIC',
queryValue: '',
customValue: '',
textboxValue: '',
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
defaultValue: '3',
modificationUUID: 'bd3a85ab-1393-4783-971e-c252adfd4920',
id: '6b6f526a-6404-46fc-8d87-dc53e7ba8e1f',
order: 0,
dynamicVariablesSource: 'Traces',
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
[],
false,
);
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0]?.filter?.expression;
expect(filterExpression).toContain(
"service.name IN ['adservice'] host.name in $host.name",
);
});
});
});

View File

@@ -0,0 +1,242 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import VariableItem from '../DashboardSettings/Variables/VariableItem/VariableItem';
// Mock dependencies
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
jest.mock('hooks/dynamicVariables/useGetFieldValues', () => ({
useGetFieldValues: (): any => ({
data: {
payload: {
normalizedValues: ['frontend', 'backend', 'database'],
},
},
isLoading: false,
error: null,
}),
}));
jest.mock('components/Editor', () => {
function MockEditor({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}): JSX.Element {
return (
<textarea
data-testid="sql-editor"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
);
}
MockEditor.displayName = 'MockEditor';
return MockEditor;
});
const mockStore = configureStore([])(() => ({
globalTime: {
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
},
}));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
return (
<Provider store={mockStore}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
TestWrapper.displayName = 'TestWrapper';
describe('VariableItem Component - Creation Flow', () => {
const mockOnSave = jest.fn();
const mockOnCancel = jest.fn();
const mockValidateName = jest.fn();
const mockValidateAttributeKey = jest.fn();
// Constants to avoid string duplication
const VARIABLE_NAME_PLACEHOLDER = 'Unique name of the variable';
const VARIABLE_DESCRIPTION_PLACEHOLDER =
'Enter a description for the variable';
const SAVE_BUTTON_TEXT = 'Save Variable';
const DISCARD_BUTTON_TEXT = 'Discard';
const defaultProps = {
variableData: {} as IDashboardVariable,
existingVariables: {},
onCancel: mockOnCancel,
onSave: mockOnSave,
validateName: mockValidateName,
mode: 'ADD' as const,
validateAttributeKey: mockValidateAttributeKey,
};
beforeEach(() => {
jest.clearAllMocks();
mockValidateName.mockReturnValue(true);
});
describe('Dynamic Variable Creation', () => {
it('should switch between variable types correctly', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Test switching to different variable types
const textboxButton = screen.getByText('Textbox');
fireEvent.click(textboxButton);
// Check if the button has the selected class or is in selected state
expect(textboxButton.closest('button')).toHaveClass('selected');
const customButton = screen.getByText('Custom');
fireEvent.click(customButton);
expect(customButton.closest('button')).toHaveClass('selected');
const queryButton = screen.getByText('Query');
fireEvent.click(queryButton);
expect(queryButton.closest('button')).toHaveClass('selected');
// Verify SQL editor appears for QUERY type
expect(screen.getByTestId('sql-editor')).toBeInTheDocument();
});
it('should validate variable name and show errors', () => {
mockValidateName.mockReturnValue(false);
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'duplicate_name' } });
// Should show error message
expect(screen.getByText('Variable name already exists')).toBeInTheDocument();
// Save button should be disabled
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
expect(saveButton.closest('button')).toBeDisabled();
});
it('should detect and prevent cyclic dependencies', async () => {
const existingVariables = {
var1: {
id: 'var1',
name: 'var1',
queryValue: 'SELECT * WHERE field = $var2',
type: 'QUERY',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
} as IDashboardVariable,
var2: {
id: 'var2',
name: 'var2',
queryValue: 'SELECT * WHERE field = $var1',
type: 'QUERY',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
} as IDashboardVariable,
};
render(
<TestWrapper>
<VariableItem {...defaultProps} existingVariables={existingVariables} />
</TestWrapper>,
);
// Fill in name and create a variable that would create cycle
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'var3' } });
// Switch to QUERY type
const queryButton = screen.getByText('Query');
fireEvent.click(queryButton);
// Add query that creates dependency
const sqlEditor = screen.getByTestId('sql-editor');
fireEvent.change(sqlEditor, {
target: { value: 'SELECT * WHERE field = $var1' },
});
// Try to save
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
// Should show cycle detection error
await waitFor(() => {
expect(
screen.getByText(/Circular dependency detected/),
).toBeInTheDocument();
});
});
it('should handle cancel button functionality', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Fill in some data
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'test_variable' } });
// Click cancel
const cancelButton = screen.getByText(DISCARD_BUTTON_TEXT);
fireEvent.click(cancelButton);
// Should call onCancel
expect(mockOnCancel).toHaveBeenCalledWith(expect.any(Object));
});
it('should persist form fields when switching between variable types', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Fill in name and description
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
const descriptionInput = screen.getByPlaceholderText(
VARIABLE_DESCRIPTION_PLACEHOLDER,
);
fireEvent.change(nameInput, { target: { value: 'persistent_var' } });
fireEvent.change(descriptionInput, {
target: { value: 'Persistent description' },
});
// Switch to TEXTBOX type
const textboxButton = screen.getByText('Textbox');
fireEvent.click(textboxButton);
// Switch back to DYNAMIC
const dynamicButton = screen.getByText('Dynamic');
fireEvent.click(dynamicButton);
// Name and description should be preserved
expect(nameInput).toHaveValue('persistent_var');
expect(descriptionInput).toHaveValue('Persistent description');
});
});
});

View File

@@ -12,7 +12,6 @@ import {
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
@@ -32,6 +31,7 @@ import {
unset,
} from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import type { BaseSelectRef } from 'rc-select';
import {
KeyboardEvent,
@@ -42,6 +42,7 @@ import {
useRef,
useState,
} from 'react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import {
BaseAutocompleteData,
DataTypes,
@@ -247,7 +248,15 @@ function QueryBuilderSearchV2(
return false;
}, [currentState, query.aggregateAttribute?.dataType, query.dataSource]);
const { dynamicVariables } = useGetDynamicVariables();
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
);
const { data, isFetching } = useGetAggregateKeys(
{
@@ -793,7 +802,7 @@ function QueryBuilderSearchV2(
values.push(...(attributeValues?.payload?.[key] || []));
// here we want to suggest the variable name matching with the key here, we will go over the dynamic variables for the keys
const variableName = dynamicVariables.find(
const variableName = dynamicVariables?.find(
(variable) =>
variable?.dynamicVariablesAttribute === currentFilterItem?.key?.key,
)?.name;

View File

@@ -181,4 +181,32 @@ describe('useGetResolvedText', () => {
expect(result.current.fullText).toBe(text);
expect(result.current.truncatedText).toBe(text);
});
it('should handle complex variable names with improved patterns', () => {
const text = 'API: $api.v1.endpoint Config: $config.database.host';
const variables = {
'api.v1.endpoint': '/users',
'config.database.host': 'localhost:5432',
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.fullText).toBe('API: /users Config: localhost:5432');
expect(result.current.truncatedText).toBe(
'API: /users Config: localhost:5432',
);
});
it('should stop at punctuation boundaries correctly', () => {
const text = 'Status: $service.name, Error: $error.type;';
const variables = {
'service.name': 'web-api',
'error.type': 'timeout',
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.fullText).toBe('Status: web-api, Error: timeout;');
expect(result.current.truncatedText).toBe('Status: web-api, Error: timeout;');
});
});

View File

@@ -13,21 +13,19 @@ import { getFiltersFromKeyValue } from './utils';
export const useAddDynamicVariableToPanels = (): ((
dashboard: Dashboard | undefined,
variableConfig: IDashboardVariable,
widgetIds?: string[],
applyToAll?: boolean,
) => Dashboard | undefined) =>
useCallback(
(
dashboard: Dashboard | undefined,
variableConfig: IDashboardVariable,
widgetIds?: string[],
applyToAll?: boolean,
): Dashboard | undefined => {
if (!variableConfig) return dashboard;
const {
dynamicVariablesAttribute,
name,
dynamicVariablesWidgetIds,
} = variableConfig;
const { dynamicVariablesAttribute, name } = variableConfig;
const tagFilters: TagFilterItem = getFiltersFromKeyValue(
dynamicVariablesAttribute || '',
@@ -39,7 +37,7 @@ export const useAddDynamicVariableToPanels = (): ((
return addTagFiltersToDashboard(
dashboard,
tagFilters,
dynamicVariablesWidgetIds,
widgetIds,
applyToAll,
);
},

View File

@@ -21,6 +21,7 @@ interface UseDashboardVariablesFromLocalStorageReturn {
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
isDynamic?: boolean,
) => void;
}
@@ -88,10 +89,17 @@ export const useDashboardVariablesFromLocalStorage = (
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
isDynamic?: boolean,
): void => {
setCurrentDashboard((prev) => ({
...prev,
[id]: { selectedValue, allSelected },
[id]:
isDynamic && allSelected
? {
selectedValue: (undefined as unknown) as IDashboardVariable['selectedValue'],
allSelected: true,
}
: { selectedValue, allSelected },
}));
};

View File

@@ -83,10 +83,10 @@ function useGetResolvedText({
const combinedPattern = useMemo(() => {
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const variablePatterns = [
`\\{\\{\\s*?\\.([^\\s}]+)\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*([^\\s}]+)\\s*\\}\\}`, // {{var}}
`${escapedMatcher}([^\\s]+)`, // matcher + var.name
`\\[\\[\\s*([^\\s\\]]+)\\s*\\]\\]`, // [[var]]
`\\{\\{\\s*?\\.([^\\s}]+?)\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*([^\\s}]+?)\\s*\\}\\}`, // {{var}}
`${escapedMatcher}([^\\s.,;)\\]}>]+(?:\\.[^\\s.,;)\\]}>]+)*)`, // $var.name.path - allows dots but stops at punctuation
`\\[\\[\\s*([^\\s\\]]+?)\\s*\\]\\]`, // [[var]]
];
return new RegExp(variablePatterns.join('|'), 'g');
}, [matcher]);

View File

@@ -1,19 +1,23 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { placeWidgetAtBottom } from 'container/NewWidget/utils';
import { isArray } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import {
Dashboard,
IDashboardVariable,
Widgets,
} from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuidv4 } from 'uuid';
import { DynamicVariable } from './useGetDynamicVariables';
const baseLogsSelectedColumns = {
dataType: 'string',
type: '',
@@ -78,14 +82,14 @@ export const getFiltersFromKeyValue = (
key,
dataType: dataType || DataTypes.String,
type: type || '',
id: `${key}--${dataType || DataTypes.String}--${type || ''}--false`,
id: `${key}--${dataType || DataTypes.String}--${type || ''}`,
},
op: op || '=',
value: value.toString(),
});
export const createDynamicVariableToWidgetsMap = (
dynamicVariables: DynamicVariable[],
dynamicVariables: IDashboardVariable[],
widgets: Widgets[],
// eslint-disable-next-line sonarjs/cognitive-complexity
): Record<string, string[]> => {
@@ -101,7 +105,10 @@ export const createDynamicVariableToWidgetsMap = (
// Check each widget for usage of dynamic variables
if (Array.isArray(widgets)) {
widgets.forEach((widget) => {
if (widget.query?.builder?.queryData) {
if (
widget.query?.builder?.queryData &&
widget.query?.queryType === EQueryType.QUERY_BUILDER
) {
widget.query.builder.queryData.forEach((queryData: IBuilderQuery) => {
// Check filter items for dynamic variables
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
@@ -139,3 +146,51 @@ export const createDynamicVariableToWidgetsMap = (
return dynamicVariableToWidgetsMap;
};
export const getWidgetsHavingDynamicVariableAttribute = (
dynamicVariablesAttribute: string,
widgets: Widgets[],
variableName?: string,
): string[] => {
const widgetsHavingDynamicVariableAttribute: string[] = [];
if (Array.isArray(widgets)) {
widgets.forEach((widget) => {
if (
widget.query?.builder?.queryData &&
widget.query?.queryType === EQueryType.QUERY_BUILDER
) {
widget.query.builder.queryData.forEach((queryData: IBuilderQuery) => {
// Check filter items for dynamic variables
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
if (
dynamicVariablesAttribute &&
filter.key?.key === dynamicVariablesAttribute &&
// If variableName is provided, validate that the filter value actually contains the variable reference
(!variableName ||
(isArray(filter.value) && filter.value.includes(`$${variableName}`)) ||
filter.value === `$${variableName}`) &&
!widgetsHavingDynamicVariableAttribute.includes(widget.id)
) {
widgetsHavingDynamicVariableAttribute.push(widget.id);
}
});
// Check filter expression for dynamic variables
if (
queryData.filter?.expression &&
dynamicVariablesAttribute &&
queryData.filter.expression.includes(
variableName ? `$${variableName}` : `$${dynamicVariablesAttribute}`,
) &&
!widgetsHavingDynamicVariableAttribute.includes(widget.id)
) {
widgetsHavingDynamicVariableAttribute.push(widget.id);
}
});
}
});
}
return widgetsHavingDynamicVariableAttribute;
};

View File

@@ -9,13 +9,15 @@ interface UseGetFieldValuesProps {
/** Name of the attribute for which values are being fetched */
name: string;
/** Optional search text */
value?: string;
searchText?: string;
/** Whether the query should be enabled */
enabled?: boolean;
/** Start Unix Milli */
startUnixMilli?: number;
/** End Unix Milli */
endUnixMilli?: number;
/** Existing query */
existingQuery?: string;
}
/**
@@ -30,16 +32,32 @@ interface UseGetFieldValuesProps {
export const useGetFieldValues = ({
signal,
name,
value,
searchText,
startUnixMilli,
endUnixMilli,
enabled = true,
existingQuery,
}: UseGetFieldValuesProps): UseQueryResult<
SuccessResponse<FieldValueResponse> | ErrorResponse
> =>
useQuery<SuccessResponse<FieldValueResponse> | ErrorResponse>({
queryKey: ['fieldValues', signal, name, value, startUnixMilli, endUnixMilli],
queryKey: [
'fieldValues',
signal,
name,
searchText,
startUnixMilli,
endUnixMilli,
existingQuery,
],
queryFn: () =>
getFieldValues(signal, name, value, startUnixMilli, endUnixMilli),
getFieldValues(
signal,
name,
searchText,
startUnixMilli,
endUnixMilli,
existingQuery,
),
enabled,
});

View File

@@ -7,17 +7,16 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
@@ -37,7 +36,13 @@ const useCreateAlerts = (
const { selectedDashboard } = useDashboard();
const { dynamicVariables } = useGetDynamicVariables();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
);
return useCallback(() => {
if (!widget) return;

View File

@@ -47,13 +47,15 @@ export const useGetCompositeQueryParam = (): Query | null => {
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression(
query.aggregateOperator,
query.aggregateAttribute as BaseAutocompleteData,
query.dataSource,
query.timeAggregation,
query.spaceAggregation,
) as any; // Type assertion to handle union type
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;

View File

@@ -2,15 +2,16 @@ import { isAxiosError } from 'axios';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { updateBarStepInterval } from 'container/GridCardLayout/utils';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { SuccessResponse, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
@@ -36,7 +37,15 @@ export const useGetQueryRange: UseGetQueryRange = (
options,
headers,
) => {
const { dynamicVariables } = useGetDynamicVariables();
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
);
const newRequestData: GetQueryResultsProps = useMemo(() => {
const firstQueryData = requestData.query.builder?.queryData[0];

View File

@@ -3,9 +3,10 @@ import {
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WhereClauseConfig } from './useAutoComplete';
@@ -31,8 +32,16 @@ export const useOptions = (
const operators = useOperators(key, keys);
// get matching dynamic variables to suggest
const { dynamicVariables } = useGetDynamicVariables();
const variableName = dynamicVariables.find(
const { selectedDashboard } = useDashboard();
const dynamicVariables = useMemo(
() =>
Object.values(selectedDashboard?.data?.variables || {})?.filter(
(variable: IDashboardVariable) => variable.type === 'DYNAMIC',
),
[selectedDashboard],
);
const variableName = dynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === key,
)?.name;

View File

@@ -27,8 +27,7 @@ export const getDashboardVariables = (
value?.type === 'DYNAMIC' &&
value?.allSelected &&
value?.showALLOption &&
value?.multiSelect &&
!value?.haveCustomValuesSelected
value?.multiSelect
? '__all__'
: value?.selectedValue;
}

View File

@@ -27,7 +27,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
import { QueryData } from 'types/api/widgets/getQuery';
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import { DynamicVariable } from 'hooks/dashboard/useGetDynamicVariables';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
/**
* Validates if metric name is available for METRICS data source
@@ -189,7 +189,7 @@ export const getLegend = (
export async function GetMetricQueryRange(
props: GetQueryResultsProps,
version: string,
dynamicVariables?: DynamicVariable[],
dynamicVariables?: IDashboardVariable[],
signal?: AbortSignal,
headers?: Record<string, string>,
isInfraMonitoring?: boolean,
@@ -368,5 +368,5 @@ export interface GetQueryResultsProps {
end?: number;
step?: number;
originalGraphType?: PANEL_TYPES;
dynamicVariables?: DynamicVariable[];
dynamicVariables?: IDashboardVariable[];
}

View File

@@ -45,6 +45,7 @@ export interface IDashboardContext {
| null
| undefined,
allSelected: boolean,
isDynamic?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;

View File

@@ -55,7 +55,6 @@ export interface IDashboardVariable {
dynamicVariablesAttribute?: string;
dynamicVariablesSource?: string;
haveCustomValuesSelected?: boolean;
dynamicVariablesWidgetIds?: string[];
}
export interface Dashboard {
id: string;

View File

@@ -1,11 +1,20 @@
export interface TelemetryFieldValues {
StringValues?: string[];
NumberValues?: number[];
RelatedValues?: string[];
[key: string]: string[] | number[] | boolean[] | undefined;
}
/**
* Response from the field values API
*/
export interface FieldValueResponse {
/** List of field values returned by type */
values: Record<string, (string | boolean | number)[]>;
values: TelemetryFieldValues;
/** Normalized values combined from all types */
normalizedValues?: string[];
/** Related values from the field */
relatedValues?: string[];
/** Indicates if the returned list is complete */
complete: boolean;
}

View File

@@ -0,0 +1,38 @@
package binding
import (
"io"
"github.com/SigNoz/signoz/pkg/errors"
)
var (
ErrCodeInvalidRequestBody = errors.MustNewCode("invalid_request_body")
)
var (
JSON Binding = &jsonBinding{}
)
type bindBodyOptions struct {
DisallowUnknownFields bool
UseNumber bool
}
type BindBodyOption func(*bindBodyOptions)
func WithDisallowUnknownFields(disallowUnknownFields bool) BindBodyOption {
return func(options *bindBodyOptions) {
options.DisallowUnknownFields = disallowUnknownFields
}
}
func WithUseNumber(useNumber bool) BindBodyOption {
return func(options *bindBodyOptions) {
options.UseNumber = useNumber
}
}
type Binding interface {
BindBody(body io.Reader, obj any, opts ...BindBodyOption) error
}

41
pkg/http/binding/json.go Normal file
View File

@@ -0,0 +1,41 @@
package binding
import (
"encoding/json"
"io"
"github.com/SigNoz/signoz/pkg/errors"
)
var _ Binding = (*jsonBinding)(nil)
type jsonBinding struct{}
func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption) error {
bindBodyOptions := &bindBodyOptions{
DisallowUnknownFields: false,
UseNumber: false,
}
for _, opt := range opts {
opt(bindBodyOptions)
}
decoder := json.NewDecoder(body)
if bindBodyOptions.DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if bindBodyOptions.UseNumber {
decoder.UseNumber()
}
if err := decoder.Decode(obj); err != nil {
return errors.
New(errors.TypeInvalidInput, ErrCodeInvalidRequestBody, "request body is invalid").
WithAdditional(err.Error())
}
return nil
}

View File

@@ -20,7 +20,7 @@ func NewStore(sqlstore sqlstore.SQLStore) types.OrganizationStore {
func (store *store) Create(ctx context.Context, organization *types.Organization) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewInsert().
Model(organization).
Exec(ctx)

View File

@@ -14,12 +14,10 @@ type store struct {
store sqlstore.SQLStore
}
// NewStore creates a new SQLite store for quick filters
func NewStore(db sqlstore.SQLStore) quickfiltertypes.QuickFilterStore {
return &store{store: db}
}
// GetQuickFilters retrieves all filters for an organization
func (s *store) Get(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.StorableQuickFilter, error) {
filters := make([]*quickfiltertypes.StorableQuickFilter, 0)
@@ -38,7 +36,6 @@ func (s *store) Get(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes
return filters, nil
}
// GetSignalFilters retrieves filters for a specific signal in an organization
func (s *store) GetBySignal(ctx context.Context, orgID valuer.UUID, signal string) (*quickfiltertypes.StorableQuickFilter, error) {
filter := new(quickfiltertypes.StorableQuickFilter)
@@ -60,7 +57,6 @@ func (s *store) GetBySignal(ctx context.Context, orgID valuer.UUID, signal strin
return filter, nil
}
// UpsertQuickFilter inserts or updates filters for an organization and signal
func (s *store) Upsert(ctx context.Context, filter *quickfiltertypes.StorableQuickFilter) error {
_, err := s.store.
BunDB().
@@ -78,9 +74,8 @@ func (s *store) Upsert(ctx context.Context, filter *quickfiltertypes.StorableQui
}
func (s *store) Create(ctx context.Context, filters []*quickfiltertypes.StorableQuickFilter) error {
// Using SQLite-specific conflict resolution
_, err := s.store.
BunDB().
BunDBCtx(ctx).
NewInsert().
Model(&filters).
On("CONFLICT (org_id, signal) DO NOTHING").

View File

@@ -8,8 +8,9 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -18,10 +19,10 @@ import (
)
type handler struct {
module user.Module
module root.Module
}
func NewHandler(module user.Module) user.Handler {
func NewHandler(module root.Module) root.Handler {
return &handler{module: module}
}
@@ -30,8 +31,8 @@ func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
defer cancel()
req := new(types.PostableAcceptInvite)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode user"))
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(w, err)
return
}
@@ -79,13 +80,13 @@ func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
}
} else {
password, err := types.NewFactorPassword(req.Password)
password, err := types.NewFactorPassword(req.Password, user.ID.StringValue())
if err != nil {
render.Error(w, err)
return
}
_, err = h.module.CreateUserWithPassword(ctx, user, password)
err = h.module.CreateUser(ctx, user, root.WithFactorPassword(password))
if err != nil {
render.Error(w, err)
return
@@ -335,7 +336,7 @@ func (h *handler) LoginPrecheck(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusOK, resp)
}
func (h *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Request) {
func (handler *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -348,13 +349,13 @@ func (h *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Request)
}
// check if the id lies in the same org as the claims
_, err = h.module.GetUserByID(ctx, claims.OrgID, id)
user, err := handler.module.GetUserByID(ctx, claims.OrgID, id)
if err != nil {
render.Error(w, err)
return
}
token, err := h.module.CreateResetPasswordToken(ctx, id)
token, err := handler.module.GetOrCreateResetPasswordToken(ctx, user.ID)
if err != nil {
render.Error(w, err)
return
@@ -363,7 +364,7 @@ func (h *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Request)
render.Success(w, http.StatusOK, token)
}
func (h *handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
func (handler *handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -373,22 +374,16 @@ func (h *handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
return
}
entry, err := h.module.GetResetPassword(ctx, req.Token)
err := handler.module.UpdatePasswordByResetPasswordToken(ctx, req.Token, req.Password)
if err != nil {
render.Error(w, err)
return
}
err = h.module.UpdatePasswordAndDeleteResetPasswordEntry(ctx, entry.PasswordID, req.Password)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, nil)
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
func (handler *handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -398,25 +393,13 @@ func (h *handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
return
}
// get the current password
password, err := h.module.GetPasswordByUserID(ctx, req.UserId)
err := handler.module.UpdatePassword(ctx, req.UserID, req.OldPassword, req.NewPassword)
if err != nil {
render.Error(w, err)
return
}
if !types.ComparePassword(password.Password, req.OldPassword) {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "old password is incorrect"))
return
}
err = h.module.UpdatePassword(ctx, req.UserId, req.NewPassword)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, nil)
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) Login(w http.ResponseWriter, r *http.Request) {

View File

@@ -13,9 +13,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
@@ -35,7 +34,7 @@ type Module struct {
}
// This module is a WIP, don't take inspiration from this.
func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, analytics analytics.Analytics) user.Module {
func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, analytics analytics.Analytics) root.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &Module{
store: store,
@@ -132,27 +131,28 @@ func (m *Module) GetInviteByEmailInOrg(ctx context.Context, orgID string, email
return m.store.GetInviteByEmailInOrg(ctx, orgID, email)
}
func (m *Module) CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) {
user, err := m.store.CreateUserWithPassword(ctx, user, password)
if err != nil {
return nil, err
}
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
traitsOrProperties := types.NewTraitsFromUser(user)
m.analytics.IdentifyUser(ctx, user.OrgID, user.ID.String(), traitsOrProperties)
m.analytics.TrackUser(ctx, user.OrgID, user.ID.String(), "User Created", traitsOrProperties)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
}
return user, nil
}
if createUserOpts.FactorPassword != nil {
if err := module.store.CreatePassword(ctx, createUserOpts.FactorPassword); err != nil {
return err
}
}
func (m *Module) CreateUser(ctx context.Context, user *types.User) error {
if err := m.store.CreateUser(ctx, user); err != nil {
return nil
}); err != nil {
return err
}
traitsOrProperties := types.NewTraitsFromUser(user)
m.analytics.IdentifyUser(ctx, user.OrgID, user.ID.String(), traitsOrProperties)
m.analytics.TrackUser(ctx, user.OrgID, user.ID.String(), "User Created", traitsOrProperties)
traitsOrProperties := types.NewTraitsFromUser(input)
module.analytics.IdentifyUser(ctx, input.OrgID, input.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, input.OrgID, input.ID.String(), "User Created", traitsOrProperties)
return nil
}
@@ -178,7 +178,6 @@ func (m *Module) ListUsers(ctx context.Context, orgID string) ([]*types.Gettable
}
func (m *Module) UpdateUser(ctx context.Context, orgID string, id string, user *types.User, updatedBy string) (*types.User, error) {
existingUser, err := m.GetUserByID(ctx, orgID, id)
if err != nil {
return nil, err
@@ -202,7 +201,7 @@ func (m *Module) UpdateUser(ctx context.Context, orgID string, id string, user *
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
// Make sure that the request is not demoting the last admin user.
// Make sure that th e request is not demoting the last admin user.
// also an admin user can only change role of their own or other user
if user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin.String() {
adminUsers, err := m.GetUsersByRoleInOrg(ctx, orgID, types.RoleAdmin)
@@ -274,81 +273,77 @@ func (m *Module) DeleteUser(ctx context.Context, orgID string, id string, delete
return nil
}
func (m *Module) CreateResetPasswordToken(ctx context.Context, userID string) (*types.ResetPasswordRequest, error) {
password, err := m.store.GetPasswordByUserID(ctx, userID)
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error) {
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
// if the user does not have a password, we need to create a new one
// this will happen for SSO users
if errors.Ast(err, errors.TypeNotFound) {
password, err = m.store.CreatePassword(ctx, &types.FactorPassword{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
},
Password: valuer.GenerateUUID().String(),
UserID: userID,
})
if err != nil {
return nil, err
}
} else {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
resetPasswordRequest, err := types.NewResetPasswordRequest(password.ID.StringValue())
if password == nil {
// if the user does not have a password, we need to create a new one (common for SSO/SAML users)
password = types.MustGenerateFactorPassword(userID.String())
if err := module.store.CreatePassword(ctx, password); err != nil {
return nil, err
}
}
resetPasswordToken, err := types.NewResetPasswordToken(password.ID)
if err != nil {
return nil, err
}
// check if a reset password token already exists for this user
existingRequest, err := m.store.GetResetPasswordByPasswordID(ctx, resetPasswordRequest.PasswordID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingRequest != nil {
return existingRequest, nil
}
err = m.store.CreateResetPasswordToken(ctx, resetPasswordRequest)
err = module.store.CreateResetPasswordToken(ctx, resetPasswordToken)
if err != nil {
return nil, err
if !errors.Ast(err, errors.TypeAlreadyExists) {
return nil, err
}
// if the token already exists, we return the existing token
resetPasswordToken, err = module.store.GetResetPasswordTokenByPasswordID(ctx, password.ID)
if err != nil {
return nil, err
}
}
return resetPasswordRequest, nil
return resetPasswordToken, nil
}
func (m *Module) GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) {
return m.store.GetPasswordByUserID(ctx, id)
}
func (m *Module) GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) {
return m.store.GetResetPassword(ctx, token)
}
func (m *Module) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, passwordID string, password string) error {
hashedPassword, err := types.HashPassword(password)
func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, token string, passwd string) error {
resetPasswordToken, err := module.store.GetResetPasswordToken(ctx, token)
if err != nil {
return err
}
existingPassword, err := m.store.GetPasswordByID(ctx, passwordID)
password, err := module.store.GetPassword(ctx, resetPasswordToken.PasswordID)
if err != nil {
return err
}
return m.store.UpdatePasswordAndDeleteResetPasswordEntry(ctx, existingPassword.UserID, hashedPassword)
if err := password.Update(passwd); err != nil {
return err
}
return module.store.UpdatePassword(ctx, password)
}
func (m *Module) UpdatePassword(ctx context.Context, userID string, password string) error {
hashedPassword, err := types.HashPassword(password)
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
return err
}
return m.store.UpdatePassword(ctx, userID, hashedPassword)
if !password.Equals(oldpasswd) {
return errors.New(errors.TypeInvalidInput, types.ErrCodeIncorrectPassword, "old password is incorrect")
}
if err := password.Update(passwd); err != nil {
return err
}
return module.store.UpdatePassword(ctx, password)
}
func (m *Module) GetAuthenticatedUser(ctx context.Context, orgID, email, password, refreshToken string) (*types.User, error) {
@@ -381,13 +376,13 @@ func (m *Module) GetAuthenticatedUser(ctx context.Context, orgID, email, passwor
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "please provide an orgID")
}
existingPassword, err := m.store.GetPasswordByUserID(ctx, dbUser.ID.StringValue())
existingPassword, err := m.store.GetPasswordByUserID(ctx, dbUser.ID)
if err != nil {
return nil, err
}
if !types.ComparePassword(existingPassword.Password, password) {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid password")
if !existingPassword.Equals(password) {
return nil, errors.New(errors.TypeInvalidInput, types.ErrCodeIncorrectPassword, "password is incorrect")
}
return dbUser, nil
@@ -631,34 +626,31 @@ func (m *Module) UpdateDomain(ctx context.Context, domain *types.GettableOrgDoma
return m.store.UpdateDomain(ctx, domain)
}
func (m *Module) Register(ctx context.Context, req *types.PostableRegisterOrgAndAdmin) (*types.User, error) {
if req.Email == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "email is required")
}
if req.Password == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "password is required")
}
organization := types.NewOrganization(req.OrgDisplayName)
err := m.orgSetter.Create(ctx, organization)
func (module *Module) CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email string, passwd string) (*types.User, error) {
user, err := types.NewUser(name, email, types.RoleAdmin.String(), organization.ID.StringValue())
if err != nil {
return nil, model.InternalError(err)
return nil, err
}
user, err := types.NewUser(req.Name, req.Email, types.RoleAdmin.String(), organization.ID.StringValue())
password, err := types.NewFactorPassword(passwd, user.ID.StringValue())
if err != nil {
return nil, model.InternalError(err)
return nil, err
}
password, err := types.NewFactorPassword(req.Password)
if err != nil {
return nil, model.InternalError(err)
}
if err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err := module.orgSetter.Create(ctx, organization)
if err != nil {
return err
}
user, err = m.CreateUserWithPassword(ctx, user, password)
if err != nil {
return nil, model.InternalError(err)
err = module.CreateUser(ctx, user, root.WithFactorPassword(password))
if err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return user, nil

View File

@@ -104,55 +104,29 @@ func (store *store) ListInvite(ctx context.Context, orgID string) ([]*types.Invi
return *invites, nil
}
func (store *store) CreatePassword(ctx context.Context, password *types.FactorPassword) (*types.FactorPassword, error) {
_, err := store.sqlstore.BunDB().NewInsert().
func (store *store) CreatePassword(ctx context.Context, password *types.FactorPassword) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(password).
Exec(ctx)
if err != nil {
return nil, store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with user id: %s already exists", password.UserID)
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password for user %s already exists", password.UserID)
}
return password, nil
}
func (store *store) CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
}
defer func() {
_ = tx.Rollback()
}()
if _, err := tx.NewInsert().
Model(user).
Exec(ctx); err != nil {
return nil, store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID)
}
password.UserID = user.ID.StringValue()
if _, err := tx.NewInsert().
Model(password).
Exec(ctx); err != nil {
return nil, store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with email: %s already exists in org: %s", user.Email, user.OrgID)
}
err = tx.Commit()
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction")
}
return user, nil
return nil
}
func (store *store) CreateUser(ctx context.Context, user *types.User) error {
_, err := store.sqlstore.BunDB().NewInsert().
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(user).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID)
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email %s already exists in org %s", user.Email, user.OrgID)
}
return nil
}
@@ -329,7 +303,7 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
// delete reset password request
_, err = tx.NewDelete().
Model(new(types.ResetPasswordRequest)).
Model(new(types.ResetPasswordToken)).
Where("password_id = ?", password.ID.String()).
Exec(ctx)
if err != nil {
@@ -372,128 +346,123 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
return nil
}
func (store *store) CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *types.ResetPasswordRequest) error {
_, err := store.sqlstore.BunDB().NewInsert().
Model(resetPasswordRequest).
func (store *store) CreateResetPasswordToken(ctx context.Context, resetPasswordToken *types.ResetPasswordToken) error {
_, err := store.
sqlstore.
BunDB().
NewInsert().
Model(resetPasswordToken).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrResetPasswordTokenAlreadyExists, "reset password token with password id: %s already exists", resetPasswordRequest.PasswordID)
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrResetPasswordTokenAlreadyExists, "reset password token for password %s already exists", resetPasswordToken.PasswordID)
}
return nil
}
func (store *store) GetPasswordByID(ctx context.Context, id string) (*types.FactorPassword, error) {
func (store *store) GetPassword(ctx context.Context, id valuer.UUID) (*types.FactorPassword, error) {
password := new(types.FactorPassword)
err := store.sqlstore.BunDB().NewSelect().
err := store.
sqlstore.
BunDB().
NewSelect().
Model(password).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with id: %s does not exist", id)
}
return password, nil
}
func (store *store) GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) {
func (store *store) GetPasswordByUserID(ctx context.Context, userID valuer.UUID) (*types.FactorPassword, error) {
password := new(types.FactorPassword)
err := store.sqlstore.BunDB().NewSelect().
err := store.
sqlstore.
BunDB().
NewSelect().
Model(password).
Where("user_id = ?", id).
Where("user_id = ?", userID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", id)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password for user %s does not exist", userID)
}
return password, nil
}
func (store *store) GetResetPasswordByPasswordID(ctx context.Context, passwordID string) (*types.ResetPasswordRequest, error) {
resetPasswordRequest := new(types.ResetPasswordRequest)
err := store.sqlstore.BunDB().NewSelect().
Model(resetPasswordRequest).
func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) (*types.ResetPasswordToken, error) {
resetPasswordToken := new(types.ResetPasswordToken)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(resetPasswordToken).
Where("password_id = ?", passwordID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", passwordID)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token for password %s does not exist", passwordID)
}
return resetPasswordRequest, nil
return resetPasswordToken, nil
}
func (store *store) GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) {
resetPasswordRequest := new(types.ResetPasswordRequest)
err := store.sqlstore.BunDB().NewSelect().
func (store *store) GetResetPasswordToken(ctx context.Context, token string) (*types.ResetPasswordToken, error) {
resetPasswordRequest := new(types.ResetPasswordToken)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(resetPasswordRequest).
Where("token = ?", token).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with token: %s does not exist", token)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token does not exist", token)
}
return resetPasswordRequest, nil
}
func (store *store) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, userID string, password string) error {
func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.FactorPassword) error {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
return err
}
defer func() {
_ = tx.Rollback()
}()
factorPassword := &types.FactorPassword{
UserID: userID,
Password: password,
TimeAuditable: types.TimeAuditable{
UpdatedAt: time.Now(),
},
}
_, err = tx.NewUpdate().
_, err = tx.
NewUpdate().
Model(factorPassword).
Column("password").
Column("updated_at").
Where("user_id = ?", userID).
Where("user_id = ?", factorPassword.UserID).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID)
return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password for user %s does not exist", factorPassword.UserID)
}
_, err = tx.NewDelete().
Model(&types.ResetPasswordRequest{}).
Where("password_id = ?", userID).
_, err = tx.
NewDelete().
Model(&types.ResetPasswordToken{}).
Where("password_id = ?", factorPassword.ID).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", userID)
return err
}
err = tx.Commit()
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction")
return err
}
return nil
}
func (store *store) UpdatePassword(ctx context.Context, userID string, password string) error {
factorPassword := &types.FactorPassword{
UserID: userID,
Password: password,
TimeAuditable: types.TimeAuditable{
UpdatedAt: time.Now(),
},
}
_, err := store.sqlstore.BunDB().NewUpdate().
Model(factorPassword).
Column("password").
Column("updated_at").
Where("user_id = ?", userID).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID)
}
return nil
}
func (store *store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) {
domain := new(types.StorableOrgDomain)
err := store.sqlstore.BunDB().NewSelect().
@@ -844,3 +813,9 @@ func (store *store) CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (
return int64(count), nil
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
return cb(ctx)
})
}

View File

@@ -0,0 +1,27 @@
package user
import "github.com/SigNoz/signoz/pkg/types"
type createUserOptions struct {
FactorPassword *types.FactorPassword
}
type CreateUserOption func(*createUserOptions)
func WithFactorPassword(factorPassword *types.FactorPassword) CreateUserOption {
return func(o *createUserOptions) {
o.FactorPassword = factorPassword
}
}
func NewCreateUserOptions(opts ...CreateUserOption) *createUserOptions {
o := &createUserOptions{
FactorPassword: nil,
}
for _, opt := range opts {
opt(o)
}
return o
}

View File

@@ -12,16 +12,29 @@ import (
)
type Module interface {
// Creates the organization and the first user of that organization.
CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email string, password string) (*types.User, error)
// Creates a user and sends an analytics event.
CreateUser(ctx context.Context, user *types.User, opts ...CreateUserOption) error
// Get or Create a reset password token for a user. If the password does not exist, a new one is randomly generated and inserted. The function
// is idempotent and can be called multiple times.
GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error)
// Updates password of a user using a reset password token. It also deletes all reset password tokens for the user.
// This is used to reset the password of a user when they forget their password.
UpdatePasswordByResetPasswordToken(ctx context.Context, token string, password string) error
// Updates password of user to the new password. It also deletes all reset password tokens for the user.
UpdatePassword(ctx context.Context, userID valuer.UUID, oldPassword string, password string) error
// invite
CreateBulkInvite(ctx context.Context, orgID, userID string, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error)
DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error
GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error)
GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*types.Invite, error)
// user
CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error)
CreateUser(ctx context.Context, user *types.User) error
GetUserByID(ctx context.Context, orgID string, id string) (*types.GettableUser, error)
GetUsersByEmail(ctx context.Context, email string) ([]*types.GettableUser, error) // public function
GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*types.GettableUser, error)
@@ -40,13 +53,6 @@ type Module interface {
PrepareSsoRedirect(ctx context.Context, redirectUri, email string) (string, error)
CanUsePassword(ctx context.Context, email string) (bool, error)
// password
CreateResetPasswordToken(ctx context.Context, userID string) (*types.ResetPasswordRequest, error)
GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error)
GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error)
UpdatePassword(ctx context.Context, userID string, password string) error
UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, passwordID string, password string) error
// Auth Domain
GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error)
GetDomainFromSsoResponse(ctx context.Context, url *url.URL) (*types.GettableOrgDomain, error)
@@ -63,9 +69,6 @@ type Module interface {
RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableAPIKeyUser, error)
// Register
Register(ctx context.Context, req *types.PostableRegisterOrgAndAdmin) (*types.User, error)
statsreporter.StatsCollector
}

View File

@@ -2065,7 +2065,8 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
return
}
user, errv2 := aH.Signoz.Modules.User.Register(r.Context(), &req)
organization := types.NewOrganization(req.OrgDisplayName)
user, errv2 := aH.Signoz.Modules.User.CreateFirstUser(r.Context(), organization, req.Name, req.Email, req.Password)
if errv2 != nil {
render.Error(w, errv2)
return

View File

@@ -0,0 +1,210 @@
package types
import (
"encoding/json"
"slices"
"time"
"unicode"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/sethvargo/go-password/password"
"github.com/uptrace/bun"
"golang.org/x/crypto/bcrypt"
)
var (
symbols []rune = []rune("~!@#$%^&*()_+`-={}|[]\\:\"<>?,./")
minPasswordLength int = 12
ErrInvalidPassword = errors.Newf(errors.TypeInvalidInput, errors.MustNewCode("invalid_password"), "password must be at least %d characters long, should contain at least one uppercase letter [A-Z], one lowercase letter [a-z], one number [0-9], and one symbol [%c].", minPasswordLength, symbols)
ErrCodeResetPasswordTokenAlreadyExists = errors.MustNewCode("reset_password_token_already_exists")
ErrCodePasswordNotFound = errors.MustNewCode("password_not_found")
ErrCodeResetPasswordTokenNotFound = errors.MustNewCode("reset_password_token_not_found")
ErrCodePasswordAlreadyExists = errors.MustNewCode("password_already_exists")
ErrCodeIncorrectPassword = errors.MustNewCode("incorrect_password")
)
type PostableResetPassword struct {
Password string `json:"password"`
Token string `json:"token"`
}
type ChangePasswordRequest struct {
UserID valuer.UUID `json:"userId"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
type ResetPasswordToken struct {
bun.BaseModel `bun:"table:reset_password_token"`
Identifiable
Token string `bun:"token,type:text,notnull" json:"token"`
PasswordID valuer.UUID `bun:"password_id,type:text,notnull,unique" json:"passwordId"`
}
type FactorPassword struct {
bun.BaseModel `bun:"table:factor_password"`
Identifiable
Password string `bun:"password,type:text,notnull" json:"password"`
Temporary bool `bun:"temporary,type:boolean,notnull" json:"temporary"`
UserID string `bun:"user_id,type:text,notnull,unique" json:"userId"`
TimeAuditable
}
func (request *ChangePasswordRequest) UnmarshalJSON(data []byte) error {
type Alias ChangePasswordRequest
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if !IsPasswordValid(temp.NewPassword) {
return ErrInvalidPassword
}
*request = ChangePasswordRequest(temp)
return nil
}
func (request *PostableResetPassword) UnmarshalJSON(data []byte) error {
type Alias PostableResetPassword
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if !IsPasswordValid(temp.Password) {
return ErrInvalidPassword
}
*request = PostableResetPassword(temp)
return nil
}
func NewFactorPassword(password string, userID string) (*FactorPassword, error) {
if !IsPasswordValid(password) {
return nil, ErrInvalidPassword
}
hashedPassword, err := NewHashedPassword(password)
if err != nil {
return nil, err
}
return &FactorPassword{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
},
Password: string(hashedPassword),
Temporary: false,
UserID: userID,
TimeAuditable: TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}, nil
}
func GenerateFactorPassword(userID string) (*FactorPassword, error) {
password, err := password.Generate(12, 1, 1, false, false)
if err != nil {
return nil, err
}
return NewFactorPassword(password+"Z", userID)
}
func MustGenerateFactorPassword(userID string) *FactorPassword {
password, err := GenerateFactorPassword(userID)
if err != nil {
panic(err)
}
return password
}
func NewHashedPassword(password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedPassword), nil
}
func NewResetPasswordToken(passwordID valuer.UUID) (*ResetPasswordToken, error) {
return &ResetPasswordToken{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
},
Token: valuer.GenerateUUID().String(),
PasswordID: passwordID,
}, nil
}
func IsPasswordValid(password string) bool {
if len(password) < minPasswordLength {
return false
}
hasUpperCase := false
hasLowerCase := false
hasNumber := false
hasSymbol := false
for _, char := range password {
if !hasLowerCase && unicode.IsLower(char) {
hasLowerCase = true
}
if !hasUpperCase && unicode.IsUpper(char) {
hasUpperCase = true
}
if !hasNumber && unicode.IsNumber(char) {
hasNumber = true
}
if !hasSymbol && slices.Contains(symbols, char) {
hasSymbol = true
}
if !unicode.IsLetter(char) && !unicode.IsNumber(char) && !slices.Contains(symbols, char) {
return false
}
}
if !hasUpperCase || !hasLowerCase || !hasNumber || !hasSymbol {
return false
}
return true
}
func (f *FactorPassword) Update(password string) error {
if !IsPasswordValid(password) {
return ErrInvalidPassword
}
hashedPassword, err := NewHashedPassword(password)
if err != nil {
return err
}
f.Password = hashedPassword
f.UpdatedAt = time.Now()
return nil
}
func (f *FactorPassword) Equals(password string) bool {
return comparePassword(f.Password, password)
}
func comparePassword(hashedPassword string, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
}

View File

@@ -0,0 +1,14 @@
package types
import (
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
func TestMustGenerateFactorPassword(t *testing.T) {
assert.NotPanics(t, func() {
MustGenerateFactorPassword(valuer.GenerateUUID().String())
})
}

View File

@@ -2,15 +2,14 @@ package types
import (
"context"
"encoding/json"
"net/url"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/uptrace/bun"
"golang.org/x/crypto/bcrypt"
)
var (
@@ -24,59 +23,6 @@ var (
ErrAPIKeyNotFound = errors.MustNewCode("api_key_not_found")
)
type UserStore interface {
// invite
CreateBulkInvite(ctx context.Context, invites []*Invite) error
ListInvite(ctx context.Context, orgID string) ([]*Invite, error)
DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error
GetInviteByToken(ctx context.Context, token string) (*GettableInvite, error)
GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*Invite, error)
// user
CreateUserWithPassword(ctx context.Context, user *User, password *FactorPassword) (*User, error)
CreateUser(ctx context.Context, user *User) error
GetUserByID(ctx context.Context, orgID string, id string) (*GettableUser, error)
GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*GettableUser, error)
GetUsersByEmail(ctx context.Context, email string) ([]*GettableUser, error)
GetUsersByRoleInOrg(ctx context.Context, orgID string, role Role) ([]*GettableUser, error)
ListUsers(ctx context.Context, orgID string) ([]*GettableUser, error)
UpdateUser(ctx context.Context, orgID string, id string, user *User) (*User, error)
DeleteUser(ctx context.Context, orgID string, id string) error
// password
CreatePassword(ctx context.Context, password *FactorPassword) (*FactorPassword, error)
CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *ResetPasswordRequest) error
GetPasswordByID(ctx context.Context, id string) (*FactorPassword, error)
GetPasswordByUserID(ctx context.Context, id string) (*FactorPassword, error)
GetResetPassword(ctx context.Context, token string) (*ResetPasswordRequest, error)
GetResetPasswordByPasswordID(ctx context.Context, passwordID string) (*ResetPasswordRequest, error)
UpdatePassword(ctx context.Context, userID string, password string) error
UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, userID string, password string) error
// Auth Domain
GetDomainByName(ctx context.Context, name string) (*StorableOrgDomain, error)
// org domain (auth domains) CRUD ops
GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*GettableOrgDomain, error)
ListDomains(ctx context.Context, orgId valuer.UUID) ([]*GettableOrgDomain, error)
GetDomain(ctx context.Context, id uuid.UUID) (*GettableOrgDomain, error)
CreateDomain(ctx context.Context, d *GettableOrgDomain) error
UpdateDomain(ctx context.Context, domain *GettableOrgDomain) error
DeleteDomain(ctx context.Context, id uuid.UUID) error
// Temporary func for SSO
GetDefaultOrgID(ctx context.Context) (string, error)
// API KEY
CreateAPIKey(ctx context.Context, apiKey *StorableAPIKey) error
UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *StorableAPIKey, updaterID valuer.UUID) error
ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*StorableAPIKeyUser, error)
RevokeAPIKey(ctx context.Context, id valuer.UUID, revokedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*StorableAPIKeyUser, error)
CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
}
type GettableUser struct {
User
Organization string `json:"organization"`
@@ -121,12 +67,12 @@ func NewUser(displayName string, email string, role string, orgID string) (*User
}
type PostableRegisterOrgAndAdmin struct {
PostableAcceptInvite
Name string `json:"name"`
OrgID string `json:"orgId"`
OrgDisplayName string `json:"orgDisplayName"`
OrgName string `json:"orgName"`
Email string `json:"email"`
Password string `json:"password"`
}
type PostableAcceptInvite struct {
@@ -138,95 +84,6 @@ type PostableAcceptInvite struct {
SourceURL string `json:"sourceUrl"`
}
func (p *PostableAcceptInvite) Validate() error {
if p.InviteToken == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invite token is required")
}
if p.Password == "" || len(p.Password) < 8 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "password must be at least 8 characters long")
}
return nil
}
type FactorPassword struct {
bun.BaseModel `bun:"table:factor_password"`
Identifiable
TimeAuditable
Password string `bun:"password,type:text,notnull" json:"password"`
Temporary bool `bun:"temporary,type:boolean,notnull" json:"temporary"`
UserID string `bun:"user_id,type:text,notnull,unique,references:user(id)" json:"userId"`
}
func NewFactorPassword(password string) (*FactorPassword, error) {
if password == "" && len(password) < 8 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "password must be at least 8 characters long")
}
password = strings.TrimSpace(password)
hashedPassword, err := HashPassword(password)
if err != nil {
return nil, err
}
return &FactorPassword{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: TimeAuditable{
CreatedAt: time.Now(),
},
Password: hashedPassword,
Temporary: false,
}, nil
}
func HashPassword(password string) (string, error) {
// bcrypt automatically handles salting and uses a secure work factor
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedPassword), nil
}
func ComparePassword(hashedPassword, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
}
type ResetPasswordRequest struct {
bun.BaseModel `bun:"table:reset_password_token"`
Identifiable
Token string `bun:"token,type:text,notnull" json:"token"`
PasswordID string `bun:"password_id,type:text,notnull,unique" json:"passwordId"`
}
func NewResetPasswordRequest(passwordID string) (*ResetPasswordRequest, error) {
return &ResetPasswordRequest{
Identifiable: Identifiable{
ID: valuer.GenerateUUID(),
},
Token: valuer.GenerateUUID().String(),
PasswordID: passwordID,
}, nil
}
type PostableResetPassword struct {
Password string `json:"password"`
Token string `json:"token"`
}
type ChangePasswordRequest struct {
UserId string `json:"userId"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
type PostableLoginRequest struct {
OrgID string `json:"orgId"`
Email string `json:"email"`
@@ -265,3 +122,97 @@ func NewTraitsFromUser(user *User) map[string]any {
"created_at": user.CreatedAt,
}
}
func (request *PostableAcceptInvite) UnmarshalJSON(data []byte) error {
type Alias PostableAcceptInvite
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.InviteToken == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invite token is required")
}
if !IsPasswordValid(temp.Password) {
return ErrInvalidPassword
}
*request = PostableAcceptInvite(temp)
return nil
}
func (request *PostableRegisterOrgAndAdmin) UnmarshalJSON(data []byte) error {
type Alias PostableRegisterOrgAndAdmin
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.Email == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
}
if !IsPasswordValid(temp.Password) {
return ErrInvalidPassword
}
*request = PostableRegisterOrgAndAdmin(temp)
return nil
}
type UserStore interface {
// invite
CreateBulkInvite(ctx context.Context, invites []*Invite) error
ListInvite(ctx context.Context, orgID string) ([]*Invite, error)
DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error
GetInviteByToken(ctx context.Context, token string) (*GettableInvite, error)
GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*Invite, error)
// Creates a user.
CreateUser(ctx context.Context, user *User) error
GetUserByID(ctx context.Context, orgID string, id string) (*GettableUser, error)
GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*GettableUser, error)
GetUsersByEmail(ctx context.Context, email string) ([]*GettableUser, error)
GetUsersByRoleInOrg(ctx context.Context, orgID string, role Role) ([]*GettableUser, error)
ListUsers(ctx context.Context, orgID string) ([]*GettableUser, error)
UpdateUser(ctx context.Context, orgID string, id string, user *User) (*User, error)
DeleteUser(ctx context.Context, orgID string, id string) error
// Creates a password.
CreatePassword(ctx context.Context, password *FactorPassword) error
CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *ResetPasswordToken) error
GetPassword(ctx context.Context, id valuer.UUID) (*FactorPassword, error)
GetPasswordByUserID(ctx context.Context, userID valuer.UUID) (*FactorPassword, error)
GetResetPasswordToken(ctx context.Context, token string) (*ResetPasswordToken, error)
GetResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) (*ResetPasswordToken, error)
UpdatePassword(ctx context.Context, password *FactorPassword) error
// Auth Domain
GetDomainByName(ctx context.Context, name string) (*StorableOrgDomain, error)
// org domain (auth domains) CRUD ops
GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*GettableOrgDomain, error)
ListDomains(ctx context.Context, orgId valuer.UUID) ([]*GettableOrgDomain, error)
GetDomain(ctx context.Context, id uuid.UUID) (*GettableOrgDomain, error)
CreateDomain(ctx context.Context, d *GettableOrgDomain) error
UpdateDomain(ctx context.Context, domain *GettableOrgDomain) error
DeleteDomain(ctx context.Context, id uuid.UUID) error
// Temporary func for SSO
GetDefaultOrgID(ctx context.Context) (string, error)
// API KEY
CreateAPIKey(ctx context.Context, apiKey *StorableAPIKey) error
UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *StorableAPIKey, updaterID valuer.UUID) error
ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*StorableAPIKeyUser, error)
RevokeAPIKey(ctx context.Context, id valuer.UUID, revokedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*StorableAPIKeyUser, error)
CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error)
// Transaction
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
}

View File

@@ -8,7 +8,7 @@ from fixtures import dev, types
USER_ADMIN_NAME = "admin"
USER_ADMIN_EMAIL = "admin@integration.test"
USER_ADMIN_PASSWORD = "password"
USER_ADMIN_PASSWORD = "password123Z$"
@pytest.fixture(name="create_user_admin", scope="package")

View File

@@ -1,7 +1,7 @@
import docker
import docker.errors
import psycopg2
import pytest
from sqlalchemy import create_engine, sql
from testcontainers.core.container import Network
from testcontainers.postgres import PostgresContainer
@@ -33,14 +33,14 @@ def postgres(
)
container.start()
connection = psycopg2.connect(
dbname=container.dbname,
user=container.username,
password=container.password,
host=container.get_container_host_ip(),
port=container.get_exposed_port(5432),
engine = create_engine(
f"postgresql+psycopg2://{container.username}:{container.password}@{container.get_container_host_ip()}:{container.get_exposed_port(5432)}/{container.dbname}"
)
with engine.connect() as conn:
result = conn.execute(sql.text("SELECT 1"))
assert result.fetchone()[0] == 1
return types.TestContainerSQL(
container=types.TestContainerDocker(
id=container.get_wrapped_container().id,
@@ -57,7 +57,7 @@ def postgres(
)
},
),
conn=connection,
conn=engine,
env={
"SIGNOZ_SQLSTORE_PROVIDER": "postgres",
"SIGNOZ_SQLSTORE_POSTGRES_DSN": f"postgresql://{container.username}:{container.password}@{container.get_wrapped_container().name}:{5432}/{container.dbname}",
@@ -83,17 +83,17 @@ def postgres(
host_config = container.host_configs["5432"]
env = cache["env"]
connection = psycopg2.connect(
dbname=env["SIGNOZ_SQLSTORE_POSTGRES_DBNAME"],
user=env["SIGNOZ_SQLSTORE_POSTGRES_USER"],
password=env["SIGNOZ_SQLSTORE_POSTGRES_PASSWORD"],
host=host_config.address,
port=host_config.port,
engine = create_engine(
f"postgresql+psycopg2://{env['SIGNOZ_SQLSTORE_POSTGRES_USER']}:{env['SIGNOZ_SQLSTORE_POSTGRES_PASSWORD']}@{host_config.address}:{host_config.port}/{env['SIGNOZ_SQLSTORE_POSTGRES_DBNAME']}"
)
with engine.connect() as conn:
result = conn.execute(sql.text("SELECT 1"))
assert result.fetchone()[0] == 1
return types.TestContainerSQL(
container=container,
conn=connection,
conn=engine,
env=env,
)

View File

@@ -1,8 +1,8 @@
import sqlite3
from collections import namedtuple
from typing import Any, Generator
import pytest
from sqlalchemy import create_engine, sql
from fixtures import dev, types
@@ -22,7 +22,11 @@ def sqlite(
def create() -> types.TestContainerSQL:
tmpdir = tmpfs("sqlite")
path = tmpdir / "signoz.db"
connection = sqlite3.connect(path, check_same_thread=False)
engine = create_engine(f"sqlite:///{path}")
with engine.connect() as conn:
result = conn.execute(sql.text("SELECT 1"))
assert result.fetchone()[0] == 1
return types.TestContainerSQL(
container=types.TestContainerDocker(
@@ -30,7 +34,7 @@ def sqlite(
host_configs={},
container_configs={},
),
conn=connection,
conn=engine,
env={
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
"SIGNOZ_SQLSTORE_SQLITE_PATH": str(path),
@@ -42,7 +46,12 @@ def sqlite(
def restore(cache: dict) -> types.TestContainerSQL:
path = cache["env"].get("SIGNOZ_SQLSTORE_SQLITE_PATH")
conn = sqlite3.connect(path, check_same_thread=False)
engine = create_engine(f"sqlite:///{path}")
with engine.connect() as conn:
result = conn.execute(sql.text("SELECT 1"))
assert result.fetchone()[0] == 1
return types.TestContainerSQL(
container=types.TestContainerDocker(
id="",

View File

@@ -6,6 +6,7 @@ import clickhouse_connect
import clickhouse_connect.driver
import clickhouse_connect.driver.client
import py
from sqlalchemy import Engine
from testcontainers.core.container import Network
LegacyPath = py.path.local
@@ -76,7 +77,7 @@ class TestContainerDocker:
class TestContainerSQL:
__test__ = False
container: TestContainerDocker
conn: any
conn: Engine
env: Dict[str, str]
def __cache__(self) -> dict:

View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "astroid"
@@ -390,7 +390,7 @@ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {main = "sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""}
markers = {main = "sys_platform == \"win32\"", dev = "sys_platform == \"win32\" or platform_system == \"Windows\""}
[[package]]
name = "dill"
@@ -431,6 +431,75 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"]
ssh = ["paramiko (>=2.4.3)"]
websockets = ["websocket-client (>=1.3.0)"]
[[package]]
name = "greenlet"
version = "3.2.4"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
files = [
{file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"},
{file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"},
{file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"},
{file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"},
{file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"},
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
]
[package.extras]
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
[[package]]
name = "idna"
version = "3.10"
@@ -889,6 +958,102 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "sqlalchemy"
version = "2.0.43"
description = "Database Abstraction Library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"},
{file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"},
{file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c"},
{file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443"},
{file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c"},
{file = "SQLAlchemy-2.0.43-cp37-cp37m-win32.whl", hash = "sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32"},
{file = "SQLAlchemy-2.0.43-cp37-cp37m-win_amd64.whl", hash = "sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512"},
{file = "sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069"},
{file = "sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154"},
{file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612"},
{file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019"},
{file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20"},
{file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18"},
{file = "sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00"},
{file = "sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b"},
{file = "sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29"},
{file = "sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631"},
{file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685"},
{file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca"},
{file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d"},
{file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3"},
{file = "sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921"},
{file = "sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8"},
{file = "sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24"},
{file = "sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83"},
{file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9"},
{file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48"},
{file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687"},
{file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe"},
{file = "sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d"},
{file = "sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a"},
{file = "sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3"},
{file = "sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa"},
{file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9"},
{file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f"},
{file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738"},
{file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164"},
{file = "sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d"},
{file = "sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197"},
{file = "sqlalchemy-2.0.43-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a"},
{file = "sqlalchemy-2.0.43-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185"},
{file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f"},
{file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a"},
{file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547"},
{file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782"},
{file = "sqlalchemy-2.0.43-cp38-cp38-win32.whl", hash = "sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed"},
{file = "sqlalchemy-2.0.43-cp38-cp38-win_amd64.whl", hash = "sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e"},
{file = "sqlalchemy-2.0.43-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7"},
{file = "sqlalchemy-2.0.43-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf"},
{file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad"},
{file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34"},
{file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7"},
{file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b"},
{file = "sqlalchemy-2.0.43-cp39-cp39-win32.whl", hash = "sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414"},
{file = "sqlalchemy-2.0.43-cp39-cp39-win_amd64.whl", hash = "sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b"},
{file = "sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc"},
{file = "sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417"},
]
[package.dependencies]
greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
typing-extensions = ">=4.6.0"
[package.extras]
aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"]
aioodbc = ["aioodbc", "greenlet (>=1)"]
aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (>=1)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"]
mssql = ["pyodbc"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0)"]
mysql-connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=8)"]
oracle-oracledb = ["oracledb (>=1.0.1)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"]
postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
postgresql-psycopg = ["psycopg (>=3.0.7)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"]
[[package]]
name = "svix-ksuid"
version = "0.6.2"
@@ -1223,4 +1388,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.13"
content-hash = "200a3892f48467b2639abfae99b94b6de6b75fe09f9669ea115eb2b55a2f46ea"
content-hash = "fc17158ab90e70dbd94668e3346d6126384cb17cf28c3b3ec82e5ed067058380"

View File

@@ -15,6 +15,7 @@ numpy = "^2.3.2"
clickhouse-connect = "^0.8.18"
svix-ksuid = "^0.6.2"
requests = "^2.32.4"
sqlalchemy = "^2.0.43"
[tool.poetry.group.dev.dependencies]

View File

@@ -8,6 +8,22 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_register_with_invalid_password(signoz: types.SigNoz) -> None:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/register"),
json={
"name": "admin",
"orgId": "",
"orgName": "integration.test",
"email": "admin@integration.test",
"password": "password",
},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_register(signoz: types.SigNoz, get_jwt_token) -> None:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/version"), timeout=2
@@ -23,7 +39,7 @@ def test_register(signoz: types.SigNoz, get_jwt_token) -> None:
"orgId": "",
"orgName": "integration.test",
"email": "admin@integration.test",
"password": "password",
"password": "password123Z$",
},
timeout=2,
)
@@ -36,7 +52,7 @@ def test_register(signoz: types.SigNoz, get_jwt_token) -> None:
assert response.status_code == HTTPStatus.OK
assert response.json()["setupCompleted"] is True
admin_token = get_jwt_token("admin@integration.test", "password")
admin_token = get_jwt_token("admin@integration.test", "password123Z$")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
@@ -72,7 +88,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None:
json={"email": "editor@integration.test", "role": "EDITOR"},
timeout=2,
headers={
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password")}"
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password123Z$")}"
},
)
@@ -82,7 +98,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None:
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password")}"
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password123Z$")}"
},
)
@@ -100,7 +116,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password",
"password": "password123Z$",
"displayName": "editor",
"token": f"{found_invite['token']}",
},
@@ -121,7 +137,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None:
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {get_jwt_token("editor@integration.test", "password")}"
"Authorization": f"Bearer {get_jwt_token("editor@integration.test", "password123Z$")}"
},
)
@@ -132,7 +148,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None:
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password")}"
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password123Z$")}"
},
)
@@ -151,7 +167,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None:
def test_revoke_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None:
admin_token = get_jwt_token("admin@integration.test", "password")
admin_token = get_jwt_token("admin@integration.test", "password123Z$")
# Generate an invite token for the viewer user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
@@ -166,7 +182,7 @@ def test_revoke_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password")}"
"Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password123Z$")}"
},
)
@@ -192,7 +208,7 @@ def test_revoke_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password",
"password": "password123Z$",
"displayName": "viewer",
"token": f"{found_invite["token"]}",
},
@@ -203,7 +219,7 @@ def test_revoke_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None
def test_self_access(signoz: types.SigNoz, get_jwt_token) -> None:
admin_token = get_jwt_token("admin@integration.test", "password")
admin_token = get_jwt_token("admin@integration.test", "password123Z$")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),

View File

@@ -2,6 +2,7 @@ import http
import json
import requests
from sqlalchemy import sql
from wiremock.client import (
HttpMethods,
Mapping,
@@ -52,7 +53,7 @@ def test_apply_license(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None:
],
)
access_token = get_jwt_token("admin@integration.test", "password")
access_token = get_jwt_token("admin@integration.test", "password123Z$")
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v3/licenses"),
@@ -111,7 +112,7 @@ def test_refresh_license(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None
],
)
access_token = get_jwt_token("admin@integration.test", "password")
access_token = get_jwt_token("admin@integration.test", "password123Z$")
response = requests.put(
url=signoz.self.host_configs["8080"].get("/api/v3/licenses"),
@@ -121,12 +122,13 @@ def test_refresh_license(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None
assert response.status_code == http.HTTPStatus.NO_CONTENT
cursor = signoz.sqlstore.conn.cursor()
cursor.execute(
"SELECT data FROM license WHERE id='0196360e-90cd-7a74-8313-1aa815ce2a67'"
)
record = cursor.fetchone()[0]
assert json.loads(record)["valid_from"] == 1732146922
with signoz.sqlstore.conn.connect() as conn:
result = conn.execute(
sql.text("SELECT data FROM license WHERE id=:id"),
{"id": "0196360e-90cd-7a74-8313-1aa815ce2a67"},
)
record = result.fetchone()[0]
assert json.loads(record)["valid_from"] == 1732146922
response = requests.post(
url=signoz.zeus.host_configs["8080"].get("/__admin/requests/count"),
@@ -163,7 +165,7 @@ def test_license_checkout(signoz: SigNoz, make_http_mocks, get_jwt_token) -> Non
],
)
access_token = get_jwt_token("admin@integration.test", "password")
access_token = get_jwt_token("admin@integration.test", "password123Z$")
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v1/checkout"),
@@ -210,7 +212,7 @@ def test_license_portal(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None:
],
)
access_token = get_jwt_token("admin@integration.test", "password")
access_token = get_jwt_token("admin@integration.test", "password123Z$")
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v1/portal"),

View File

@@ -6,7 +6,7 @@ from fixtures import types
def test_api_key(signoz: types.SigNoz, get_jwt_token) -> None:
admin_token = get_jwt_token("admin@integration.test", "password")
admin_token = get_jwt_token("admin@integration.test", "password123Z$")
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/pats"),

View File

@@ -0,0 +1,235 @@
from http import HTTPStatus
import requests
from sqlalchemy import sql
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_change_password(signoz: types.SigNoz, get_jwt_token) -> None:
admin_token = get_jwt_token("admin@integration.test", "password123Z$")
# Create another admin user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "admin+password@integration.test", "role": "ADMIN"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
invite_response = response.json()["data"]
found_invite = next(
(
invite
for invite in invite_response
if invite["email"] == "admin+password@integration.test"
),
None,
)
# Accept the invite with a bad password which should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password",
"displayName": "admin password",
"token": f"{found_invite['token']}",
},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# Accept the invite with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password123Z$",
"displayName": "admin password",
"token": f"{found_invite['token']}",
},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
# Get the user id
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "admin+password@integration.test"
),
None,
)
# Try logging in with the password
token = get_jwt_token("admin+password@integration.test", "password123Z$")
assert token is not None
# Try changing the password with a bad old password which should fail
response = requests.post(
signoz.self.host_configs["8080"].get(
f"/api/v1/changePassword/{found_user['id']}"
),
json={
"userId": f"{found_user['id']}",
"oldPassword": "password",
"newPassword": "password123Z$",
},
timeout=2,
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# Try changing the password with a good old password
response = requests.post(
signoz.self.host_configs["8080"].get(
f"/api/v1/changePassword/{found_user['id']}"
),
json={
"userId": f"{found_user['id']}",
"oldPassword": "password123Z$",
"newPassword": "password123Znew$",
},
timeout=2,
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try logging in with the new password
token = get_jwt_token("admin+password@integration.test", "password123Znew$")
assert token is not None
def test_reset_password(signoz: types.SigNoz, get_jwt_token) -> None:
admin_token = get_jwt_token("admin@integration.test", "password123Z$")
# Get the user id for admin+password@integration.test
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "admin+password@integration.test"
),
None,
)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/getResetPasswordToken/{found_user['id']}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
token = response.json()["data"]["token"]
# Reset the password with a bad password which should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
# Reset the password with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$NEWNEW#!", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
token = get_jwt_token("admin+password@integration.test", "password123Z$NEWNEW#!")
assert token is not None
def test_reset_password_with_no_password(signoz: types.SigNoz, get_jwt_token) -> None:
admin_token = get_jwt_token("admin@integration.test", "password123Z$")
# Get the user id for admin+password@integration.test
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "admin+password@integration.test"
),
None,
)
with signoz.sqlstore.conn.connect() as conn:
result = conn.execute(
sql.text("DELETE FROM factor_password WHERE user_id = :user_id"),
{"user_id": found_user["id"]},
)
assert result.rowcount == 1
# Generate a new reset password token
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/getResetPasswordToken/{found_user['id']}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
token = response.json()["data"]["token"]
# Reset the password with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "FINALPASSword123!#[", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
token = get_jwt_token("admin+password@integration.test", "FINALPASSword123!#[")
assert token is not None