Compare commits

..

8 Commits

68 changed files with 3325 additions and 2500 deletions

View File

@@ -2437,6 +2437,17 @@ components:
url:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
value:
type: string
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -2801,15 +2812,9 @@ components:
type: string
nullable: true
type: object
mode:
$ref: '#/components/schemas/DashboardtypesLegendMode'
position:
$ref: '#/components/schemas/DashboardtypesLegendPosition'
type: object
DashboardtypesLegendMode:
enum:
- list
type: string
DashboardtypesLegendPosition:
enum:
- bottom
@@ -2860,25 +2865,15 @@ components:
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
sort:
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
nullable: true
type: string
required:
- name
- display
type: object
DashboardtypesListVariableSpecSort:
enum:
- none
- alphabetical-asc
- alphabetical-desc
- numerical-asc
- numerical-desc
- alphabetical-ci-asc
- alphabetical-ci-desc
type: string
DashboardtypesListableDashboardForUserV2:
properties:
dashboards:
@@ -3388,13 +3383,8 @@ components:
DashboardtypesSpanGaps:
properties:
fillLessThan:
description: The maximum gap size to connect when fillOnlyBelow is true.
Gaps larger than this duration are left disconnected.
type: string
fillOnlyBelow:
description: Controls whether lines connect across null values. When false
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
are connected.
type: boolean
type: object
DashboardtypesStorableDashboardData:
@@ -3442,20 +3432,6 @@ components:
- color
- columnName
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
minLength: 1
type: string
value:
type: string
required:
- name
type: object
DashboardtypesThresholdFormat:
enum:
- text
@@ -3475,6 +3451,7 @@ components:
required:
- value
- color
- label
type: object
DashboardtypesTimePreference:
enum:
@@ -3559,11 +3536,23 @@ components:
discriminator:
mapping:
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
type: object
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardTextVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
properties:
@@ -3577,18 +3566,6 @@ components:
- kind
- spec
type: object
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
required:
- kind
- spec
type: object
DashboardtypesVariablePlugin:
discriminator:
mapping:
@@ -7674,6 +7651,15 @@ components:
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:

View File

@@ -3154,6 +3154,37 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
hidden?: boolean;
/**
* @type string
*/
name?: string;
}
export interface DashboardTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: VariableDisplayDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -3185,9 +3216,6 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3207,7 +3235,6 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3219,7 +3246,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label?: string;
label: string;
/**
* @type string
*/
@@ -3884,12 +3911,10 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
@@ -4521,15 +4546,6 @@ export type DashboardtypesVariablePluginDTO =
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
export enum DashboardtypesListVariableSpecSortDTO {
none = 'none',
'alphabetical-asc' = 'alphabetical-asc',
'alphabetical-desc' = 'alphabetical-desc',
'numerical-asc' = 'numerical-asc',
'numerical-desc' = 'numerical-desc',
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
}
export interface DashboardtypesListVariableSpecDTO {
/**
* @type boolean
@@ -4548,14 +4564,16 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display?: DashboardtypesDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
name?: string;
plugin?: DashboardtypesVariablePluginDTO;
sort?: DashboardtypesListVariableSpecSortDTO;
/**
* @type string,null
*/
sort?: string | null;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
@@ -4567,38 +4585,21 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
spec: DashboardtypesTextVariableSpecDTO;
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**

View File

@@ -1,29 +1,67 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { Button, Skeleton } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import {
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { cloneDeep, isArray, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import useActiveQueryIndex from './useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';
import useCheckboxFilterValues from './useCheckboxFilterValues';
import { isKeyMatch } from './utils';
import './Checkbox.styles.scss';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
interface ICheckboxProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
@@ -34,39 +72,194 @@ interface ICheckboxProps {
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
const activeQueryIndex = useActiveQueryIndex(source);
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
const {
isOpen,
lastUsedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType,
} = useQueryBuilder();
// Determine if we're in ListView mode
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// Check if this filter has active filters in the query
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
// Derive isOpen from filter state + user action
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
} = useCheckboxDisclosure({ filter, activeQueryIndex });
filter.defaultOpen,
]);
const { attributeValues, isLoading } = useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
});
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
});
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
// derive the state of each filter key here in the renderer itself and keep it in sync with current query
// also we need to keep a note of last focussed query.
// eslint-disable-next-line sonarjs/cognitive-complexity
const currentFilterState = useMemo(() => {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}, [
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
]);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// variable to check if the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
// Sort checked items to the top, then unchecked items
const currentAttributeKeys = useMemo(() => {
const checkedValues = attributeValues.filter(
@@ -84,6 +277,293 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
[currentAttributeKeys, currentFilterState],
);
const handleClearFilterAttribute = (): void => {
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(preparedQuery);
} else {
redirectWithQueryBuilderData(preparedQuery);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
const finalQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(finalQuery);
} else {
redirectWithQueryBuilderData(finalQuery);
}
};
const isEmptyStateWithDocsEnabled =
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
!searchText &&
@@ -91,19 +571,48 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
return (
<div className="checkbox-filter">
<CheckboxFilterHeader
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
/>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
<section
className="filter-header-checkbox"
onClick={(): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(10);
} else {
setUserToggleState(true);
}
}}
>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{filter.title}</Typography.Text>
</section>
)}
{isOpen && !isLoading && (
<section className="right-action">
{isOpen && !!attributeValues.length && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleClearFilterAttribute();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
{isOpen &&
(isLoading || isLoadingKeyValueSuggestions) &&
!attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
<>
{!isEmptyStateWithDocsEnabled && (
<section className="search">
@@ -125,24 +634,48 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
data-testid="filter-separator"
/>
)}
<CheckboxValueRow
value={value}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
title={filter.title}
onlyButtonLabel={
isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'
}
customRendererForValue={filter.customRendererForValue}
onCheckboxChange={(checked): void => onChange(value, checked, false)}
onOnlyOrAllClick={(): void =>
onChange(value, currentFilterState[value], true)
}
/>
<div className="value">
<Checkbox
onChange={(checked): void =>
onChange(value, checked === true, false)
}
value={currentFilterState[value]}
disabled={isFilterDisabled}
className="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
<div className={`${filter.title} label-${value}`} />
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
</Fragment>
))}
</section>
@@ -155,7 +688,10 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text className="show-more-text" onClick={onShowMore}>
<Typography.Text
className="show-more-text"
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
>
Show More...
</Typography.Text>
</section>

View File

@@ -1,47 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onClear: () => void;
}
function CheckboxFilterHeader({
title,
isOpen,
showClearAll,
onToggleOpen,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section className="filter-header-checkbox" onClick={onToggleOpen}>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && showClearAll && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
);
}
export default CheckboxFilterHeader;

View File

@@ -1,68 +0,0 @@
import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
interface CheckboxValueRowProps {
value: string;
checked: boolean;
disabled: boolean;
title: string;
onlyButtonLabel: string;
customRendererForValue?: (value: string) => JSX.Element;
onCheckboxChange: (checked: boolean) => void;
onOnlyOrAllClick: () => void;
}
function CheckboxValueRow({
value,
checked,
disabled,
title,
onlyButtonLabel,
customRendererForValue,
onCheckboxChange,
onOnlyOrAllClick,
}: CheckboxValueRowProps): JSX.Element {
return (
<div className="value">
<Checkbox
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
value={checked}
disabled={disabled}
className="check-box"
/>
<div
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
onClick={(): void => {
if (disabled) {
return;
}
onOnlyOrAllClick();
}}
>
<div className={`${title} label-${value}`} />
{customRendererForValue ? (
customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
);
}
CheckboxValueRow.defaultProps = {
customRendererForValue: undefined,
};
export default CheckboxValueRow;

View File

@@ -1,417 +0,0 @@
/* eslint-disable sonarjs/no-identical-functions */
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { isKeyMatch } from './utils';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
export function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
/**
* Derives the checked/unchecked state for each attribute value by reading the
* active filter clause for this attribute key out of the query.
*
* - No matching clause -> every value is checked (all selected).
* - IN / `=` clause -> only the listed values are checked.
* - NOT IN / `!=` clause -> every value is checked except the excluded ones.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function deriveCheckboxState({
attributeValues,
filterItems,
filterKey,
}: {
attributeValues: string[];
filterItems: TagFilterItem[] | undefined;
filterKey: string;
}): Record<string, boolean> {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = filterItems?.find((item) =>
isKeyMatch(item.key?.key, filterKey),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}
/**
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
*/
export function clearFilterFromQuery({
currentQuery,
filter,
activeQueryIndex,
}: {
currentQuery: Query;
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}: {
currentQuery: Query;
activeQueryIndex: number;
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
value: string;
checked: boolean;
isOnlyOrAllClicked: boolean;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
const currentFilterState = deriveCheckboxState({
attributeValues,
filterItems: activeItems,
filterKey: filter.attributeKey.key,
});
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
filter.attributeKey.key,
]);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
}

View File

@@ -1,27 +0,0 @@
import { useMemo } from 'react';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
/**
* Resolves which query-builder query index the checkbox filter reads from and
* writes to.
*
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
* mode track the last focused query.
*/
function useActiveQueryIndex(source: QuickFiltersSource): number {
const { lastUsedQuery, panelType } = useQueryBuilder();
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -1,90 +0,0 @@
import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isKeyMatch } from './utils';
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
interface UseCheckboxDisclosureProps {
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}
interface UseCheckboxDisclosureReturn {
isOpen: boolean;
isSomeFilterPresentForCurrentAttribute: boolean;
visibleItemsCount: number;
onToggleOpen: () => void;
onShowMore: () => void;
}
/**
* Owns the open/collapsed state of a checkbox filter section and how many
* values are visible.
*
* Auto-opens when the query already has a clause for this attribute, otherwise
* falls back to `filter.defaultOpen`. An explicit user toggle always wins.
* Collapsing resets the visible count.
*/
function useCheckboxDisclosure({
filter,
activeQueryIndex,
}: UseCheckboxDisclosureProps): UseCheckboxDisclosureReturn {
const { currentQuery } = useQueryBuilder();
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(
DEFAULT_VISIBLE_ITEMS_COUNT,
);
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
const onToggleOpen = (): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(DEFAULT_VISIBLE_ITEMS_COUNT);
} else {
setUserToggleState(true);
}
};
const onShowMore = (): void => {
setVisibleItemsCount((prev) => prev + DEFAULT_VISIBLE_ITEMS_COUNT);
};
return {
isOpen,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
};
}
export default useCheckboxDisclosure;

View File

@@ -1,78 +0,0 @@
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
}
interface UseCheckboxFilterActionsReturn {
onChange: (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
) => void;
onClear: () => void;
}
/**
* Wires the pure checkbox query algebra to query-builder dispatch: the
* caller-provided `onFilterChange` when present, otherwise a URL redirect.
*/
function useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const dispatch = (query: Query): void => {
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(query);
} else {
redirectWithQueryBuilderData(query);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
): void => {
dispatch(
applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}),
);
};
const onClear = (): void => {
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
};
return { onChange, onClear };
}
export default useCheckboxFilterActions;

View File

@@ -1,71 +0,0 @@
import { useMemo } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { deriveCheckboxState } from './checkboxFilterQuery';
import { isKeyMatch } from './utils';
interface UseCheckboxFilterStateProps {
filter: IQuickFiltersConfig;
attributeValues: string[];
activeQueryIndex: number;
}
interface UseCheckboxFilterStateReturn {
currentFilterState: Record<string, boolean>;
isFilterDisabled: boolean;
isMultipleValuesTrueForTheKey: boolean;
}
/**
* Reads the active query and derives the per-value checked state for this
* attribute, whether the filter is disabled (same key used more than once in
* the filter bar), and whether more than one value is currently selected.
*/
function useCheckboxFilterState({
filter,
attributeValues,
activeQueryIndex,
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
const { currentQuery } = useQueryBuilder();
// derive the state of each filter key here and keep it in sync with current query
const currentFilterState = useMemo(
() =>
deriveCheckboxState({
attributeValues,
filterItems:
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
filterKey: filter.attributeKey.key,
}),
[
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
],
);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// whether the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
return {
currentFilterState,
isFilterDisabled,
isMultipleValuesTrueForTheKey,
};
}
export default useCheckboxFilterState;

View File

@@ -1,99 +0,0 @@
import { useMemo } from 'react';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
interface UseCheckboxFilterValuesProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
searchText: string;
isOpen: boolean;
}
interface UseCheckboxFilterValuesReturn {
attributeValues: string[];
isLoading: boolean;
}
function useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
return {
attributeValues,
isLoading: isLoading || isLoadingKeyValueSuggestions,
};
}
export default useCheckboxFilterValues;

View File

@@ -20,7 +20,7 @@ import APIError from 'types/api/error';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
import VariablesBar from '../VariablesBar/VariablesBar';
import styles from './DashboardPageToolbar.module.scss';
@@ -53,10 +53,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
// drives the public-access badge.
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -122,7 +118,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
image={image}
tags={tags}
description={description}
isPublicDashboard={isPublicDashboard}
isPublicDashboard={false}
isDashboardLocked={isDashboardLocked}
isEditing={isEditing}
draft={draft}
@@ -142,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onOpenRename={startEdit}
/>
</div>
<VariablesBar dashboard={dashboard} />
</section>
);
}

View File

@@ -1,24 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectSimple } from '@signozhq/ui/select';
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import TextToolTip from 'components/TextToolTip';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { isRetryableError } from 'utils/errorUtils';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import {
DYNAMIC_SIGNAL_LABEL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
signalForApi,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: TelemetrySignal;
signal: DynamicSignalOption;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: TelemetrySignal;
dynamicSignal?: DynamicSignalOption;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
attributeError?: string;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
@@ -27,18 +37,24 @@ function DynamicVariableFields({
signal,
onChange,
onPreview,
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const apiSignal = signalForApi(signal);
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
const {
data: keyData,
isLoading,
error,
refetch,
} = useGetFieldKeys({
signal: apiSignal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
// CustomSelect filters the supplied options locally as the user types.
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
@@ -49,7 +65,7 @@ function DynamicVariableFields({
);
const { data: valueData } = useGetFieldValues({
signal,
signal: apiSignal,
name: attribute,
enabled: !!attribute,
});
@@ -62,40 +78,60 @@ function DynamicVariableFields({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
const errorMessage = error ? (error as Error).message || null : null;
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
<Typography.Text className={styles.label}>Source</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<SelectSimple
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={signal}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as TelemetrySignal })
onChange({ dynamicSignal: value as DynamicSignalOption })
}
testId="variable-signal-select"
data-testid="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<Select
<CustomSelect
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
filterOption={isComplete}
options={options}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
noDataMessage="No fields found"
errorMessage={errorMessage}
onRetry={(): void => {
void refetch();
}}
showRetryButton={error ? isRetryableError(error) : true}
data-testid="variable-field-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>
{attributeError}
</Typography.Text>
) : null}
</>
);
}

View File

@@ -0,0 +1,139 @@
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option sort picker
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import {
VARIABLE_SORT_LABEL,
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface ListVariableFieldsProps {
model: VariableFormModel;
onChange: (patch: Partial<VariableFormModel>) => void;
previewValues: (string | number)[];
previewError: string | null;
defaultValue: string;
onDefaultValueChange: (value: string) => void;
/** Whether the "ALL values" toggle applies to this type (QUERY / CUSTOM). */
showAllOptionField: boolean;
}
/**
* Rows shared by the list-style variables (Query / Custom / Dynamic): the value
* preview, sort, multi-select / ALL toggles and the default-value picker.
*/
function ListVariableFields({
model,
onChange,
previewValues,
previewError,
defaultValue,
onDefaultValueChange,
showAllOptionField,
}: ListVariableFieldsProps): JSX.Element {
return (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={model.sort}
options={VARIABLE_SORTS.map((sort) => ({
label: VARIABLE_SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => onChange({ sort: value as VariableSort })}
data-testid="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void =>
onChange({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
})
}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => onChange({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Default Value</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<CustomSelect
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => onDefaultValueChange((value as string) ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
);
}
export default ListVariableFields;

View File

@@ -2,16 +2,15 @@ import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
import Editor from 'components/Editor';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import { sortDirectionOf } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSortDTO;
/** Sibling variable selections, so dependent `$vars` in the query resolve. */
variables: PayloadVariables;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
@@ -20,7 +19,7 @@ interface QueryVariableFieldsProps {
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
sort,
variables,
onChange,
onPreview,
onError,
@@ -31,23 +30,21 @@ function QueryVariableFields({
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
const res = await dashboardVariablesQuery({ query: queryValue, variables });
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sortDirectionOf(sort)) as (
| string
| number
)[],
);
onPreview(res.payload.variableValues ?? []);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
onError((err as Error).message || 'Failed to run query');
// `dashboardVariablesQuery` throws `{ message, details: { error } }`.
const detail = (err as { details?: { error?: string } }).details?.error;
const message =
detail && detail.includes('Syntax error:')
? 'Please make sure query is valid and dependent variables are selected'
: detail || (err as Error).message || 'Failed to run query';
onError(message);
onPreview([]);
} finally {
setIsRunning(false);

View File

@@ -5,22 +5,8 @@
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
border: 1px solid var(--l2-border);
}
.content {
@@ -42,6 +28,12 @@
width: 200px;
}
.sourceLabel {
display: flex;
align-items: center;
gap: 6px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
@@ -59,7 +51,7 @@
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l3-background);
}
@@ -78,48 +70,89 @@
color: var(--bg-amber-500);
}
/* Variable type segmented group */
/* Variable type — Tabs root composing the picker row + per-type body panels. */
.typeSection {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 40px;
margin-bottom: 0;
}
/* Picker row (label left, tabs right); the bottom divider separates type from
config. Single line — the tab row scrolls (never wraps) when narrow. */
.typePicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--l2-border);
@media (max-width: 1440px) {
flex-wrap: wrap;
}
}
/* Active tab panel — reset the Tabs default padding; body rows handle spacing. */
.typePanel {
padding: 0 !important;
}
.typeContent {
display: flex;
flex-direction: column;
gap: 20px;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
width: auto;
white-space: nowrap;
}
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
/* Horizontal scroll so the tab row never wraps to a second line. The scrollbar
is hidden — the row stays a single crisp line and scrolls only when narrow. */
.typeTabsScroll {
justify-self: flex-end;
--tab-list-wrapper-secondary-padding-left: 0;
}
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
padding, segments divided by 1px borders, active segment filled + bold. */
.typeTabs {
display: inline-flex;
flex-wrap: nowrap;
width: max-content;
gap: 0;
padding: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
background: transparent;
}
.typeBtn {
--button-height: 32px;
display: flex;
.typeTab {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 114px;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
white-space: nowrap;
border-radius: 0;
color: var(--l2-foreground);
& + & {
border-left: 1px solid var(--l1-border);
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
}
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
&[data-state='active'] {
color: var(--l1-foreground);
font-weight: 500;
// override the Tabs component's default (transparent) active background.
background: var(--l3-background) !important;
}
}
.betaTag {
@@ -138,7 +171,7 @@
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 2px;
}
@@ -154,7 +187,7 @@
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
@@ -208,7 +241,7 @@
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l1-border);
border: 1px solid var(--l2-border);
border-radius: 3px;
}
@@ -271,13 +304,9 @@
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: grid;
grid-template-columns: max-content 1fr;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
margin-bottom: 0;
@@ -297,14 +326,21 @@
letter-spacing: -0.06px;
}
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect {
width: 100%;
width: 240px;
flex-shrink: 0;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
/* Footer */
.footer {
.actionButtons {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -1,361 +1,199 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, Check, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { TabsContent, TabsRoot } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput } from 'antd';
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
import {
sortDirectionOf,
VARIABLE_SORTS,
type VariableFormModel,
type VariableType,
} from '../variableModel';
import type { VariableType } from '../variableFormModel';
import DynamicVariableFields from './DynamicVariableFields';
import ListVariableFields from './ListVariableFields';
import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import { useVariableForm } from './useVariableForm';
import VariableTypeTabs from './VariableTypeTabs';
import styles from './VariableForm.module.scss';
const SORT_LABEL: Record<VariableSortDTO, string> = {
[VariableSortDTO.none]: 'Disabled',
[VariableSortDTO['alphabetical-asc']]: 'Alphabetical (asc)',
[VariableSortDTO['alphabetical-desc']]: 'Alphabetical (desc)',
[VariableSortDTO['numerical-asc']]: 'Numerical (asc)',
[VariableSortDTO['numerical-desc']]: 'Numerical (desc)',
[VariableSortDTO['alphabetical-ci-asc']]:
'Alphabetical, case-insensitive (asc)',
[VariableSortDTO['alphabetical-ci-desc']]:
'Alphabetical, case-insensitive (desc)',
};
function getNameError(name: string, existingNames: string[]): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
interface VariableFormProps {
initial: VariableFormModel;
/** Names of the other variables, for uniqueness validation. */
existingNames: string[];
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}
import BackToAllVariables from '../components/BackToAllVariables/BackToAllVariables';
import { VariableFormProps } from '../types';
import VariableInfoForm from '../components/VariableInfoForm/VariableInfoForm';
/**
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
* and searchable selects). Master→detail: renders in place of the list.
* and searchable selects). Master→detail: renders in place of the list. Form
* state/handlers live in {@link useVariableForm}; the shared list-type rows in
* {@link ListVariableFields}.
*/
function VariableForm({
initial,
existingNames,
siblings,
isNew,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
const [model, setModel] = useState<VariableFormModel>(initial);
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [defaultValue, setDefaultValue] = useState<string>(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
const {
model,
set,
onNameChange,
selectType,
onCustomChange,
onDynamicChange,
setRawPreview,
previewValues,
previewError,
setPreviewError,
defaultValue,
setDefaultValue,
visibleNameError,
nameError,
attributeError,
cycleError,
isListType,
showAllOptionField,
payloadVariables,
handleSave,
} = useVariableForm({ initial, siblings, isNew, onSave });
useEffect(() => {
setModel(initial);
setPreviewValues([]);
setPreviewError(null);
setDefaultValue(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const selectType = (type: VariableType): void => {
set({ type });
setPreviewValues([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), sortDirectionOf(model.sort)) as (
| string
| number
)[],
);
};
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const handleSave = (): void => {
onSave({
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
});
};
// Shared list rows (preview/sort/multi/default) for the list-type variables;
// rendered as a sibling inside each list-type panel. Only the active panel
// mounts (Tabs unmounts the rest), so reusing one element is safe.
const listFields = isListType ? (
<ListVariableFields
model={model}
onChange={set}
previewValues={previewValues}
previewError={previewError}
defaultValue={defaultValue}
onDefaultValueChange={setDefaultValue}
showAllOptionField={showAllOptionField}
/>
) : null;
return (
<>
<div className={styles.container}>
<div className={styles.allVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.allVariablesBtn}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
>
All variables
</Button>
</div>
<div className={styles.container}>
<BackToAllVariables onClose={onClose} />
<div className={styles.content}>
{/* Name */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Name</Typography.Text>
<Input
className={styles.input}
value={model.name}
placeholder="Unique name of the variable"
onChange={(e): void => set({ name: e.target.value })}
testId="variable-name-input"
/>
{nameError ? (
<Typography.Text className={styles.errorText}>
{nameError}
</Typography.Text>
) : null}
</div>
<div className={styles.content}>
<VariableInfoForm
title={model.name}
description={model.description}
onTitleChange={onNameChange}
onDescriptionChange={(value): void => set({ description: value })}
visibleNameError={visibleNameError}
/>
{/* Description */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Description</Typography.Text>
<AntdInput.TextArea
className={styles.textarea}
value={model.description}
placeholder="Enter a description for the variable"
rows={3}
onChange={(e): void => set({ description: e.target.value })}
data-testid="variable-description-input"
/>
</div>
<TabsRoot
className={styles.typeSection}
value={model.type}
onValueChange={(next): void => selectType(next as VariableType)}
>
<VariableTypeTabs />
{/* Variable Type */}
<VariableTypeSelector value={model.type} onChange={selectType} />
{/* Type-specific body */}
{model.type === 'DYNAMIC' ? (
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={(patch): void => set(patch)}
onPreview={setPreviewValues}
/>
) : null}
{model.type === 'QUERY' ? (
<QueryVariableFields
queryValue={model.queryValue}
sort={model.sort}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setPreviewValues}
onError={setPreviewError}
/>
) : null}
{model.type === 'CUSTOM' ? (
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
<TabsContent value="DYNAMIC" className={styles.typePanel}>
<div className={styles.typeContent}>
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={onDynamicChange}
onPreview={setRawPreview}
attributeError={attributeError}
/>
{listFields}
</div>
) : null}
</TabsContent>
{model.type === 'TEXT' ? (
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
<TabsContent value="QUERY" className={styles.typePanel}>
<div className={styles.typeContent}>
<QueryVariableFields
queryValue={model.queryValue}
variables={payloadVariables}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setRawPreview}
onError={setPreviewError}
/>
{listFields}
</div>
) : null}
</TabsContent>
{/* Shared rows for list-type variables */}
{isListType ? (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={model.sort}
items={VARIABLE_SORTS.map((sort) => ({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSortDTO })}
testId="variable-sort-select"
<TabsContent value="CUSTOM" className={styles.typePanel}>
<div className={styles.typeContent}>
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
/>
</div>
{listFields}
</div>
</TabsContent>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void => {
set({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
});
}}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => set({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<TabsContent value="TEXT" className={styles.typePanel}>
<div className={styles.typeContent}>
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => setDefaultValue(value ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
/>
</div>
</>
) : null}
</div>
</TabsContent>
</TabsRoot>
{cycleError ? (
<Typography.Text className={styles.errorText}>
{cycleError}
</Typography.Text>
) : null}
<div className={styles.actionButtons}>
<Button
variant="outlined"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError || !!attributeError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</div>
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</>
</div>
);
}

View File

@@ -1,99 +0,0 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import type { VariableType } from '../variableModel';
import styles from './VariableForm.module.scss';
interface VariableTypeSelectorProps {
value: VariableType;
onChange: (type: VariableType) => void;
}
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
function VariableTypeSelector({
value,
onChange,
}: VariableTypeSelectorProps): JSX.Element {
return (
<div className={cx(styles.row, styles.typeSection)}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeBtnGroup}>
<Button
variant="ghost"
color="secondary"
prefix={<Pyramid size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'DYNAMIC',
})}
onClick={(): void => onChange('DYNAMIC')}
testId="variable-type-dynamic"
>
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<ClipboardType size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'TEXT',
})}
onClick={(): void => onChange('TEXT')}
testId="variable-type-textbox"
>
Textbox
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutList size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'CUSTOM',
})}
onClick={(): void => onChange('CUSTOM')}
testId="variable-type-custom"
>
Custom
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<DatabaseZap size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'QUERY',
})}
onClick={(): void => onChange('QUERY')}
testId="variable-type-query"
>
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
</Button>
</div>
</div>
);
}
export default VariableTypeSelector;

View File

@@ -0,0 +1,93 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { TabsList, TabsTrigger } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import TextToolTip from 'components/TextToolTip';
import styles from './VariableForm.module.scss';
/**
* Presentational trigger row for the variable-type tabs (label + segmented
* triggers). Must render inside a `TabsRoot`, which owns the active state and
* change handling; the matching `TabsContent` panels are siblings in the root.
*/
function VariableTypeTabs(): JSX.Element {
return (
<div className={styles.typePicker}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeTabsScroll}>
<TabsList variant="secondary" className={styles.typeTabs}>
<TabsTrigger
value="DYNAMIC"
className={styles.typeTab}
testId="variable-type-dynamic"
>
<Pyramid size={14} />
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</TabsTrigger>
<TabsTrigger
value="TEXT"
className={styles.typeTab}
testId="variable-type-textbox"
>
<ClipboardType size={14} />
Textbox
</TabsTrigger>
<TabsTrigger
value="CUSTOM"
className={styles.typeTab}
testId="variable-type-custom"
>
<LayoutList size={14} />
Custom
</TabsTrigger>
<TabsTrigger
value="QUERY"
className={styles.typeTab}
testId="variable-type-query"
>
<DatabaseZap size={14} />
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
<span
className={styles.betaTag}
onClick={(e): void => e.stopPropagation()}
role="presentation"
>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</span>
</TabsTrigger>
</TabsList>
</div>
</div>
);
}
export default VariableTypeTabs;

View File

@@ -0,0 +1,191 @@
import { useEffect, useMemo, useState } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { detectVariableCycle } from '../variableDependencies';
import {
sortValuesByOrder,
type VariableFormModel,
type VariableType,
} from '../variableFormModel';
import { getAttributeError, getNameError } from './variableValidation';
// Stable reference so the zustand selector never returns a fresh object (which
// would make useSyncExternalStore loop) when this dashboard has no selections.
const EMPTY_SELECTIONS: VariableSelectionMap = {};
interface UseVariableFormArgs {
initial: VariableFormModel;
siblings: VariableFormModel[];
isNew: boolean;
onSave: (model: VariableFormModel) => void;
}
export interface UseVariableForm {
model: VariableFormModel;
set: (patch: Partial<VariableFormModel>) => void;
onNameChange: (value: string) => void;
selectType: (type: VariableType) => void;
onCustomChange: (value: string) => void;
onDynamicChange: (patch: Partial<VariableFormModel>) => void;
setRawPreview: (values: (string | number)[]) => void;
previewValues: (string | number)[];
previewError: string | null;
setPreviewError: (message: string | null) => void;
defaultValue: string;
setDefaultValue: (value: string) => void;
visibleNameError: string | null;
nameError: string | null;
attributeError: string | undefined;
cycleError: string | null;
isListType: boolean;
showAllOptionField: boolean;
payloadVariables: PayloadVariables;
handleSave: () => void;
}
const readDefaultValue = (model: VariableFormModel): string =>
((model.defaultValue as { value?: string })?.value ?? '') as string;
/** Form state, derivations and handlers for the variable editor. */
export function useVariableForm({
initial,
siblings,
isNew,
onSave,
}: UseVariableFormArgs): UseVariableForm {
const [model, setModel] = useState<VariableFormModel>(initial);
// Raw, unsorted preview; `previewValues` applies the chosen sort so a shown
// preview re-sorts when Sort changes.
const [rawPreview, setRawPreview] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [cycleError, setCycleError] = useState<string | null>(null);
// In add mode, mirror the chosen attribute into the name until the user types.
const [nameTouched, setNameTouched] = useState(false);
const [defaultValue, setDefaultValue] = useState<string>(
readDefaultValue(initial),
);
useEffect(() => {
setModel(initial);
setRawPreview([]);
setPreviewError(null);
setCycleError(null);
setNameTouched(false);
setDefaultValue(readDefaultValue(initial));
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const previewValues = useMemo(
() => sortValuesByOrder(rawPreview, model.sort) as (string | number)[],
[rawPreview, model.sort],
);
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
const existingDynamicAttributes = useMemo(
() =>
siblings
.filter((v) => v.type === 'DYNAMIC' && v.dynamicAttribute)
.map((v) => v.dynamicAttribute),
[siblings],
);
// Sibling selections feed the Query "Test Run" so dependent `$vars` resolve.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const selections = useDashboardStore(
(s) => s.variableValues[dashboardId ?? ''] ?? EMPTY_SELECTIONS,
);
const payloadVariables = useMemo<PayloadVariables>(() => {
const out: PayloadVariables = {};
siblings.forEach((v) => {
if (v.name) {
out[v.name] = selections[v.name]?.value ?? null;
}
});
return out;
}, [siblings, selections]);
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames, initial.name);
// Surface the message only once the field is dirty; Save stays disabled regardless.
const visibleNameError = nameTouched ? nameError : null;
const attributeError = getAttributeError(model, existingDynamicAttributes);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const onNameChange = (value: string): void => {
setNameTouched(true);
set({ name: value });
};
const selectType = (type: VariableType): void => {
set({ type });
setRawPreview([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setRawPreview(commaValuesParser(value));
};
// In add mode, mirror the selected attribute into the name until the user
// edits the name themselves (matches the V1 dynamic-variable behaviour).
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
if (isNew && !nameTouched && patch.dynamicAttribute) {
set({ ...patch, name: patch.dynamicAttribute });
} else {
set(patch);
}
};
const handleSave = (): void => {
const next: VariableFormModel = {
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
};
const cycle = detectVariableCycle([...siblings, next]);
if (cycle) {
setCycleError(
`Cannot save: circular dependency detected between variables: ${cycle.join(
' → ',
)}`,
);
return;
}
setCycleError(null);
onSave(next);
};
return {
model,
set,
onNameChange,
selectType,
onCustomChange,
onDynamicChange,
setRawPreview,
previewValues,
previewError,
setPreviewError,
defaultValue,
setDefaultValue,
visibleNameError,
nameError,
attributeError,
cycleError,
isListType,
showAllOptionField,
payloadVariables,
handleSave,
};
}

View File

@@ -0,0 +1,37 @@
import type { VariableFormModel } from '../variableFormModel';
/**
* Name validation, mirroring V1: empty / whitespace are rejected, and the name
* set includes self, but keeping your own (original) name is always allowed.
*/
export function getNameError(
name: string,
existingNames: string[],
originalName: string,
): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (name !== originalName && existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
/** Rejects a dynamic variable reusing an attribute already bound elsewhere. */
export function getAttributeError(
model: VariableFormModel,
existingDynamicAttributes: string[],
): string | undefined {
if (
model.type === 'DYNAMIC' &&
model.dynamicAttribute &&
existingDynamicAttributes.includes(model.dynamicAttribute)
) {
return 'A variable with this attribute key already exists';
}
return undefined;
}

View File

@@ -0,0 +1,140 @@
import type { CSSProperties } from 'react';
import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariableRowProps {
variable: VariableFormModel;
index: number;
canEdit: boolean;
/** True when this row's delete is awaiting inline confirmation. */
isConfirmingDelete: boolean;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
}
/** A single draggable variable row (drag handle + meta + inline actions). */
function VariableRow({
variable,
index,
canEdit,
isConfirmingDelete,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: VariableRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: variable.name });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 1, opacity: 0.8 } : {}),
};
return (
<div
ref={setNodeRef}
style={style}
className={styles.row}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
{canEdit ? (
<span
ref={setActivatorNodeRef}
className={styles.dragHandle}
aria-label="Reorder variable"
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</span>
) : null}
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && isConfirmingDelete ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && !isConfirmingDelete ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
);
}
export default VariableRow;

View File

@@ -2,13 +2,11 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
justify-content: flex-end;
gap: 16px;
}
@@ -30,14 +28,6 @@
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
@@ -62,6 +52,15 @@
min-width: 0;
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);

View File

@@ -1,24 +1,20 @@
import type { DragEndEvent } from '@dnd-kit/core';
import {
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import type { VariableFormModel } from './variableModel';
import VariableRow from './VariableRow';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
@@ -41,98 +37,48 @@ function VariablesList({
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
);
const handleDragEnd = ({ active, over }: DragEndEvent): void => {
if (!over || active.id === over.id) {
return;
}
const from = variables.findIndex((v) => v.name === active.id);
const to = variables.findIndex((v) => v.name === over.id);
if (from !== -1 && to !== -1) {
onMove(from, to);
}
};
return (
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
>
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
/>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -0,0 +1,26 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
const AddVariableButton = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: (state: { type: 'new' }) => void;
}): JSX.Element => {
return (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
size="md"
onClick={(): void => setIsEditing({ type: 'new' })}
testId="add-variable"
disabled={!isEditable}
>
Add variable
</Button>
);
};
export default AddVariableButton;

View File

@@ -0,0 +1,11 @@
.backToAllVariables {
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--l3-border);
}
.backToAllVariablesButton {
--button-font-size: 14px;
--button-padding: var(--spacing-5) var(--spacing-3);
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,28 @@
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './BackToAllVariables.module.scss';
import { VariableFormProps } from '../../types';
const BackToAllVariables = ({
onClose,
}: {
onClose: VariableFormProps['onClose'];
}): JSX.Element => {
return (
<div className={styles.backToAllVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.backToAllVariablesButton}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
size="md"
>
All variables
</Button>
</div>
);
};
export default BackToAllVariables;

View File

@@ -0,0 +1,25 @@
.noVariablesCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.noVariablesCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.noVariablesTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.noVariablesInfo {
color: var(--l3-foreground);
font-size: 13px;
line-height: 18px;
}

View File

@@ -0,0 +1,28 @@
import { Typography } from '@signozhq/ui/typography';
import AddVariableButton from '../AddVariableButton';
import { EditingState } from '../../types';
import styles from './NoVariables.module.scss';
const NoVariablesCard = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
}): JSX.Element => {
return (
<div className={styles.noVariablesCard}>
<div className={styles.noVariablesCopy}>
<Typography.Text className={styles.noVariablesTitle}>
No variables yet
</Typography.Text>
<Typography.Text className={styles.noVariablesInfo}>
Create a variable to parameterize your panel queries.
</Typography.Text>
</div>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
);
};
export default NoVariablesCard;

View File

@@ -0,0 +1,25 @@
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
}
.variableNameInput {
border-radius: 2px;
border: 1px solid var(--l2-border);
&::placeholder {
color: var(--l3-foreground);
}
}
.descriptionTextArea {
border-radius: 2px;
border: 1px solid var(--l2-border);
}

View File

@@ -0,0 +1,60 @@
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import styles from './VariableInfoForm.module.scss';
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
interface VariableInfoFormProps {
title: string;
description: string;
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
visibleNameError: string | null;
}
function VariableInfoForm({
title,
description,
onTitleChange,
onDescriptionChange,
visibleNameError,
}: VariableInfoFormProps): JSX.Element {
return (
<>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Name</Typography>
<Input
testId="variable-name"
className={styles.variableNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
placeholder="Unique name of the variable"
/>
{visibleNameError ? (
<Typography.Text className={variableFormStyles.errorText}>
<sup>*</sup>&nbsp;
{visibleNameError}
</Typography.Text>
) : null}
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Description</Typography>
<AntdInput.TextArea
className={styles.descriptionTextArea}
value={description}
placeholder="Enter a description for the variable"
data-testid="dashboard-desc"
rows={3}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
</>
);
}
export default VariableInfoForm;

View File

@@ -1,75 +1,75 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import settingsStyles from '../DashboardSettings.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableModel';
} from './variableFormModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
import { EditingState } from './types';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/** `null` index = adding a new variable; a number = editing that row. */
type EditingState = { index: number | null } | null;
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { save, isSaving } = useSaveVariables();
const initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
const initialFormModels = useMemo(
() => dashboard.spec.variables.map(dtoToFormModel),
[dashboard.spec.variables],
);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
const [variables, setVariables] =
useState<VariableFormModel[]>(initialFormModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialModels);
setVariables(initialFormModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [editing, setEditing] = useState<EditingState>(null);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
return null;
}
return editing.index === null
return isEditing.type === 'new'
? emptyVariableFormModel()
: variables[editing.index];
}, [editing, variables]);
: variables[isEditing.index];
}, [isEditing, variables]);
const existingNames = useMemo(() => {
const self = editing?.index ?? null;
return variables.filter((_, i) => i !== self).map((v) => v.name);
}, [variables, editing]);
const siblings = useMemo(() => {
const self = isEditing?.type === 'edit' ? isEditing.index : null;
return variables.filter((_, i) => i !== self);
}, [variables, isEditing]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (model: VariableFormModel): void => {
const handleFormSave = (Formmodel: VariableFormModel): void => {
const next = [...variables];
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
if (isEditing?.type === 'new') {
next.push(Formmodel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = Formmodel;
}
setEditing(null);
setIsEditing(null);
persist(next);
};
@@ -88,14 +88,14 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
if (editingFormModel) {
return (
<VariableForm
initial={editingModel}
existingNames={existingNames}
initial={editingFormModel}
siblings={siblings}
isNew={isEditing?.type === 'new'}
isSaving={isSaving}
onClose={(): void => setEditing(null)}
onClose={(): void => setIsEditing(null)}
onSave={handleFormSave}
/>
);
@@ -103,42 +103,25 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// Master view — the variables list.
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Typography.Text className={styles.title}>Variables</Typography.Text>
<Typography.Text className={styles.subtitle}>
Define variables to parameterize panel queries.
</Typography.Text>
</div>
{isEditable ? (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => setEditing({ index: null })}
testId="add-variable"
>
New variable
</Button>
) : null}
</div>
<div className={cx(styles.container, settingsStyles.settingsCard)}>
{variables.length === 0 ? (
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
) : (
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setEditing({ index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
<>
<div className={styles.header}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
</>
)}
</div>
);

View File

@@ -0,0 +1,18 @@
import { VariableFormModel } from './variableFormModel';
/** `null` index = adding a new variable; a number = editing that row. */
export type EditingState =
| { type: 'new' }
| { type: 'edit'; index: number }
| null;
export interface VariableFormProps {
initial: VariableFormModel;
/** The other variables (excluding this one), for uniqueness & cycle checks. */
siblings: VariableFormModel[];
/** True when adding a new variable (enables auto-naming from the attribute). */
isNew: boolean;
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}

View File

@@ -6,7 +6,7 @@ import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableModel';
import type { VariableFormModel } from './variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {

View File

@@ -1,41 +1,43 @@
import {
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
DashboardtypesListVariableSpecSortDTO as VariableSortDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardtypesTextVariableSpecDTO,
DashboardTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DYNAMIC_SIGNAL_ALL,
type DynamicSignalOption,
emptyVariableFormModel,
PLUGIN_KIND,
type TelemetrySignal,
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
} from './variableModel';
type VariableSort,
} from './variableFormModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec?.display;
const display = dto.spec.display;
const common: VariableFormModel = {
...base,
// TODO
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
};
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
@@ -50,7 +52,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: spec.sort ?? VariableSortDTO.none,
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
@@ -67,7 +69,9 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
// An omitted wire signal means "all telemetry".
dynamicSignal:
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -95,7 +99,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
signal: signalForApi(model.dynamicSignal),
},
};
case 'QUERY':
@@ -114,7 +118,6 @@ export function formModelToDto(
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
@@ -135,7 +138,10 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
allowAllValue: model.showAllOption,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
@@ -149,5 +155,3 @@ export function variableTypeOf(
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -0,0 +1,35 @@
import {
buildDependencies,
buildDependencyGraph,
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
import type { VariableFormModel } from './variableFormModel';
/**
* Detects a circular reference among QUERY variables (a query referencing
* another that, transitively, references it back). Reuses the V1 dependency
* graph helpers, which key off `name` / `type` / `queryValue` only.
*
* Returns the names forming the cycle, or `null` when the set is acyclic.
*/
export function detectVariableCycle(
variables: VariableFormModel[],
): string[] | null {
const asDbVariables = variables
.filter((variable) => variable.name)
.map(
(variable) =>
({
name: variable.name,
type: variable.type,
queryValue: variable.queryValue,
}) as IDashboardVariable,
);
const { hasCycle, cycleNodes } = buildDependencyGraph(
buildDependencies(asDbVariables),
);
return hasCycle ? (cycleNodes ?? []) : null;
}

View File

@@ -0,0 +1,154 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
/**
* The four variable types the editor exposes. No generated enum exists for this
* — it's a UI grouping over the wire's envelope + plugin kinds: the TextVariable
* envelope → `TEXT`, and a ListVariable's `DashboardtypesVariablePluginKindDTO`
* (`signoz/QueryVariable` | `signoz/CustomVariable` | `signoz/DynamicVariable`)
* → `QUERY` | `CUSTOM` | `DYNAMIC`. Replace with a generated enum if the backend
* ever exposes a single variable-kind type.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
/** Telemetry signal — the generated enum (traces / logs / metrics). */
export type TelemetrySignal = TelemetrytypesSignalDTO;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
*/
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* Sort order for list-variable values. The wire (Perses) validates `sort`
* against a fixed method set. There is no generated TS enum for it
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
* so we mirror the Perses `Sort` tokens here.
*/
export const VARIABLE_SORT = {
DISABLED: 'none',
ASC: 'alphabetical-asc',
DESC: 'alphabetical-desc',
NUMERICAL_ASC: 'numerical-asc',
NUMERICAL_DESC: 'numerical-desc',
CI_ASC: 'alphabetical-ci-asc',
CI_DESC: 'alphabetical-ci-desc',
} as const;
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
/** Persisted "no sort" value (Perses `none`). */
export const VARIABLE_SORT_DISABLED: VariableSort = VARIABLE_SORT.DISABLED;
export const VARIABLE_SORTS: VariableSort[] = [
VARIABLE_SORT.DISABLED,
VARIABLE_SORT.ASC,
VARIABLE_SORT.DESC,
VARIABLE_SORT.NUMERICAL_ASC,
VARIABLE_SORT.NUMERICAL_DESC,
VARIABLE_SORT.CI_ASC,
VARIABLE_SORT.CI_DESC,
];
export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.DISABLED]: 'Disabled',
[VARIABLE_SORT.ASC]: 'Alphabetical (ascending)',
[VARIABLE_SORT.DESC]: 'Alphabetical (descending)',
[VARIABLE_SORT.NUMERICAL_ASC]: 'Numerical (ascending)',
[VARIABLE_SORT.NUMERICAL_DESC]: 'Numerical (descending)',
[VARIABLE_SORT.CI_ASC]: 'Alphabetical, case-insensitive (ascending)',
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
};
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
export function signalForApi(
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
}
type SortableValues = (string | number | boolean)[];
/** Sorts preview / option values by the variable's chosen order (no-op when disabled). */
export function sortValuesByOrder(
values: SortableValues,
sort: VariableSort,
): SortableValues {
switch (sort) {
case VARIABLE_SORT.ASC:
return sortBy(values);
case VARIABLE_SORT.DESC:
return sortBy(values).reverse();
case VARIABLE_SORT.NUMERICAL_ASC:
return sortBy(values, (value) => Number(value));
case VARIABLE_SORT.NUMERICAL_DESC:
return sortBy(values, (value) => Number(value)).reverse();
case VARIABLE_SORT.CI_ASC:
return sortBy(values, (value) => String(value).toLowerCase());
case VARIABLE_SORT.CI_DESC:
return sortBy(values, (value) => String(value).toLowerCase()).reverse();
default:
return values;
}
}
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
type: 'DYNAMIC',
multiSelect: false,
showAllOption: false,
sort: VARIABLE_SORT_DISABLED,
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: DYNAMIC_SIGNAL_ALL,
};
}

View File

@@ -1,91 +0,0 @@
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import type { TSortVariableValuesType } from 'types/api/dashboard/getAll';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
* to bind a form to; `variableAdapters` converts between this model and the DTO.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
export const ENVELOPE_KIND = {
LIST: 'ListVariable',
TEXT: 'TextVariable',
} as const;
export const PLUGIN_KIND = {
QUERY: 'signoz/QueryVariable',
CUSTOM: 'signoz/CustomVariable',
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSortDTO[] = Object.values(VariableSortDTO);
/** Direction the preview sorter should apply for a given wire sort value. */
export function sortDirectionOf(
sort: VariableSortDTO,
): TSortVariableValuesType {
if (sort.endsWith('-asc')) {
return 'ASC';
}
if (sort.endsWith('-desc')) {
return 'DESC';
}
return 'DISABLED';
}
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
'logs',
'metrics',
];
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
hidden: boolean;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSortDTO;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
hidden: false,
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: VariableSortDTO.none,
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: 'traces',
};
}

View File

@@ -0,0 +1,114 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/** One labelled variable control; dispatches on the variable type. */
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
};
return (
<div
className={styles.variableItem}
data-testid={`variable-${variable.name}`}
>
<Typography.Text className={styles.variableName}>
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size="md" />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
</div>
);
}
export default VariableSelector;

View File

@@ -0,0 +1,71 @@
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
a robin `$name` segment joined to a value segment. */
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
gap from the tags — horizontal/bottom padding comes from the toolbar. */
.bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
}
.variableItem {
display: flex;
align-items: center;
}
.variableName {
display: flex;
min-width: 56px;
height: 32px;
align-items: center;
gap: 4px;
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: 16px;
white-space: nowrap;
}
.infoIcon {
margin-left: 4px;
color: var(--l2-foreground);
}
.variableValue {
display: flex;
min-width: 120px;
height: 32px;
align-items: center;
border: 1px solid var(--l1-border);
border-left: none;
border-radius: 0 2px 2px 0;
background: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
/* Inner control fills the value segment; the segment provides the frame, so the
control itself is borderless/transparent. */
.control {
width: 100%;
min-width: 120px;
:global(.ant-select-selector),
:global(.ant-input),
&:global(.ant-input) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
}

View File

@@ -0,0 +1,45 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
if (variables.length === 0) {
return null;
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
</div>
);
}
export default VariablesBar;

View File

@@ -0,0 +1,56 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from './selectionTypes';
function formatQueryValue(val: string): string {
const num = Number(val);
if (!Number.isNaN(num) && Number.isFinite(num)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter expression from the OTHER dynamic variables' current
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
* dynamic variable's option list is scoped by its sibling selections. Variables
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
* the V1 dynamic-variable runtime.
*/
export function buildExistingDynamicVariableQuery(
variables: VariableFormModel[],
selections: VariableSelectionMap,
currentName: string,
): string {
const parts: string[] = [];
variables.forEach((variable) => {
if (
variable.name === currentName ||
variable.type !== 'DYNAMIC' ||
!variable.dynamicAttribute
) {
return;
}
const selection = selections[variable.name];
if (!selection || selection.allSelected) {
return;
}
const raw = Array.isArray(selection.value)
? selection.value
: [selection.value];
const valid = raw
.filter((v) => v !== null && v !== undefined && v !== '')
.map((v) => String(v));
if (valid.length > 0) {
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
}
});
return parts.join(' AND ');
}

View File

@@ -0,0 +1,16 @@
/** A user-selected variable value at runtime (not persisted to the spec). */
export type SelectedVariableValue =
| string
| number
| boolean
| (string | number | boolean)[]
| null;
export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
}
/** Selected values for a dashboard's variables, keyed by variable name. */
export type VariableSelectionMap = Record<string, VariableSelection>;

View File

@@ -0,0 +1,31 @@
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
export function isResolved(selection?: VariableSelection): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
return true;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,
): Record<string, SelectedVariableValue> {
const payload: Record<string, SelectedVariableValue> = {};
Object.entries(selection).forEach(([name, sel]) => {
payload[name] = sel.value;
});
return payload;
}

View File

@@ -0,0 +1,82 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: signalForApi(variable.dynamicSignal),
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -0,0 +1,90 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -0,0 +1,70 @@
import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface TextSelectorProps {
selection: VariableSelection;
/** Configured default; an emptied input falls back to it (V1 behaviour). */
defaultValue?: string;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Free-text variable input. Mirrors V1: edits are local and only committed on
* blur / Enter (not per keystroke), and clearing the field restores the default.
*/
function TextSelector({
selection,
defaultValue,
onChange,
testId,
}: TextSelectorProps): JSX.Element {
const inputRef = useRef<InputRef>(null);
const [value, setValue] = useState<string>(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
const commit = useCallback(
(next: string): void => onChange({ value: next, allSelected: false }),
[onChange],
);
const handleBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>): void => {
const trimmed = event.target.value.trim();
if (!trimmed && defaultValue) {
setValue(defaultValue);
commit(defaultValue);
} else {
commit(trimmed);
}
},
[commit, defaultValue],
);
return (
<Input
ref={inputRef}
className={styles.control}
bordered={false}
placeholder="Enter value"
value={value}
title={value}
onChange={(e): void => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
inputRef.current?.blur();
}
}}
data-testid={testId}
/>
);
}
export default TextSelector;

View File

@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface ValueSelectorProps {
options: string[];
multiSelect: boolean;
showAllOption: boolean;
loading?: boolean;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
* shared NewSelect components, which provide search, the "ALL" option and
* apply-on-close batching (so multi-select edits don't cascade per toggle).
*/
function ValueSelector({
options,
multiSelect,
showAllOption,
loading,
selection,
onChange,
testId,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
);
if (multiSelect) {
const value = selection.allSelected
? ALL_SELECT_VALUE
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
className={styles.control}
data-testid={testId}
options={optionData}
value={value}
loading={loading}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)
: next
? [String(next)]
: [];
if (values.length === 0) {
onChange({ value: [], allSelected: false });
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
}}
onClear={(): void => onChange({ value: [], allSelected: false })}
/>
);
}
return (
<CustomSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={
selection.value == null || Array.isArray(selection.value)
? undefined
: String(selection.value)
}
loading={loading}
showSearch
placeholder="Select value"
onChange={(next): void =>
onChange({ value: next == null ? '' : String(next), allSelected: false })
}
/>
);
}
export default ValueSelector;

View File

@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from './selectionTypes';
/**
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option — so dependent children always have a usable parent value.
*/
export function useAutoSelect(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
onChange: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0 || selection.allSelected) {
return;
}
const current = selection.value;
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
return { value: def, allSelected: false };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
/**
* Runtime variable selection: derives the variable list from the spec, seeds
* each value from URL → localStorage(store) → default, and persists changes to
* both the store and the URL. Never writes to the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
): UseVariableSelection {
const dashboardId = dashboard.id ?? '';
const variables = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
}

View File

@@ -0,0 +1,199 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
* "depends on" another variable when its query text references that variable
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
* changes, its dependent QUERY variables must refetch. Ported from the V1
* dashboard-variables runtime; operates on the V2 flat variable model.
*/
export type VariableGraph = Record<string, string[]>;
export interface VariableDependencyData {
/** Topological order of variables (parents before children). */
order: string[];
/** Direct children (dependents) of each variable. */
graph: VariableGraph;
/** Direct parents of each variable. */
parentGraph: VariableGraph;
/** All transitive descendants of each variable (precomputed). */
transitiveDescendants: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
/** Names of QUERY variables whose query references `variableName`. */
function getDependents(
variableName: string,
variables: VariableFormModel[],
): string[] {
return variables
.filter(
(v) =>
v.type === 'QUERY' &&
!!v.name &&
textContainsVariableReference(v.queryValue || '', variableName),
)
.map((v) => v.name);
}
/** variable name → its direct dependents (children). */
export function buildDependencies(
variables: VariableFormModel[],
): VariableGraph {
const graph: VariableGraph = {};
variables.forEach((v) => {
if (v.name) {
graph[v.name] = getDependents(v.name, variables);
}
});
return graph;
}
/** Invert a child graph into a parent graph. */
export function buildParentGraph(graph: VariableGraph): VariableGraph {
const parents: VariableGraph = {};
Object.keys(graph).forEach((node) => {
parents[node] = parents[node] ?? [];
});
Object.entries(graph).forEach(([node, children]) => {
children.forEach((child) => {
parents[child] = parents[child] ?? [];
parents[child].push(node);
});
});
return parents;
}
function collectCyclePath(
graph: VariableGraph,
start: string,
end: string,
): string[] {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
}
function detectCycle(
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
let cycleNodes: string[] | null = null;
(graph[node] || []).some((neighbor) => {
if (!visited.has(neighbor)) {
const found = detectCycle(graph, neighbor, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
} else if (recStack.has(neighbor)) {
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
}
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
export function buildDependencyData(
dependencies: VariableGraph,
): VariableDependencyData {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
Object.keys(dependencies).forEach((node) => {
inDegree[node] = inDegree[node] ?? 0;
adjList[node] = adjList[node] ?? [];
(dependencies[node] || []).forEach((child) => {
inDegree[child] = inDegree[child] ?? 0;
inDegree[child] += 1;
adjList[node].push(child);
});
});
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const found = detectCycle(dependencies, node, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
}
return false;
});
// Topological sort (Kahn's algorithm).
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
const order: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
order.push(current);
(adjList[current] || []).forEach((neighbor) => {
inDegree[neighbor] -= 1;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = order.length !== Object.keys(dependencies).length;
// Transitive descendants: walk topo order in reverse.
const transitiveDescendants: VariableGraph = {};
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i];
const desc = new Set<string>();
(adjList[node] || []).forEach((child) => {
desc.add(child);
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
});
transitiveDescendants[node] = Array.from(desc);
}
return {
order,
graph: adjList,
parentGraph: buildParentGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
}
/** Compute the full dependency data straight from the variable list. */
export function computeVariableDependencies(
variables: VariableFormModel[],
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}

View File

@@ -0,0 +1,55 @@
import type { StateCreator } from 'zustand';
import type {
VariableSelection,
VariableSelectionMap,
} from '../../VariablesBar/selectionTypes';
import type { DashboardStore } from '../useDashboardStore';
/**
* Runtime variable selection — the values the user picks in the variable bar.
* Keyed by dashboardId → variable name. Frontend-only and persisted to
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
setVariableValue: (
dashboardId: string,
name: string,
selection: VariableSelection,
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
}
export const createVariableSelectionSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
variableValues: {
...variableValues,
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
},
});
},
setVariableValues: (dashboardId, values): void => {
const { variableValues } = get();
set({
variableValues: { ...variableValues, [dashboardId]: values },
});
},
});
/** Selector: the selection map for a dashboard (empty if none). */
export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? {};

View File

@@ -9,25 +9,36 @@ import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
export type DashboardStore = EditContextSlice & CollapseSlice;
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
* - variable-selection: runtime variable values (frontend-only, persisted).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variableValues: state.variableValues,
}),
},
),
);

View File

@@ -69,24 +69,19 @@ function stripUndefinedLabels(
export function toPostableRuleDTO(
local: PostableAlertRuleV2,
): RuletypesPostableRuleDTO {
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
const payload = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
condition: local.condition,
annotations: local.annotations,
labels: stripUndefinedLabels(local.labels),
evalWindow: (local as unknown as RuletypesPostableRuleDTO).evalWindow,
frequency: (local as unknown as RuletypesPostableRuleDTO).frequency,
preferredChannels: (local as unknown as RuletypesPostableRuleDTO)
.preferredChannels,
notificationSettings: local.notificationSettings,
evaluation: local.evaluation,
schemaVersion: local.schemaVersion,
source: local.source,
version: local.version,
disabled: local.disabled,
description: (local as unknown as RuletypesPostableRuleDTO).description,
};
return payload as unknown as RuletypesPostableRuleDTO;
}
@@ -94,7 +89,7 @@ export function toPostableRuleDTO(
export function toPostableRuleDTOFromAlertDef(
local: AlertDef,
): RuletypesPostableRuleDTO {
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
const payload = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
@@ -104,16 +99,11 @@ export function toPostableRuleDTOFromAlertDef(
evalWindow: local.evalWindow,
frequency: local.frequency,
preferredChannels: local.preferredChannels,
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
.notificationSettings,
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
source: local.source,
version: local.version,
disabled: local.disabled,
description: (local as unknown as RuletypesPostableRuleDTO).description,
};
return payload;
return payload as unknown as RuletypesPostableRuleDTO;
}
export function fromRuleDTOToPostableRuleV2(

View File

@@ -119,7 +119,11 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
queries := make(map[string]qbtypes.Query)
steps := make(map[string]qbtypes.Step)
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
// Resolve metric metadata once per request: patches each metric-aggregation
// query's spec in place, returns the queries whose every aggregation was
// missing (used for preseeded empty results), and any dormant-metric
// warning string. NotFound errors for never-seen metrics are propagated.
missingMetricQueries, dormantMetricsWarningMsg, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
if err != nil {
return nil, err
}
@@ -236,15 +240,13 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
}
}
if len(metricWarnings) > 0 {
if dormantMetricsWarningMsg != "" {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{}
}
for _, w := range metricWarnings {
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
Message: w,
})
}
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
Message: dormantMetricsWarningMsg,
})
}
}
return qbResp, qbErr
@@ -300,11 +302,12 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
// - missingMetricQueries: names of queries whose every aggregation was
// missing. Used downstream to preseed empty result placeholders so the
// response still has an entry per requested query name.
// - metricWarnings: human-readable warnings for metrics that could not be
// resolved: never-seen metrics and dormant metrics (seen but no data in
// the query window).
// - err: Internal when a metadata fetch fails.
func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, metricWarnings []string, err error) {
// - dormantWarning: a human-readable warning describing metrics that exist in
// the store but produced no data within the query window. Empty when no
// such metrics are present.
// - err: NotFound when one or more referenced metrics have never been seen,
// or Internal when a metadata fetch fails.
func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, dormantWarning string, err error) {
metricNames := make([]string, 0)
for idx := range queries {
if queries[idx].Type != qbtypes.QueryTypeBuilder {
@@ -322,13 +325,13 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
}
if len(metricNames) == 0 {
return nil, nil, nil
return nil, "", nil
}
metricTemporality, metricTypes, err := q.metadataStore.FetchTemporalityAndTypeMulti(ctx, start, end, metricNames...)
if err != nil {
q.logger.WarnContext(ctx, "failed to fetch metric temporality", errors.Attr(err), slog.Any("metrics", metricNames))
return nil, nil, errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
return nil, "", errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
}
q.logger.DebugContext(ctx, "fetched metric temporalities and types", slog.Any("metric_temporality", metricTemporality), slog.Any("metric_types", metricTypes))
@@ -360,7 +363,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
}
// Type is resolved now; validate aggregation compatibility against it.
if err := spec.Aggregations[i].ValidateForType(); err != nil {
return nil, nil, err
return nil, "", err
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
@@ -373,7 +376,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
}
if len(missingMetrics) == 0 {
return missingMetricQueries, nil, nil
return missingMetricQueries, "", nil
}
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
@@ -384,33 +387,29 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
}
}
if len(externalMissingMetrics) == 0 {
return missingMetricQueries, nil, nil
// this means all missing metrics are internal, and since internal metrics
// aren't user-controlled, skip errors/warnings for them since users can't act on them
return missingMetricQueries, "", nil
}
// Classify each missing metric: never-seen -> warning with empty result;
// seen-but-no-data-in-window -> dormant warning.
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
// data-in-window dormant warning.
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, externalMissingMetrics...)
var nonExistentMetrics []string
var dormantMetrics []string
nonExistentMetrics := []string{}
for _, name := range externalMissingMetrics {
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
dormantMetrics = append(dormantMetrics, name)
continue
}
nonExistentMetrics = append(nonExistentMetrics, name)
}
var warnings []string
// Never-seen metrics: the query already gets a preseeded empty result
// via the aggregation-dropping path above; we just attach a warning.
if len(nonExistentMetrics) == 1 {
warnings = append(warnings, fmt.Sprintf("metric %s has never been received. Check the metric name and instrumentation", nonExistentMetrics[0]))
} else if len(nonExistentMetrics) > 1 {
warnings = append(warnings, fmt.Sprintf("the following metrics have never been received. Check the metric names and instrumentation: %s", strings.Join(nonExistentMetrics, ", ")))
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "could not find the metric %s", nonExistentMetrics[0])
}
if len(nonExistentMetrics) > 1 {
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "the following metrics were not found: %s", strings.Join(nonExistentMetrics, ", "))
}
// Dormant metrics: seen before but no data in the query window.
// All missing metrics are dormant — assemble the warning string.
lastSeenStr := func(name string) string {
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
@@ -418,16 +417,16 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
}
return name
}
if len(dormantMetrics) == 1 {
warnings = append(warnings, fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(dormantMetrics[0])))
} else if len(dormantMetrics) > 1 {
parts := make([]string, len(dormantMetrics))
for i, m := range dormantMetrics {
if len(externalMissingMetrics) == 1 {
dormantWarning = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
} else {
parts := make([]string, len(externalMissingMetrics))
for i, m := range externalMissingMetrics {
parts[i] = lastSeenStr(m)
}
warnings = append(warnings, fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", ")))
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
}
return missingMetricQueries, warnings, nil
return missingMetricQueries, dormantWarning, nil
}
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {

View File

@@ -37,7 +37,7 @@ func (m *mockMetricStmtBuilder) Build(_ context.Context, _, _ uint64, _ qbtypes.
func TestQueryRange_MetricTypeMissing(t *testing.T) {
// When a metric has UnspecifiedType and is not found in the metadata store,
// the querier should return an empty result with a warning instead of an error.
// the querier should return a not-found error, even if the request provides a temporality
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
@@ -80,14 +80,9 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
},
}
resp, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Warning)
require.Len(t, resp.Warning.Warnings, 1)
assert.Contains(t, resp.Warning.Warnings[0].Message, "unknown_metric")
assert.Contains(t, resp.Warning.Warnings[0].Message, "has never been received")
_, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
require.Error(t, err)
assert.Contains(t, err.Error(), "could not find the metric unknown_metric")
}
func TestQueryRange_MetricTypeFromStore(t *testing.T) {

View File

@@ -101,29 +101,9 @@ func (b *MetricQueryStatementBuilder) Build(
return nil, err
}
var pairFallbackWarnings []string
for _, sel := range keySelectors {
if _, ok := keys[sel.Name]; !ok {
keys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
Name: sel.Name,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
Signal: telemetrytypes.SignalMetrics,
}}
pairFallbackWarnings = append(pairFallbackWarnings,
fmt.Sprintf("key `%s` not found on metric %s", sel.Name, query.Aggregations[0].MetricName),
)
}
}
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
stmt, err := b.buildPipelineStatement(ctx, start, end, query, keys, variables)
if err != nil {
return nil, err
}
stmt.Warnings = append(stmt.Warnings, pairFallbackWarnings...)
return stmt, nil
return b.buildPipelineStatement(ctx, start, end, query, keys, variables)
}
func (b *MetricQueryStatementBuilder) buildPipelineStatement(

View File

@@ -217,39 +217,6 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "test_missing_key_falls_back_to_labels",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "signoz_calls_total",
Type: metrictypes.SumType,
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
Filter: &qbtypes.Filter{
Expression: "k8s.statefulset.name = 'my-statefulset'",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "k8s.statefulset.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", false, "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
Warnings: []string{"key `k8s.statefulset.name` not found on metric signoz_calls_total"},
},
expectedErr: nil,
},
}
fm := NewFieldMapper()

View File

@@ -38,7 +38,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
FillMode: FillModeSolid,
SpanGaps: SpanGaps{FillLessThan: valuer.MustParseTextDuration("60s")},
},
Legend: Legend{Position: LegendPositionBottom, Mode: LegendModeList},
Legend: Legend{Position: LegendPositionBottom},
},
},
Queries: []Query{

View File

@@ -48,42 +48,7 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
// ══════════════════════════════════════════════
func (d *DashboardSpec) Validate() error {
if err := d.validateVariables(); err != nil {
return err
}
if err := d.validatePanels(); err != nil {
return err
}
return d.validateLayouts()
}
// validateVariables rejects two variables sharing the same name.
func (d *DashboardSpec) validateVariables() error {
seen := make(map[string]struct{}, len(d.Variables))
for i, v := range d.Variables {
var name string
switch s := v.Spec.(type) {
case *ListVariableSpec:
name = s.Name
case *TextVariableSpec:
name = s.Name
default:
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.variables[%d].spec: unexpected variable spec type %T", i, v.Spec)
}
if _, dup := seen[name]; dup {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables[%d]: duplicate variable name %q", i, name)
}
seen[name] = struct{}{}
}
return nil
}
func (d *DashboardSpec) validatePanels() error {
for key, panel := range d.Panels {
if err := common.ValidateID(key); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
}
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
@@ -104,13 +69,6 @@ func (d *DashboardSpec) validatePanels() error {
}
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
if !slices.Contains(allowed, plugin.Kind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
@@ -138,35 +96,12 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
return nil
}
// validateLayouts rejects grid items referencing a panel that doesn't exist.
func (d *DashboardSpec) validateLayouts() error {
for li, layout := range d.Layouts {
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
if !ok {
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
for ii, item := range grid.Items {
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
if item.Content == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: content reference is required", path)
}
key, err := panelKeyFromRef(item.Content.Path, item.Content.Ref, path)
if err != nil {
return err
}
if _, ok := d.Panels[key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
}
}
var (
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
return nil
}
// panelKeyFromRef extracts <key> from a "#/spec/panels/<key>" content ref.
func panelKeyFromRef(refPath []string, ref string, path string) (string, error) {
if len(refPath) != 3 || refPath[0] != "spec" || refPath[1] != "panels" {
return "", errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: %q must reference a panel as \"#/spec/panels/<key>\"", path, ref)
}
return refPath[2], nil
}
)

View File

@@ -73,7 +73,7 @@ func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV
}
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
if err != nil {
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard").WithAdditional(err.Error())
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
}
out := &UpdatableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {

View File

@@ -405,7 +405,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"},
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
]`).Apply(base)
require.NoError(t, err)

View File

@@ -112,174 +112,6 @@ func TestValidateOnlyVariables(t *testing.T) {
require.NoError(t, err, "expected valid")
}
func TestInvalidateDuplicateVariableNames(t *testing.T) {
data := []byte(`{
"variables": [
{
"kind": "TextVariable",
"spec": {"name": "env", "value": "prod"}
},
{
"kind": "ListVariable",
"spec": {
"name": "env",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for duplicate variable name")
require.Contains(t, err.Error(), `duplicate variable name "env"`)
}
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
listVarWithName := func(name string) []byte {
return []byte(`{
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "` + name + `",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"layouts": []
}`)
}
for _, name := range []string{"my var", "cost$", "bad!", "a/b"} {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.Error(t, err, "expected error for invalid variable name %q", name)
require.Contains(t, err.Error(), "is not a correct name")
})
}
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName(name))
require.NoError(t, err, "expected valid variable name %q", name)
})
}
t.Run("digits only", func(t *testing.T) {
_, err := unmarshalDashboard(listVarWithName("123"))
require.Error(t, err)
require.Contains(t, err.Error(), "cannot contain only digits")
})
}
func TestInvalidatePanelKey(t *testing.T) {
data := []byte(`{
"panels": {
"bad key!": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
}}}
}]
}
}
},
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for invalid panel key")
require.Contains(t, err.Error(), "is not a correct name")
}
func TestInvalidateListVariableCrossFields(t *testing.T) {
listVar := func(specFields string) []byte {
return []byte(`{
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
` + specFields + `
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"layouts": []
}`)
}
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
require.Error(t, err)
require.Contains(t, err.Error(), "customAllValue cannot be set")
})
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
require.Error(t, err)
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
require.Error(t, err)
require.Contains(t, err.Error(), "allowMultiple")
})
t.Run("valid sort is accepted", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
require.NoError(t, err)
})
t.Run("unknown sort is rejected", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
require.Error(t, err)
require.Contains(t, err.Error(), "unknown sort")
})
}
func TestInvalidateEmptyVariableName(t *testing.T) {
cases := map[string][]byte{
"text variable": []byte(`{
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
"layouts": []
}`),
"list variable": []byte(`{
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
}
}],
"layouts": []
}`),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected error for empty variable name")
require.Contains(t, err.Error(), "name cannot be empty")
})
}
}
func TestInvalidateUnknownPluginKind(t *testing.T) {
tests := []struct {
name string
@@ -438,65 +270,6 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
func TestInvalidateLayoutPanelReferences(t *testing.T) {
validPanels := `"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}`
layout := func(items string) []byte {
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
}
tests := []struct {
name string
data []byte
wantContain string
}{
{
name: "reference to unknown panel",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/ghost"}}`),
wantContain: `references unknown panel "ghost"`,
},
{
name: "reference not pointing at a panel",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/variables/p1"}}`),
wantContain: "must reference a panel",
},
{
name: "reference missing spec prefix",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/panels/p1"}}`),
wantContain: "must reference a panel",
},
{
name: "valid reference",
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}`),
wantContain: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := unmarshalDashboard(tt.data)
if tt.wantContain == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantContain)
})
}
}
func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
tests := []struct {
name string
@@ -796,24 +569,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}`,
wantContain: "legend position",
},
{
name: "bad legend mode",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"mode": "grid"}}
}
}
}
},
"layouts": []
}`,
wantContain: "legend mode",
},
{
name: "bad threshold format",
data: `{
@@ -879,39 +634,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
// Label on ThresholdWithLabel is optional — the backend never reads it, so a
// threshold with an omitted or empty label must validate cleanly.
func TestThresholdLabelOptional(t *testing.T) {
for _, tt := range []struct {
name string
threshold string
}{
{name: "label omitted", threshold: `{"value": 100, "color": "Red"}`},
{name: "label empty", threshold: `{"value": 100, "color": "Red", "label": ""}`},
} {
t.Run(tt.name, func(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "threshold without a label should validate")
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Len(t, spec.Thresholds, 1)
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
})
}
}
func TestInvalidatePanelWithoutQueries(t *testing.T) {
data := []byte(`{
"panels": {
@@ -1027,6 +749,11 @@ func TestValidateRequiredFields(t *testing.T) {
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
wantContain: "Color",
},
{
name: "ThresholdWithLabel missing label",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
wantContain: "Label",
},
{
name: "ComparisonThreshold missing value",
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": "above", "format": "text", "color": "Red"}]}`),
@@ -1084,11 +811,10 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
require.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
// and verify the output contains the default values.
@@ -1099,10 +825,9 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"none"`,
"fillMode": `"solid"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"mode": `"list"`,
} {
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
}
@@ -1205,7 +930,7 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Equal(t, "2", tsSpec.Formatting.DecimalPrecision.ValueOrDefault())
assert.Equal(t, "spline", tsSpec.ChartAppearance.LineInterpolation.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.LineStyle.ValueOrDefault())
assert.Equal(t, "none", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "global_time", tsSpec.Visualization.TimePreference.ValueOrDefault())
assert.Equal(t, "bottom", tsSpec.Legend.Position.ValueOrDefault())
numSpec := d.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
@@ -1225,7 +950,7 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Equal(t, "2", tsLoaded.Formatting.DecimalPrecision.ValueOrDefault(), "after load")
assert.Equal(t, "spline", tsLoaded.ChartAppearance.LineInterpolation.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.LineStyle.ValueOrDefault(), "after load")
assert.Equal(t, "none", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "global_time", tsLoaded.Visualization.TimePreference.ValueOrDefault(), "after load")
assert.Equal(t, "bottom", tsLoaded.Legend.Position.ValueOrDefault(), "after load")
numLoaded := loaded.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
@@ -1241,7 +966,7 @@ func TestStorageRoundTrip(t *testing.T) {
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"none"`,
"fillMode": `"solid"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"format": `"text"`,

View File

@@ -30,7 +30,6 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
}

View File

@@ -51,7 +51,7 @@ func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = PanelPluginKind(kind)
p.Spec = *spec
p.Spec = spec
return nil
}
@@ -110,7 +110,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = QueryPluginKind(kind)
p.Spec = *spec
p.Spec = spec
return nil
}
@@ -165,7 +165,7 @@ func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = VariablePluginKind(kind)
p.Spec = *spec
p.Spec = spec
return nil
}
@@ -215,7 +215,7 @@ func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
return err
}
p.Kind = DatasourcePluginKind(kind)
p.Spec = *spec
p.Spec = spec
return nil
}
@@ -297,7 +297,8 @@ func extractKindAndSpec(data []byte) (string, []byte, error) {
return head.Kind, head.Spec, nil
}
func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
// decodeSpec strict-decodes a spec JSON into target and runs struct-tag validation (go-playground/validator).
func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
if len(specJSON) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
}
@@ -309,12 +310,7 @@ func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
if err := validator.New().Struct(target); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
}
if v, ok := any(target).(interface{ validate() error }); ok {
if err := v.validate(); err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: %s", kind, err.Error())
}
}
return &target, nil
return target, nil
}
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators

View File

@@ -4,11 +4,9 @@ import (
"encoding/json"
"maps"
"slices"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"github.com/perses/spec/go/dashboard"
"github.com/perses/spec/go/dashboard/variable"
@@ -86,7 +84,7 @@ type QuerySpec struct {
// ══════════════════════════════════════════════
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind" required:"true"`
@@ -96,7 +94,7 @@ type Variable struct {
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(variable.KindList): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec"),
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec"),
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec"),
})
}
@@ -112,14 +110,14 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
return err
}
v.Kind = variable.KindList
v.Spec = *spec
v.Spec = spec
case string(variable.KindText):
spec, err := decodeSpec(specJSON, new(TextVariableSpec), kind)
spec, err := decodeSpec(specJSON, new(dashboard.TextVariableSpec), kind)
if err != nil {
return err
}
v.Kind = variable.KindText
v.Spec = *spec
v.Spec = spec
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown variable kind %q; allowed values: %s", kind, allowedValuesForKind([]variable.Kind{variable.KindList, variable.KindText}))
}
@@ -129,7 +127,7 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
func (Variable) JSONSchemaOneOf() []any {
return []any{
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
VariableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
VariableEnvelope[dashboard.TextVariableSpec]{Kind: string(variable.KindText)},
}
}
@@ -145,106 +143,15 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display *Display `json:"display,omitempty"`
Display Display `json:"display" required:"true"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
CustomAllValue string `json:"customAllValue,omitempty"`
CapturingRegexp string `json:"capturingRegexp,omitempty"`
Sort ListVariableSpecSort `json:"sort,omitzero"`
Sort *variable.Sort `json:"sort,omitempty"`
Plugin VariablePlugin `json:"plugin"`
Name string `json:"name" required:"true" minLength:"1"`
}
// validate mirrors perses ListVariableSpec validation (plus the digits-only name
// check perses only applies to text variables); run by decodeSpec on unmarshal.
func (s *ListVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
return err
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
}
if s.CustomAllValue != "" && !s.AllowAllValue {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "customAllValue cannot be set if allowAllValue is not set to true")
}
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "defaultValue cannot be a list if allowMultiple is not set to true")
}
return nil
}
// ListVariableSpecSort is the value-list sort method, mirrored from Perses as a
// stable enum so the allowed values surface in the generated OpenAPI schema.
type ListVariableSpecSort struct{ valuer.String }
var (
SortNone = ListVariableSpecSort{valuer.NewString("none")}
SortAlphabeticalAsc = ListVariableSpecSort{valuer.NewString("alphabetical-asc")}
SortAlphabeticalDesc = ListVariableSpecSort{valuer.NewString("alphabetical-desc")}
SortNumericalAsc = ListVariableSpecSort{valuer.NewString("numerical-asc")}
SortNumericalDesc = ListVariableSpecSort{valuer.NewString("numerical-desc")}
SortAlphabeticalCaseInsensitiveAsc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-asc")}
SortAlphabeticalCaseInsensitiveDesc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-desc")}
)
func (ListVariableSpecSort) Enum() []any {
return []any{
SortNone,
SortAlphabeticalAsc,
SortAlphabeticalDesc,
SortNumericalAsc,
SortNumericalDesc,
SortAlphabeticalCaseInsensitiveAsc,
SortAlphabeticalCaseInsensitiveDesc,
}
}
func (s ListVariableSpecSort) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
// UnmarshalJSON validates against the enum on decode (valuer.String alone
// accepts any string). An empty value is allowed and means "no sort", matching
// Perses.
func (s *ListVariableSpecSort) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid sort: must be a string, one of `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`")
}
if v == "" {
*s = ListVariableSpecSort{}
return nil
}
sort := ListVariableSpecSort{valuer.NewString(v)}
if !sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown sort %q: must be `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`", v)
}
*s = sort
return nil
}
// TextVariableSpec replicates dashboard.TextVariableSpec so name can carry the
// required/non-empty schema tags perses leaves off.
type TextVariableSpec struct {
Display *Display `json:"display,omitempty"`
Value string `json:"value"`
Constant bool `json:"constant,omitempty"`
Name string `json:"name" required:"true" minLength:"1"`
}
// validate mirrors perses TextVariableSpec validation; run by decodeSpec on unmarshal.
func (s *TextVariableSpec) validate() error {
if err := common.ValidateID(s.Name); err != nil {
return err
}
if _, err := strconv.Atoi(s.Name); err == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
}
if s.Value == "" && s.Constant {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "value for a constant text variable cannot be empty")
}
return nil
Name string `json:"name"`
}
// ══════════════════════════════════════════════
@@ -287,7 +194,7 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
return err
}
l.Kind = dashboard.LayoutKind(kind)
l.Spec = *spec
l.Spec = spec
return nil
}

View File

@@ -241,7 +241,6 @@ type TableFormatting struct {
type Legend struct {
Position LegendPosition `json:"position"`
Mode LegendMode `json:"mode"`
CustomColors map[string]string `json:"customColors"`
}
@@ -249,7 +248,7 @@ type ThresholdWithLabel struct {
Value float64 `json:"value" validate:"required" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label"`
Label string `json:"label" validate:"required" required:"true"`
}
type ComparisonThreshold struct {
@@ -359,47 +358,6 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
}
}
type LegendMode struct{ valuer.String }
var (
LegendModeList = LegendMode{valuer.NewString("list")} // default
LegendModeTable = LegendMode{valuer.NewString("table")}
)
func (LegendMode) Enum() []any {
return []any{LegendModeList} // others are not supported in UI yet
}
func (m LegendMode) ValueOrDefault() string {
if m.IsZero() {
return LegendModeList.StringValue()
}
return m.StringValue()
}
func (m LegendMode) MarshalJSON() ([]byte, error) {
return json.Marshal(m.ValueOrDefault())
}
func (m *LegendMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend mode: must be a string, one of `list` or `table`")
}
if v == "" {
*m = LegendModeList
return nil
}
lm := LegendMode{valuer.NewString(v)}
switch lm {
case LegendModeList, LegendModeTable:
*m = lm
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend mode %q: must be `list` or `table`", v)
}
}
type ThresholdFormat struct{ valuer.String }
var (
@@ -576,9 +534,9 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
type FillMode struct{ valuer.String }
var (
FillModeSolid = FillMode{valuer.NewString("solid")}
FillModeSolid = FillMode{valuer.NewString("solid")} // default
FillModeGradient = FillMode{valuer.NewString("gradient")}
FillModeNone = FillMode{valuer.NewString("none")} // default
FillModeNone = FillMode{valuer.NewString("none")}
)
func (FillMode) Enum() []any {
@@ -587,7 +545,7 @@ func (FillMode) Enum() []any {
func (fm FillMode) ValueOrDefault() string {
if fm.IsZero() {
return FillModeNone.StringValue()
return FillModeSolid.StringValue()
}
return fm.StringValue()
}
@@ -602,7 +560,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
if v == "" {
*fm = FillModeNone
*fm = FillModeSolid
return nil
}
val := FillMode{valuer.NewString(v)}
@@ -615,9 +573,12 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
}
}
// SpanGaps controls whether lines connect across null values.
// When FillOnlyBelow is false (default), all gaps are connected.
// When FillOnlyBelow is true, only gaps smaller than FillLessThan are connected.
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan valuer.TextDuration `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
FillOnlyBelow bool `json:"fillOnlyBelow"`
FillLessThan valuer.TextDuration `json:"fillLessThan"`
}
type PrecisionOption struct{ valuer.String }

View File

@@ -614,7 +614,7 @@ def test_histogram_p90_returns_warning_outside_data_window(
assert warnings[0]["message"].startswith(f"no data found for the metric {metric_name}")
def test_non_existent_metrics_returns_warning(
def test_non_existent_metrics_returns_404(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
@@ -635,11 +635,9 @@ def test_non_existent_metrics_returns_warning(
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
assert response.status_code == HTTPStatus.NOT_FOUND
data = response.json()
warnings = get_all_warnings(data)
assert any("whatevergoennnsgoeshere" in w["message"] and "has never been received" in w["message"] for w in warnings), f"expected never-seen metric warning, got: {warnings}"
assert get_error_message(response.json()) == "could not find the metric whatevergoennnsgoeshere"
def test_non_existent_internal_metrics_returns_no_warning(