mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-04 17:30:34 +01:00
Compare commits
57 Commits
feat/cross
...
feat/cross
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001f14c017 | ||
|
|
aa847b71ad | ||
|
|
9ad6ab49f0 | ||
|
|
8f6b853bb9 | ||
|
|
4e4f7cc521 | ||
|
|
d794404f31 | ||
|
|
7d81f1e665 | ||
|
|
3b8033c7ec | ||
|
|
70e6b61660 | ||
|
|
2b4cd5d1fb | ||
|
|
73c279cac9 | ||
|
|
73ce96e3a6 | ||
|
|
8217e1e0cb | ||
|
|
e24da43559 | ||
|
|
6efbce3ea1 | ||
|
|
eb063f7ac0 | ||
|
|
b01f8ae170 | ||
|
|
10167f7cd1 | ||
|
|
a98b56d994 | ||
|
|
6eb2546398 | ||
|
|
47b447099d | ||
|
|
35a1875d45 | ||
|
|
d971224169 | ||
|
|
c72ef90209 | ||
|
|
6c41aa1420 | ||
|
|
d2a175db9c | ||
|
|
5a149a9a4f | ||
|
|
0319e1b816 | ||
|
|
215707304a | ||
|
|
cb22545031 | ||
|
|
9f40bd6a9f | ||
|
|
3a78b13e0c | ||
|
|
2323ce4aeb | ||
|
|
72272799ee | ||
|
|
73d635149f | ||
|
|
9bd55dfa6c | ||
|
|
822338ace8 | ||
|
|
37bb8e95a8 | ||
|
|
7eaab9cd21 | ||
|
|
7c97b8f880 | ||
|
|
6f1dd4d10a | ||
|
|
8a9f67b17c | ||
|
|
d16e26b5e4 | ||
|
|
36dd024f69 | ||
|
|
12f61bdccf | ||
|
|
46307ed4f4 | ||
|
|
8e20150e48 | ||
|
|
15f857bced | ||
|
|
e4b0388de5 | ||
|
|
bcd2ebed47 | ||
|
|
dae61cfa7a | ||
|
|
c6b6e84db6 | ||
|
|
bf02c6b500 | ||
|
|
abeadc7672 | ||
|
|
faadc60c74 | ||
|
|
360e8309c8 | ||
|
|
27580b62ba |
@@ -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
|
||||
|
||||
|
||||
@@ -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) (
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>),
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -61,4 +61,5 @@ export interface CustomMultiSelectProps
|
||||
maxTagTextLength?: number;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
showLabels?: boolean;
|
||||
enableRegexOption?: boolean;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.list-graph-container {
|
||||
.full-view-graph-container {
|
||||
height: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface GridCardGraphProps {
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
widgetsHavingDynamicVariables?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('DynamicVariable Component', () => {
|
||||
const DEFAULT_PROPS = {
|
||||
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue: undefined,
|
||||
errorAttributeKeyMessage: '',
|
||||
};
|
||||
|
||||
const mockFieldKeysResponse = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -27,8 +27,7 @@ export const getDashboardVariables = (
|
||||
value?.type === 'DYNAMIC' &&
|
||||
value?.allSelected &&
|
||||
value?.showALLOption &&
|
||||
value?.multiSelect &&
|
||||
!value?.haveCustomValuesSelected
|
||||
value?.multiSelect
|
||||
? '__all__'
|
||||
: value?.selectedValue;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface IDashboardContext {
|
||||
| null
|
||||
| undefined,
|
||||
allSelected: boolean,
|
||||
isDynamic?: boolean,
|
||||
) => void;
|
||||
variablesToGetUpdated: string[];
|
||||
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface IDashboardVariable {
|
||||
dynamicVariablesAttribute?: string;
|
||||
dynamicVariablesSource?: string;
|
||||
haveCustomValuesSelected?: boolean;
|
||||
dynamicVariablesWidgetIds?: string[];
|
||||
}
|
||||
export interface Dashboard {
|
||||
id: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
38
pkg/http/binding/binding.go
Normal file
38
pkg/http/binding/binding.go
Normal 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
41
pkg/http/binding/json.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
27
pkg/modules/user/option.go
Normal file
27
pkg/modules/user/option.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
210
pkg/types/factor_password.go
Normal file
210
pkg/types/factor_password.go
Normal 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
|
||||
}
|
||||
14
pkg/types/factor_password_test.go
Normal file
14
pkg/types/factor_password_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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="",
|
||||
|
||||
@@ -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:
|
||||
|
||||
171
tests/integration/poetry.lock
generated
171
tests/integration/poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
235
tests/integration/src/auth/d_password.py
Normal file
235
tests/integration/src/auth/d_password.py
Normal 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
|
||||
Reference in New Issue
Block a user