mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-19 23:10:25 +01:00
Compare commits
7 Commits
nv/dashboa
...
issue_5267
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1682f2ab6 | ||
|
|
b8567664da | ||
|
|
643aac4424 | ||
|
|
2cf7ef93ea | ||
|
|
dba827ee33 | ||
|
|
467a556062 | ||
|
|
a8f6b8187e |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.128.0
|
||||
image: signoz/signoz:v0.129.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.128.0
|
||||
image: signoz/signoz:v0.129.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
image: signoz/signoz:${VERSION:-v0.129.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
image: signoz/signoz:${VERSION:-v0.129.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -6992,6 +6992,16 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableSpanMapperTest:
|
||||
properties:
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -7079,6 +7089,39 @@ components:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableSpanMapperTest:
|
||||
properties:
|
||||
groups:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
|
||||
nullable: true
|
||||
type: array
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
- groups
|
||||
type: object
|
||||
SpantypesPostableSpanMapperTestGroup:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
|
||||
enabled:
|
||||
type: boolean
|
||||
mappers:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
|
||||
nullable: true
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -7240,6 +7283,17 @@ components:
|
||||
- operation
|
||||
- priority
|
||||
type: object
|
||||
SpantypesSpanMapperTestSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
resource:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -12797,6 +12851,69 @@ paths:
|
||||
summary: Update a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/span_mapper_groups/test:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Tests how span mappers would transform sample spans
|
||||
operationId: TestSpanMappers
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Test span mappers against sample spans
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/stats:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -8135,6 +8135,44 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributes =
|
||||
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOResource =
|
||||
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanMapperTestSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesSpanMapperTestSpanDTOResource;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -8430,6 +8468,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestGroupDTO {
|
||||
condition: SpantypesSpanMapperGroupConditionDTO | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mappers?: SpantypesPostableSpanMapperDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
@@ -9798,6 +9863,14 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type TestSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperTestDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
|
||||
@@ -30,8 +30,10 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,67 +1,29 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Skeleton } from 'antd';
|
||||
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 { 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 { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import CheckboxFilterHeader from './CheckboxFilterHeader';
|
||||
import CheckboxValueRow from './CheckboxValueRow';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
import { isKeyMatch } from './utils';
|
||||
import useActiveQueryIndex from './useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from './useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from './useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from './useCheckboxFilterState';
|
||||
import useCheckboxFilterValues from './useCheckboxFilterValues';
|
||||
|
||||
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;
|
||||
@@ -72,194 +34,39 @@ interface ICheckboxProps {
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
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,
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
|
||||
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 { attributeValues, isLoading } = useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
});
|
||||
|
||||
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 { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
|
||||
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 { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -277,293 +84,6 @@ 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 &&
|
||||
@@ -571,48 +91,19 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="checkbox-filter">
|
||||
<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>
|
||||
<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>
|
||||
<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 && (
|
||||
)}
|
||||
{isOpen && !isLoading && (
|
||||
<>
|
||||
{!isEmptyStateWithDocsEnabled && (
|
||||
<section className="search">
|
||||
@@ -634,48 +125,24 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</section>
|
||||
@@ -688,10 +155,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
{visibleItemsCount < attributeValues?.length && (
|
||||
<section className="show-more">
|
||||
<Typography.Text
|
||||
className="show-more-text"
|
||||
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
|
||||
>
|
||||
<Typography.Text className="show-more-text" onClick={onShowMore}>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
@@ -0,0 +1,417 @@
|
||||
/* 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;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
@@ -20,6 +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 styles from './DashboardPageToolbar.module.scss';
|
||||
|
||||
@@ -52,6 +53,10 @@ 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;
|
||||
|
||||
@@ -117,7 +122,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={false}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
|
||||
@@ -1,106 +1,15 @@
|
||||
// settings card wrapper — mirrors the V1 public dashboard treatment
|
||||
.publicDashboardCard {
|
||||
// Publish tab — "status strip" direction (Claude Design: Publish Drawer Final).
|
||||
// Fills the drawer height so the actions anchor a footer instead of floating.
|
||||
.publishTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l2-border);
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.statusTitle {
|
||||
margin-bottom: 16px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeRangeSelectGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeRangeSelectLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.timeRangeSelect {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.urlGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.urlLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.urlContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.urlText {
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 8px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
|
||||
}
|
||||
|
||||
.calloutIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.calloutText {
|
||||
color: var(--text-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 32px;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.footer {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Globe, Trash } from '@signozhq/icons';
|
||||
import { Globe, RefreshCw, Trash } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
import styles from './PublicDashboardActions.module.scss';
|
||||
|
||||
interface PublicDashboardActionsProps {
|
||||
isPublic: boolean;
|
||||
@@ -25,7 +25,7 @@ function PublicDashboardActions({
|
||||
onUnpublish,
|
||||
}: PublicDashboardActionsProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.footer}>
|
||||
{isPublic ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -33,22 +33,22 @@ function PublicDashboardActions({
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={14} />}
|
||||
prefix={<Trash size={15} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish dashboard
|
||||
Unpublish Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isUpdating}
|
||||
prefix={<Globe size={14} />}
|
||||
prefix={<RefreshCw size={15} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
>
|
||||
Update published dashboard
|
||||
Update Dashboard
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -57,11 +57,11 @@ function PublicDashboardActions({
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={14} />}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Publish dashboard
|
||||
Publish Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
function PublicDashboardCallout(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
<Info size={12} className={styles.calloutIcon} />
|
||||
<Typography.Text className={styles.calloutText}>
|
||||
Dashboard variables won't work in public dashboards
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardCallout;
|
||||
@@ -0,0 +1,19 @@
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hintIcon {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hintText {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboardHint.module.scss';
|
||||
|
||||
function PublicDashboardHint(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.hint}>
|
||||
<Info size={14} className={styles.hintIcon} />
|
||||
<Typography.Text className={styles.hintText}>
|
||||
Dashboard variables aren't supported on public links.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardHint;
|
||||
@@ -0,0 +1,34 @@
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
// Render the (non-portaled) dropdown above the drawer.
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
// Radix sets --radix-select-trigger-width on the content element (the wrapper's
|
||||
// child), so match it there to make the dropdown take the input's width.
|
||||
// SelectSimple exposes no content className, hence the descendant selector.
|
||||
[data-radix-popper-content-wrapper] > * {
|
||||
width: var(--radix-select-trigger-width);
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timeRangeSelect {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { TIME_RANGE_PRESETS_OPTIONS } from './constants';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
import styles from './PublicDashboardSettingsForm.module.scss';
|
||||
|
||||
interface PublicDashboardSettingsFormProps {
|
||||
timeRangeEnabled: boolean;
|
||||
@@ -22,28 +22,29 @@ function PublicDashboardSettingsForm({
|
||||
}: PublicDashboardSettingsFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
id="public-dashboard-enable-time-range"
|
||||
className={styles.checkbox}
|
||||
testId="public-dashboard-time-range-toggle"
|
||||
value={timeRangeEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(checked): void => onTimeRangeEnabledChange(checked === true)}
|
||||
>
|
||||
Enable time range
|
||||
</Checkbox>
|
||||
<div className={styles.switchRow}>
|
||||
<Switch
|
||||
testId="public-dashboard-time-range-toggle"
|
||||
value={timeRangeEnabled}
|
||||
disabled={disabled}
|
||||
onChange={onTimeRangeEnabledChange}
|
||||
>
|
||||
Enable time range
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className={styles.timeRangeSelectGroup}>
|
||||
<Typography.Text className={styles.timeRangeSelectLabel}>
|
||||
<div className={styles.fieldGroup}>
|
||||
<Typography.Text className={styles.fieldLabel}>
|
||||
Default time range
|
||||
</Typography.Text>
|
||||
<SelectSimple
|
||||
className={styles.timeRangeSelect}
|
||||
testId="public-dashboard-default-time-range"
|
||||
placeholder="Select default time range"
|
||||
items={TIME_RANGE_PRESETS_OPTIONS}
|
||||
items={RelativeDurationOptions}
|
||||
value={defaultTimeRange}
|
||||
disabled={disabled}
|
||||
withPortal={false}
|
||||
onChange={(value): void => onDefaultTimeRangeChange(value as string)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardStatusProps {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
function PublicDashboardStatus({
|
||||
isPublic,
|
||||
}: PublicDashboardStatusProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text className={styles.statusTitle}>
|
||||
{isPublic
|
||||
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
|
||||
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardStatus;
|
||||
@@ -0,0 +1,67 @@
|
||||
.statusStrip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.statusStripLive {
|
||||
border-color: var(--callout-primary-border);
|
||||
background: var(--callout-primary-background);
|
||||
}
|
||||
|
||||
.statusMedallion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.statusMedallionLive {
|
||||
border-color: var(--callout-primary-border);
|
||||
background: var(--callout-primary-background);
|
||||
color: var(--callout-primary-icon);
|
||||
}
|
||||
|
||||
.statusBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.statusTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.statusSubtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.statusSubtitleLive {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.statusBadgeDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
background: currentColor;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Globe, LockKeyhole } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './PublicDashboardStatus.module.scss';
|
||||
|
||||
interface PublicDashboardStatusProps {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
function PublicDashboardStatus({
|
||||
isPublic,
|
||||
}: PublicDashboardStatusProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.statusStrip, { [styles.statusStripLive]: isPublic })}
|
||||
>
|
||||
<span
|
||||
className={cx(styles.statusMedallion, {
|
||||
[styles.statusMedallionLive]: isPublic,
|
||||
})}
|
||||
>
|
||||
{isPublic ? <Globe size={18} /> : <LockKeyhole size={18} />}
|
||||
</span>
|
||||
|
||||
<div className={styles.statusBody}>
|
||||
<Typography.Text className={styles.statusTitle}>
|
||||
{isPublic ? 'This dashboard is live' : 'This dashboard is private'}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className={cx(styles.statusSubtitle, {
|
||||
[styles.statusSubtitleLive]: isPublic,
|
||||
})}
|
||||
>
|
||||
{isPublic
|
||||
? 'Anyone with the link can view it — no account needed.'
|
||||
: 'Publish it to share a read-only view with anyone who has the link.'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" color={isPublic ? 'robin' : 'secondary'}>
|
||||
<span className={styles.statusBadgeDot} />
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardStatus;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Copy, ExternalLink } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardUrlProps {
|
||||
url: string;
|
||||
onCopy: () => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function PublicDashboardUrl({
|
||||
url,
|
||||
onCopy,
|
||||
onOpen,
|
||||
}: PublicDashboardUrlProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.urlGroup}>
|
||||
<Typography.Text className={styles.urlLabel}>
|
||||
Public dashboard URL
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.urlContainer}>
|
||||
<Typography.Text className={styles.urlText}>{url}</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Copy public dashboard URL"
|
||||
testId="public-dashboard-copy-url"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open public dashboard in new tab"
|
||||
testId="public-dashboard-open-url"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardUrl;
|
||||
@@ -0,0 +1,69 @@
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.linkPlaceholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.linkPlaceholderIcon {
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.linkPlaceholderText {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.linkField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 40px;
|
||||
padding: 0 5px 0 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
}
|
||||
|
||||
.linkUrl {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--l2-foreground);
|
||||
font-family: var(--font-mono, 'Geist Mono'), monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.linkDivider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 4px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Copy, ExternalLink, Link2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboardUrl.module.scss';
|
||||
|
||||
interface PublicDashboardUrlProps {
|
||||
isPublic: boolean;
|
||||
url: string;
|
||||
onCopy: () => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function PublicDashboardUrl({
|
||||
isPublic,
|
||||
url,
|
||||
onCopy,
|
||||
onOpen,
|
||||
}: PublicDashboardUrlProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.fieldGroup}>
|
||||
<Typography.Text className={styles.fieldLabel}>Public link</Typography.Text>
|
||||
|
||||
{isPublic ? (
|
||||
<div className={styles.linkField}>
|
||||
<Typography.Text className={styles.linkUrl}>{url}</Typography.Text>
|
||||
<span className={styles.linkDivider} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Copy link"
|
||||
testId="public-dashboard-copy-url"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open link"
|
||||
testId="public-dashboard-open-url"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.linkPlaceholder}>
|
||||
<Link2 size={15} className={styles.linkPlaceholderIcon} />
|
||||
<Typography.Text className={styles.linkPlaceholderText}>
|
||||
Your shareable link will appear here once published
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardUrl;
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface TimeRangePresetOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Default time-range presets offered for the public dashboard viewer.
|
||||
export const TIME_RANGE_PRESETS_OPTIONS: TimeRangePresetOption[] = [
|
||||
{ label: 'Last 5 minutes', value: '5m' },
|
||||
{ label: 'Last 15 minutes', value: '15m' },
|
||||
{ label: 'Last 30 minutes', value: '30m' },
|
||||
{ label: 'Last 1 hour', value: '1h' },
|
||||
{ label: 'Last 6 hours', value: '6h' },
|
||||
{ label: 'Last 1 day', value: '24h' },
|
||||
];
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import PublicDashboardActions from './PublicDashboardActions';
|
||||
import PublicDashboardCallout from './PublicDashboardCallout';
|
||||
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm';
|
||||
import PublicDashboardStatus from './PublicDashboardStatus';
|
||||
import PublicDashboardUrl from './PublicDashboardUrl';
|
||||
import PublicDashboardActions from './PublicDashboardActions/PublicDashboardActions';
|
||||
import PublicDashboardHint from './PublicDashboardHint/PublicDashboardHint';
|
||||
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm/PublicDashboardSettingsForm';
|
||||
import PublicDashboardStatus from './PublicDashboardStatus/PublicDashboardStatus';
|
||||
import PublicDashboardUrl from './PublicDashboardUrl/PublicDashboardUrl';
|
||||
import { usePublicDashboard } from './usePublicDashboard';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
@@ -37,22 +37,27 @@ function PublicDashboardSettings({
|
||||
const controlsDisabled = isLoading || !isAdmin;
|
||||
|
||||
return (
|
||||
<div className={styles.publicDashboardCard}>
|
||||
<PublicDashboardStatus isPublic={isPublic} />
|
||||
<div className={styles.publishTab}>
|
||||
<div className={styles.content}>
|
||||
<PublicDashboardStatus isPublic={isPublic} />
|
||||
|
||||
<PublicDashboardSettingsForm
|
||||
timeRangeEnabled={timeRangeEnabled}
|
||||
defaultTimeRange={defaultTimeRange}
|
||||
disabled={controlsDisabled}
|
||||
onTimeRangeEnabledChange={setTimeRangeEnabled}
|
||||
onDefaultTimeRangeChange={setDefaultTimeRange}
|
||||
/>
|
||||
<PublicDashboardUrl
|
||||
isPublic={isPublic}
|
||||
url={publicUrl}
|
||||
onCopy={onCopyUrl}
|
||||
onOpen={onOpenUrl}
|
||||
/>
|
||||
|
||||
{isPublic && (
|
||||
<PublicDashboardUrl url={publicUrl} onCopy={onCopyUrl} onOpen={onOpenUrl} />
|
||||
)}
|
||||
<PublicDashboardSettingsForm
|
||||
timeRangeEnabled={timeRangeEnabled}
|
||||
defaultTimeRange={defaultTimeRange}
|
||||
disabled={controlsDisabled}
|
||||
onTimeRangeEnabledChange={setTimeRangeEnabled}
|
||||
onDefaultTimeRangeChange={setDefaultTimeRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PublicDashboardCallout />
|
||||
<PublicDashboardHint />
|
||||
|
||||
<PublicDashboardActions
|
||||
isPublic={isPublic}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
invalidateGetPublicDashboard,
|
||||
useCreatePublicDashboard,
|
||||
useDeletePublicDashboard,
|
||||
useGetPublicDashboard,
|
||||
useUpdatePublicDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
@@ -17,6 +16,8 @@ import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { usePublicDashboardMeta } from './usePublicDashboardMeta';
|
||||
|
||||
export interface UsePublicDashboardReturn {
|
||||
isPublic: boolean;
|
||||
isAdmin: boolean;
|
||||
@@ -54,22 +55,16 @@ export function usePublicDashboard(
|
||||
const [defaultTimeRange, setDefaultTimeRange] =
|
||||
useState<string>(DEFAULT_TIME_RANGE);
|
||||
|
||||
// Read the shared public-meta cache — the GET is owned globally (toolbar), so the
|
||||
// drawer reuses it rather than issuing its own request.
|
||||
const {
|
||||
data,
|
||||
publicMeta,
|
||||
isPublic,
|
||||
isLoading: isLoadingMeta,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
{ query: { enabled: !!dashboardId, retry: false } },
|
||||
);
|
||||
|
||||
// react-query retains the last successful `data` even after a refetch errors, so
|
||||
// after unpublishing (the refetch 404s) `data` still holds the old publicPath.
|
||||
// Gate on `!error` so the UI flips back to the private state.
|
||||
const publicMeta = error ? undefined : data?.data;
|
||||
const isPublic = !!publicMeta?.publicPath;
|
||||
} = usePublicDashboardMeta(dashboardId);
|
||||
|
||||
// Seed form state from the server config when published.
|
||||
useEffect(() => {
|
||||
@@ -103,7 +98,7 @@ export function usePublicDashboard(
|
||||
(message: string): void => {
|
||||
toast.success(message);
|
||||
void invalidateGetPublicDashboard(queryClient, { id: dashboardId });
|
||||
void refetch();
|
||||
refetch();
|
||||
},
|
||||
[queryClient, dashboardId, refetch],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetPublicDashboard } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettablePublicDasbhboardDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
export interface UsePublicDashboardMetaReturn {
|
||||
publicMeta: DashboardtypesGettablePublicDasbhboardDTO | undefined;
|
||||
isPublic: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: unknown;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// How long a fetched result stays fresh before a natural trigger may refresh it.
|
||||
const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Single source of truth for a dashboard's public-sharing meta. Keyed by dashboard
|
||||
* id via the generated query, so the GET happens once globally (the toolbar mounts it
|
||||
* with the dashboard) and every other caller — the publish settings drawer — reads the
|
||||
* same cache instead of issuing its own request. A mutation that invalidates
|
||||
* getGetPublicDashboardQueryKey refreshes all consumers at once.
|
||||
*
|
||||
* Only fetched on cloud / enterprise tenants, where public dashboards are available.
|
||||
*/
|
||||
export function usePublicDashboardMeta(
|
||||
dashboardId: string,
|
||||
): UsePublicDashboardMetaReturn {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
retry: false,
|
||||
// refetchOnMount: false stops opening the drawer / switching to the Publish
|
||||
// tab from refiring the GET — it reuses the toolbar's cached result. A finite
|
||||
// staleTime still lets it refresh naturally once the data ages, and mutations
|
||||
// invalidate the key to refresh the published state immediately.
|
||||
staleTime: PUBLIC_META_STALE_TIME,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// react-query retains the last successful `data` after a refetch errors (e.g. the
|
||||
// 404 once a dashboard is unpublished), so gate on the error to reflect the
|
||||
// private state.
|
||||
const publicMeta = error ? undefined : data?.data;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
publicMeta,
|
||||
isPublic: !!publicMeta?.publicPath,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
}),
|
||||
[publicMeta, isLoading, isFetching, error, refetch],
|
||||
);
|
||||
}
|
||||
@@ -69,19 +69,24 @@ function stripUndefinedLabels(
|
||||
export function toPostableRuleDTO(
|
||||
local: PostableAlertRuleV2,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload = {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
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;
|
||||
}
|
||||
@@ -89,7 +94,7 @@ export function toPostableRuleDTO(
|
||||
export function toPostableRuleDTOFromAlertDef(
|
||||
local: AlertDef,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload = {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
@@ -99,11 +104,16 @@ 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 as unknown as RuletypesPostableRuleDTO;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToPostableRuleV2(
|
||||
|
||||
@@ -51,6 +51,26 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/test", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.TestMappers),
|
||||
handler.OpenAPIDef{
|
||||
ID: "TestSpanMappers",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Test span mappers against sample spans",
|
||||
Description: "Tests how span mappers would transform sample spans",
|
||||
Request: new(spantypes.PostableSpanMapperTest),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableSpanMapperTest),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.UpdateGroup),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -304,7 +304,7 @@ func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
name: "daemonset and namespace group-by",
|
||||
labels: map[string]string{
|
||||
"k8s.daemonset.name": "web-1",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{daemonSetNameGroupByKey, namespaceNameGroupByKey},
|
||||
expected: "web-1\x00ns-x",
|
||||
@@ -330,6 +330,47 @@ func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
groupBy: []qbtypes.GroupByKey{deploymentNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "web-1\x00ns-x\x00",
|
||||
},
|
||||
{
|
||||
// volumes default group identity: (pvc, namespace, cluster).
|
||||
name: "pvc, namespace and cluster group-by",
|
||||
labels: map[string]string{
|
||||
"k8s.persistentvolumeclaim.name": "data-pg-0",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
"k8s.cluster.name": "cluster-a",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "data-pg-0\x00ns-x\x00cluster-a",
|
||||
},
|
||||
{
|
||||
// absent cluster label on a PVC -> empty trailing segment.
|
||||
name: "pvc missing cluster label yields empty trailing segment",
|
||||
labels: map[string]string{
|
||||
"k8s.persistentvolumeclaim.name": "data-pg-0",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "data-pg-0\x00ns-x\x00",
|
||||
},
|
||||
{
|
||||
// namespaces default group identity: (namespace, cluster) — namespaces are
|
||||
// cluster-scoped, so cluster is the only cross-cluster disambiguator.
|
||||
name: "namespace and cluster group-by",
|
||||
labels: map[string]string{
|
||||
"k8s.namespace.name": "ns-x",
|
||||
"k8s.cluster.name": "cluster-a",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "ns-x\x00cluster-a",
|
||||
},
|
||||
{
|
||||
// absent cluster label on a namespace -> empty trailing segment.
|
||||
name: "namespace missing cluster label yields empty trailing segment",
|
||||
labels: map[string]string{
|
||||
"k8s.namespace.name": "ns-x",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "ns-x\x00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -360,7 +360,7 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{namespaceNameGroupByKey}
|
||||
req.GroupBy = []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
@@ -535,7 +535,7 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{pvcNameGroupByKey}
|
||||
req.GroupBy = []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
|
||||
@@ -273,6 +273,35 @@ func (h *handler) DeleteMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// TestMappers handles POST /api/v1/span_mapper_groups/test.
|
||||
func (h *handler) TestMappers(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
req := new(spantypes.PostableSpanMapperTest)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groups := spantypes.NewSpanMapperGroupsWithMappersFromPostable(orgID, req.Groups)
|
||||
out, err := h.module.TestMappers(ctx, orgID, req.Spans, groups)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &spantypes.GettableSpanMapperTest{Spans: out})
|
||||
}
|
||||
|
||||
// groupIDFromPath extracts and validates the {id} or {groupId} path variable.
|
||||
func groupIDFromPath(r *http.Request) (valuer.UUID, error) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
@@ -102,6 +103,54 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, error) {
|
||||
if len(spans) == 0 {
|
||||
return nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
|
||||
}
|
||||
|
||||
_, err := module.backfillMappers(ctx, orgID, groups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// out, _, err := spantypes.SimulateSpanMappersProcessing(ctx, resolved, spans)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return out, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any group whose Mappers is nil.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
// Load all the saved groups for this org, so we can look up by name.
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, &spantypes.ListSpanMapperGroupsQuery{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return spantypes.SpanAttrMappingFeatureType
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type Module interface {
|
||||
CreateMapper(ctx context.Context, orgID, groupID valuer.UUID, mapper *spantypes.SpanMapper) error
|
||||
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, error)
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
|
||||
@@ -42,4 +43,5 @@ type Handler interface {
|
||||
CreateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
UpdateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
DeleteMapper(rw http.ResponseWriter, r *http.Request)
|
||||
TestMappers(rw http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
@@ -119,11 +119,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
queries := make(map[string]qbtypes.Query)
|
||||
steps := make(map[string]qbtypes.Step)
|
||||
|
||||
// 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)
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -240,13 +236,15 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
}
|
||||
}
|
||||
}
|
||||
if dormantMetricsWarningMsg != "" {
|
||||
if len(metricWarnings) > 0 {
|
||||
if qbResp.Warning == nil {
|
||||
qbResp.Warning = &qbtypes.QueryWarnData{}
|
||||
}
|
||||
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
|
||||
Message: dormantMetricsWarningMsg,
|
||||
})
|
||||
for _, w := range metricWarnings {
|
||||
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
|
||||
Message: w,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return qbResp, qbErr
|
||||
@@ -302,12 +300,11 @@ 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.
|
||||
// - 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) {
|
||||
// - 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) {
|
||||
metricNames := make([]string, 0)
|
||||
for idx := range queries {
|
||||
if queries[idx].Type != qbtypes.QueryTypeBuilder {
|
||||
@@ -325,13 +322,13 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
|
||||
if len(metricNames) == 0 {
|
||||
return nil, "", nil
|
||||
return nil, 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, "", errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
|
||||
return nil, 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))
|
||||
|
||||
@@ -363,7 +360,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, "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
presentAggregations = append(presentAggregations, spec.Aggregations[i])
|
||||
}
|
||||
@@ -376,7 +373,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
|
||||
if len(missingMetrics) == 0 {
|
||||
return missingMetricQueries, "", nil
|
||||
return missingMetricQueries, nil, nil
|
||||
}
|
||||
|
||||
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
|
||||
@@ -387,29 +384,33 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
}
|
||||
if len(externalMissingMetrics) == 0 {
|
||||
// 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
|
||||
return missingMetricQueries, nil, nil
|
||||
}
|
||||
|
||||
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
|
||||
// data-in-window → dormant warning.
|
||||
// Classify each missing metric: never-seen -> warning with empty result;
|
||||
// seen-but-no-data-in-window -> dormant warning.
|
||||
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, externalMissingMetrics...)
|
||||
nonExistentMetrics := []string{}
|
||||
var nonExistentMetrics []string
|
||||
var dormantMetrics []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 {
|
||||
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, ", "))
|
||||
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, ", ")))
|
||||
}
|
||||
|
||||
// All missing metrics are dormant — assemble the warning string.
|
||||
// Dormant metrics: seen before but no data in the query window.
|
||||
lastSeenStr := func(name string) string {
|
||||
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
|
||||
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
|
||||
@@ -417,16 +418,16 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
return name
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
parts[i] = lastSeenStr(m)
|
||||
}
|
||||
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
|
||||
warnings = append(warnings, fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", ")))
|
||||
}
|
||||
return missingMetricQueries, dormantWarning, nil
|
||||
return missingMetricQueries, warnings, nil
|
||||
}
|
||||
|
||||
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
|
||||
|
||||
@@ -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 a not-found error, even if the request provides a temporality
|
||||
// the querier should return an empty result with a warning instead of an error.
|
||||
providerSettings := instrumentationtest.New().ToProviderSettings()
|
||||
metadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
|
||||
@@ -80,9 +80,14 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not find the metric unknown_metric")
|
||||
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")
|
||||
}
|
||||
|
||||
func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
|
||||
@@ -101,9 +101,29 @@ 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)
|
||||
|
||||
return b.buildPipelineStatement(ctx, start, end, query, keys, variables)
|
||||
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
|
||||
}
|
||||
|
||||
func (b *MetricQueryStatementBuilder) buildPipelineStatement(
|
||||
|
||||
@@ -217,6 +217,39 @@ 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()
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type migrateCommon struct {
|
||||
@@ -24,10 +23,119 @@ func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapInV5Envelope delegates to querybuildertypesv5.WrapInV5Envelope; the
|
||||
// transform is stateless and shared with the v1→v2 dashboard conversion.
|
||||
func (migration *migrateCommon) WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
|
||||
return querybuildertypesv5.WrapInV5Envelope(name, queryMap, queryType)
|
||||
// Create a properly structured v5 query
|
||||
v5Query := map[string]any{
|
||||
"name": name,
|
||||
"disabled": queryMap["disabled"],
|
||||
"legend": queryMap["legend"],
|
||||
}
|
||||
|
||||
if name != queryMap["expression"] {
|
||||
// formula
|
||||
queryType = "builder_formula"
|
||||
v5Query["expression"] = queryMap["expression"]
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
// Add signal based on data source
|
||||
if dataSource, ok := queryMap["dataSource"].(string); ok {
|
||||
switch dataSource {
|
||||
case "traces":
|
||||
v5Query["signal"] = "traces"
|
||||
case "logs":
|
||||
v5Query["signal"] = "logs"
|
||||
case "metrics":
|
||||
v5Query["signal"] = "metrics"
|
||||
}
|
||||
}
|
||||
|
||||
if stepInterval, ok := queryMap["stepInterval"]; ok {
|
||||
v5Query["stepInterval"] = stepInterval
|
||||
}
|
||||
|
||||
if aggregations, ok := queryMap["aggregations"]; ok {
|
||||
v5Query["aggregations"] = aggregations
|
||||
}
|
||||
|
||||
if filter, ok := queryMap["filter"]; ok {
|
||||
v5Query["filter"] = filter
|
||||
}
|
||||
|
||||
// Copy groupBy with proper structure
|
||||
if groupBy, ok := queryMap["groupBy"].([]any); ok {
|
||||
v5GroupBy := make([]any, len(groupBy))
|
||||
for i, gb := range groupBy {
|
||||
if gbMap, ok := gb.(map[string]any); ok {
|
||||
v5GroupBy[i] = map[string]any{
|
||||
"name": gbMap["key"],
|
||||
"fieldDataType": gbMap["dataType"],
|
||||
"fieldContext": gbMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["groupBy"] = v5GroupBy
|
||||
}
|
||||
|
||||
// Copy orderBy with proper structure
|
||||
if orderBy, ok := queryMap["orderBy"].([]any); ok {
|
||||
v5OrderBy := make([]any, len(orderBy))
|
||||
for i, ob := range orderBy {
|
||||
if obMap, ok := ob.(map[string]any); ok {
|
||||
v5OrderBy[i] = map[string]any{
|
||||
"key": map[string]any{
|
||||
"name": obMap["columnName"],
|
||||
"fieldDataType": obMap["dataType"],
|
||||
"fieldContext": obMap["type"],
|
||||
},
|
||||
"direction": obMap["order"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["order"] = v5OrderBy
|
||||
}
|
||||
|
||||
// Copy selectColumns as selectFields
|
||||
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
|
||||
v5SelectFields := make([]any, len(selectColumns))
|
||||
for i, col := range selectColumns {
|
||||
if colMap, ok := col.(map[string]any); ok {
|
||||
v5SelectFields[i] = map[string]any{
|
||||
"name": colMap["key"],
|
||||
"fieldDataType": colMap["dataType"],
|
||||
"fieldContext": colMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["selectFields"] = v5SelectFields
|
||||
}
|
||||
|
||||
// Copy limit and offset
|
||||
if limit, ok := queryMap["limit"]; ok {
|
||||
v5Query["limit"] = limit
|
||||
}
|
||||
if offset, ok := queryMap["offset"]; ok {
|
||||
v5Query["offset"] = offset
|
||||
}
|
||||
|
||||
if having, ok := queryMap["having"]; ok {
|
||||
v5Query["having"] = having
|
||||
}
|
||||
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *migrateCommon) updateQueryData(ctx context.Context, queryData map[string]any, version, widgetType string) bool {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -405,26 +406,27 @@ func (dashboard *Dashboard) GetWidgetQuery(startTime, endTime, widgetIndex uint6
|
||||
widgetData := data.Widgets[widgetIndex]
|
||||
switch widgetData.Query.QueryType {
|
||||
case "builder":
|
||||
migrate := transition.NewMigrateCommon(logger)
|
||||
for _, query := range widgetData.Query.Builder.QueryData {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_query"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryFormulas {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_formula"))
|
||||
}
|
||||
for _, query := range widgetData.Query.Builder.QueryTraceOperator {
|
||||
queryName, ok := query["queryName"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "cannot type cast query name as string")
|
||||
}
|
||||
compositeQueries = append(compositeQueries, querybuildertypesv5.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
compositeQueries = append(compositeQueries, migrate.WrapInV5Envelope(queryName, query, "builder_trace_operator"))
|
||||
}
|
||||
case "clickhouse_sql":
|
||||
for _, query := range widgetData.Query.ClickhouseSQL {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
|
||||
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
|
||||
// DashboardSpec.
|
||||
//
|
||||
// Assumes the v1 widget query data has already been migrated to v5 shape
|
||||
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
|
||||
// invalid v2 envelopes — run the v4→v5 migration first.
|
||||
//
|
||||
// The conversion is split across sibling files by concern:
|
||||
// - perses_v1_to_v2_tags.go tags
|
||||
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
|
||||
// - perses_v1_to_v2_queries.go widget queries
|
||||
// - perses_v1_to_v2_layouts.go grid layouts and sections
|
||||
// - perses_v1_to_v2_variables.go variables
|
||||
// - perses_v1_to_v2_helpers.go generic map/slice accessors
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Entry point
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (storable StorableDashboard) IsV2() bool {
|
||||
metadata, _ := storable.Data["metadata"].(map[string]any)
|
||||
if metadata == nil {
|
||||
return false
|
||||
}
|
||||
version, _ := metadata["schemaVersion"].(string)
|
||||
return version == SchemaVersion
|
||||
}
|
||||
|
||||
func (storable StorableDashboard) ConvertV1ToV2() (*DashboardV2, error) {
|
||||
if storable.IsV2() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
|
||||
}
|
||||
|
||||
image, _ := storable.Data["image"].(string)
|
||||
title, _ := storable.Data["title"].(string)
|
||||
description, _ := storable.Data["description"].(string)
|
||||
|
||||
spec := DashboardSpec{
|
||||
Display: Display{Name: title, Description: description},
|
||||
Variables: convertV1Variables(storable.Data["variables"]),
|
||||
Panels: convertV1Panels(storable.Data["widgets"]),
|
||||
Layouts: convertV1Layouts(storable.Data),
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
Identifiable: storable.Identifiable,
|
||||
TimeAuditable: storable.TimeAuditable,
|
||||
UserAuditable: storable.UserAuditable,
|
||||
OrgID: storable.OrgID,
|
||||
Locked: storable.Locked,
|
||||
Source: storable.Source,
|
||||
DashboardV2MetadataBase: DashboardV2MetadataBase{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Image: image,
|
||||
},
|
||||
Name: generateDashboardName(title),
|
||||
Tags: convertV1TagsForOrg(storable.OrgID, storable.Data["tags"]),
|
||||
Spec: spec,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Generic helpers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// ptrValueAt is the pointer-returning sibling of valueAt: it returns *T so the
|
||||
// caller can tell "absent / wrong type" (nil) apart from a present zero value.
|
||||
// Used for optional fields like soft axis bounds and histogram bucket sizing.
|
||||
func ptrValueAt[T any](raw any, key string) *T {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
v, ok := m[key].(T)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func readStringMap(raw any) map[string]string {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok || len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readSliceOfMaps(raw any) []map[string]any {
|
||||
rawSlice, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rawSlice))
|
||||
for _, item := range rawSlice {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// valueAt reads key from raw (when raw is a map[string]any) and returns its
|
||||
// value as T, or the zero value of T if raw isn't a map, the key is absent, or
|
||||
// the stored value isn't a T. Used to pull typed fields out of the untyped v1
|
||||
// dashboard blob.
|
||||
func valueAt[T any](raw any, key string) T {
|
||||
var zero T
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return zero
|
||||
}
|
||||
v, _ := m[key].(T)
|
||||
return v
|
||||
}
|
||||
|
||||
// intAt is a thin wrapper over valueAt: JSON decodes numbers as float64, so an
|
||||
// integer field must be read as float64 and narrowed.
|
||||
func intAt(raw any, key string) int {
|
||||
return int(valueAt[float64](raw, key))
|
||||
}
|
||||
|
||||
// decodeMapInto converts an untyped map[string]any into a typed T by
|
||||
// round-tripping through JSON, letting encoding/json (struct tags, custom
|
||||
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
|
||||
func decodeMapInto[T any](src map[string]any) (T, error) {
|
||||
var dst T
|
||||
bytes, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return dst, err
|
||||
}
|
||||
if err := json.Unmarshal(bytes, &dst); err != nil {
|
||||
return dst, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Layouts (data.layout + data.panelMap)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Layouts groups v1 react-grid-layout entries into v2 grid layouts.
|
||||
// Membership is positional (as the frontend renders): each row widget owns the
|
||||
// panels below it until the next row; panels above the first row form an unnamed
|
||||
// grid with no section header. Collapsed rows are the exception — their children
|
||||
// live in panelMap[rowID].widgets, not `layout`.
|
||||
func convertV1Layouts(data StorableDashboardData) []Layout {
|
||||
layout := readSliceOfMaps(data["layout"])
|
||||
if len(layout) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := extractRowsAndCollapsedWidgets(data["widgets"], data["panelMap"])
|
||||
|
||||
// Skip collapsed-row children a malformed dashboard lists in `layout` too.
|
||||
isWidgetCollapsed := make(map[string]bool)
|
||||
for _, row := range rows {
|
||||
for _, child := range row.collapsedWidgets {
|
||||
if id, _ := child["i"].(string); id != "" {
|
||||
isWidgetCollapsed[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this lets us track the current row under which widgets are to be added.
|
||||
sortByPosition(layout)
|
||||
|
||||
type section struct {
|
||||
row *rowInfo // nil for the unnamed grid of ungrouped panels
|
||||
items []map[string]any
|
||||
}
|
||||
topSectionWithoutHeader := §ion{}
|
||||
sectionsWithHeader := make([]*section, 0, len(rows))
|
||||
currentRowHeader := topSectionWithoutHeader
|
||||
for _, item := range layout {
|
||||
id, _ := item["i"].(string)
|
||||
if id == "" || isWidgetCollapsed[id] {
|
||||
continue
|
||||
}
|
||||
if row, ok := rows[id]; ok {
|
||||
newRowHeader := §ion{row: row, items: row.collapsedWidgets}
|
||||
sectionsWithHeader = append(sectionsWithHeader, newRowHeader)
|
||||
// A collapsed row owns only its stashed children; later panels → ungrouped.
|
||||
if row.collapsed {
|
||||
currentRowHeader = topSectionWithoutHeader
|
||||
} else {
|
||||
currentRowHeader = newRowHeader
|
||||
}
|
||||
continue
|
||||
}
|
||||
currentRowHeader.items = append(currentRowHeader.items, item)
|
||||
}
|
||||
|
||||
out := make([]Layout, 0, len(sectionsWithHeader)+1)
|
||||
if len(topSectionWithoutHeader.items) > 0 {
|
||||
out = append(out, buildV2GridLayout(nil, topSectionWithoutHeader.items))
|
||||
}
|
||||
for _, sec := range sectionsWithHeader {
|
||||
out = append(out, buildV2GridLayout(sec.row, sec.items))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type rowInfo struct {
|
||||
title string
|
||||
collapsed bool
|
||||
collapsedWidgets []map[string]any
|
||||
}
|
||||
|
||||
// extractRowsAndCollapsedWidgets returns the row widgets keyed by id; collapsed rows also carry their
|
||||
// children stashed under panelMap[id].widgets.
|
||||
func extractRowsAndCollapsedWidgets(widgetsRaw, panelMapRaw any) map[string]*rowInfo {
|
||||
panelMap, _ := panelMapRaw.(map[string]any)
|
||||
rows := make(map[string]*rowInfo)
|
||||
for _, w := range readSliceOfMaps(widgetsRaw) {
|
||||
id := valueAt[string](w, "id")
|
||||
if valueAt[string](w, "panelTypes") != "row" || id == "" {
|
||||
continue
|
||||
}
|
||||
row := &rowInfo{title: valueAt[string](w, "title")}
|
||||
if pm, ok := panelMap[id].(map[string]any); ok && valueAt[bool](pm, "collapsed") {
|
||||
row.collapsed = true
|
||||
row.collapsedWidgets = readSliceOfMaps(pm["widgets"])
|
||||
}
|
||||
rows[id] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildV2GridLayout builds one v2 grid. row is nil for the unnamed grid (no display);
|
||||
// otherwise the grid takes the row's title and collapse state. Items are sorted
|
||||
// by (y, x) and their y's normalized so the topmost sits at 0.
|
||||
func buildV2GridLayout(row *rowInfo, items []map[string]any) Layout {
|
||||
sortByPosition(items)
|
||||
|
||||
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
|
||||
if row != nil {
|
||||
spec.Display = &dashboard.GridLayoutDisplay{
|
||||
Title: row.title,
|
||||
Collapse: &dashboard.GridLayoutCollapse{Open: !row.collapsed},
|
||||
}
|
||||
}
|
||||
|
||||
minY := 0
|
||||
if len(items) > 0 {
|
||||
minY = intAt(items[0], "y") // sorted by y, so the first item is topmost
|
||||
}
|
||||
for _, item := range items {
|
||||
id, _ := item["i"].(string)
|
||||
spec.Items = append(spec.Items, dashboard.GridItem{
|
||||
X: intAt(item, "x"),
|
||||
Y: intAt(item, "y") - minY,
|
||||
Width: intAt(item, "w"),
|
||||
Height: intAt(item, "h"),
|
||||
Content: &common.JSONRef{Ref: fmt.Sprintf("#/spec/panels/%s", id)},
|
||||
})
|
||||
}
|
||||
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
|
||||
}
|
||||
|
||||
func sortByPosition(items []map[string]any) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if yi, yj := intAt(items[i], "y"), intAt(items[j], "y"); yi != yj {
|
||||
return yi < yj
|
||||
}
|
||||
return intAt(items[i], "x") < intAt(items[j], "x")
|
||||
})
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Widgets → Panels
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
|
||||
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
|
||||
// and consumed by convertV1Layouts as section headers.
|
||||
func convertV1Panels(raw any) map[string]*Panel {
|
||||
rawSlice, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
panels := make(map[string]*Panel, len(rawSlice))
|
||||
for _, item := range rawSlice {
|
||||
widget, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := widget["id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
panelType, _ := widget["panelTypes"].(string)
|
||||
var panel *Panel
|
||||
switch panelType {
|
||||
case "graph":
|
||||
panel = convertGraphWidget(widget)
|
||||
case "bar":
|
||||
panel = convertBarWidget(widget)
|
||||
case "value":
|
||||
panel = convertValueWidget(widget)
|
||||
case "pie":
|
||||
panel = convertPieWidget(widget)
|
||||
case "table":
|
||||
panel = convertTableWidget(widget)
|
||||
case "histogram":
|
||||
panel = convertHistogramWidget(widget)
|
||||
case "list":
|
||||
panel = convertListWidget(widget)
|
||||
default:
|
||||
// "row" (section header) is handled by the layout pass; unknown kinds skipped.
|
||||
continue
|
||||
}
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
panels[id] = panel
|
||||
}
|
||||
return panels
|
||||
}
|
||||
|
||||
func convertGraphWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTimeSeries,
|
||||
Spec: &TimeSeriesPanelSpec{
|
||||
Visualization: TimeSeriesVisualization{
|
||||
BasicVisualization: basicVisualization(w),
|
||||
FillSpans: valueAt[bool](w, "fillSpans"),
|
||||
},
|
||||
Formatting: panelFormatting(w),
|
||||
ChartAppearance: TimeSeriesChartAppearance{
|
||||
LineInterpolation: mapV1Enum(w["lineInterpolation"], LineInterpolationSpline,
|
||||
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
|
||||
ShowPoints: valueAt[bool](w, "showPoints"),
|
||||
LineStyle: mapV1Enum(w["lineStyle"], LineStyleSolid, LineStyleSolid, LineStyleDashed),
|
||||
FillMode: mapV1Enum(w["fillMode"], FillModeSolid, FillModeSolid, FillModeGradient, FillModeNone),
|
||||
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
|
||||
},
|
||||
Axes: axesFromWidget(w),
|
||||
Legend: legendFromWidget(w),
|
||||
Thresholds: mapV1ThresholdsWithLabel(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindTimeSeries),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertBarWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindBarChart,
|
||||
Spec: &BarChartPanelSpec{
|
||||
Visualization: BarChartVisualization{
|
||||
BasicVisualization: basicVisualization(w),
|
||||
FillSpans: valueAt[bool](w, "fillSpans"),
|
||||
StackedBarChart: valueAt[bool](w, "stackedBarChart"),
|
||||
},
|
||||
Formatting: panelFormatting(w),
|
||||
Axes: axesFromWidget(w),
|
||||
Legend: legendFromWidget(w),
|
||||
Thresholds: mapV1ThresholdsWithLabel(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindBarChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertValueWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindNumber,
|
||||
Spec: &NumberPanelSpec{
|
||||
Visualization: basicVisualization(w),
|
||||
Formatting: panelFormatting(w),
|
||||
Thresholds: mapV1ComparisonThresholds(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindNumber),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertPieWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindPieChart,
|
||||
Spec: &PieChartPanelSpec{
|
||||
Visualization: basicVisualization(w),
|
||||
Formatting: panelFormatting(w),
|
||||
Legend: legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindPieChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertTableWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTable,
|
||||
Spec: &TablePanelSpec{
|
||||
Visualization: basicVisualization(w),
|
||||
Formatting: TableFormatting{
|
||||
ColumnUnits: readStringMap(w["columnUnits"]),
|
||||
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
|
||||
},
|
||||
Thresholds: mapV1TableThresholds(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindTable),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertHistogramWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindHistogram,
|
||||
Spec: &HistogramPanelSpec{
|
||||
HistogramBuckets: HistogramBuckets{
|
||||
BucketCount: ptrValueAt[float64](w, "bucketCount"),
|
||||
BucketWidth: ptrValueAt[float64](w, "bucketWidth"),
|
||||
MergeAllActiveQueries: valueAt[bool](w, "mergeAllActiveQueries"),
|
||||
},
|
||||
Legend: legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindHistogram),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertListWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindList,
|
||||
Spec: &ListPanelSpec{
|
||||
SelectFields: mapV1SelectFields(w),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindList),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel-spec shared helpers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func widgetDisplay(w map[string]any) Display {
|
||||
title, _ := w["title"].(string)
|
||||
description, _ := w["description"].(string)
|
||||
return Display{Name: title, Description: description}
|
||||
}
|
||||
|
||||
func basicVisualization(w map[string]any) BasicVisualization {
|
||||
return BasicVisualization{TimePreference: mapV1TimePreference(w["timePreferance"])}
|
||||
}
|
||||
|
||||
func panelFormatting(w map[string]any) PanelFormatting {
|
||||
unit, _ := w["yAxisUnit"].(string)
|
||||
return PanelFormatting{Unit: unit, DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
|
||||
}
|
||||
|
||||
func axesFromWidget(w map[string]any) Axes {
|
||||
return Axes{
|
||||
SoftMin: ptrValueAt[float64](w, "softMin"),
|
||||
SoftMax: ptrValueAt[float64](w, "softMax"),
|
||||
IsLogScale: valueAt[bool](w, "isLogScale"),
|
||||
}
|
||||
}
|
||||
|
||||
func legendFromWidget(w map[string]any) Legend {
|
||||
return Legend{
|
||||
Position: mapV1Enum(w["legendPosition"], LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
|
||||
CustomColors: readStringMap(w["customLegendColors"]),
|
||||
}
|
||||
}
|
||||
|
||||
func mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
|
||||
if raw, ok := w["selectedLogFields"].([]any); ok && len(raw) > 0 {
|
||||
return decodeTelemetryFields(raw)
|
||||
}
|
||||
if raw, ok := w["selectedTracesFields"].([]any); ok && len(raw) > 0 {
|
||||
return decodeTelemetryFields(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTelemetryFields(raw []any) []telemetrytypes.TelemetryFieldKey {
|
||||
bytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var fields []telemetrytypes.TelemetryFieldKey
|
||||
if err := json.Unmarshal(bytes, &fields); err != nil {
|
||||
return nil
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel field mappers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
|
||||
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
|
||||
// lowercase form, so the translation is just downcase.
|
||||
func mapV1TimePreference(raw any) TimePreference {
|
||||
s, ok := raw.(string)
|
||||
if !ok || s == "" {
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
|
||||
func mapV1Precision(raw any) PrecisionOption {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
candidate := PrecisionOption{valuer.NewString(v)}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
n := int(v)
|
||||
if n >= 0 && n <= 4 {
|
||||
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
|
||||
}
|
||||
}
|
||||
return PrecisionOption2
|
||||
}
|
||||
|
||||
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
|
||||
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
|
||||
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
|
||||
func mapV1Enum[T interface{ StringValue() string }](raw any, fallback T, allowed ...T) T {
|
||||
s, ok := raw.(string)
|
||||
if !ok || s == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a.StringValue() == s {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
|
||||
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
|
||||
// X-axis threshold in seconds.
|
||||
func mapV1SpanGaps(raw any) SpanGaps {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
if v {
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: true}
|
||||
case float64:
|
||||
dur, err := valuer.ParseTextDuration(time.Duration(v * float64(time.Second)).String())
|
||||
if err != nil {
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: true, FillLessThan: dur}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
|
||||
func mapV1ThresholdsWithLabel(raw any) []ThresholdWithLabel {
|
||||
rawSlice := readSliceOfMaps(raw)
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ThresholdWithLabel, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color, _ := t["thresholdColor"].(string)
|
||||
label, _ := t["thresholdLabel"].(string)
|
||||
if color == "" || label == "" {
|
||||
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
|
||||
continue
|
||||
}
|
||||
value, _ := t["thresholdValue"].(float64)
|
||||
unit, _ := t["thresholdUnit"].(string)
|
||||
out = append(out, ThresholdWithLabel{Value: value, Unit: unit, Color: color, Label: label})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapV1ComparisonThresholds(raw any) []ComparisonThreshold {
|
||||
rawSlice := readSliceOfMaps(raw)
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ComparisonThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color, _ := t["thresholdColor"].(string)
|
||||
if color == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ComparisonThreshold{
|
||||
Value: valueAt[float64](t, "thresholdValue"),
|
||||
Operator: mapV1ComparisonOperator(t["thresholdOperator"]),
|
||||
Unit: valueAt[string](t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapV1TableThresholds(raw any) []TableThreshold {
|
||||
rawSlice := readSliceOfMaps(raw)
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]TableThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color, _ := t["thresholdColor"].(string)
|
||||
columnName, _ := t["thresholdTableOptions"].(string)
|
||||
if color == "" || columnName == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, TableThreshold{
|
||||
ComparisonThreshold: ComparisonThreshold{
|
||||
Value: valueAt[float64](t, "thresholdValue"),
|
||||
Operator: mapV1ComparisonOperator(t["thresholdOperator"]),
|
||||
Unit: valueAt[string](t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
|
||||
},
|
||||
ColumnName: columnName,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapV1ComparisonOperator(raw any) ComparisonOperator {
|
||||
s, _ := raw.(string)
|
||||
switch s {
|
||||
case ">":
|
||||
return ComparisonOperatorAbove
|
||||
case ">=":
|
||||
return ComparisonOperatorAboveOrEqual
|
||||
case "<":
|
||||
return ComparisonOperatorBelow
|
||||
case "<=":
|
||||
return ComparisonOperatorBelowOrEqual
|
||||
case "=":
|
||||
return ComparisonOperatorEqual
|
||||
case "!=":
|
||||
return ComparisonOperatorNotEqual
|
||||
}
|
||||
return ComparisonOperatorAbove
|
||||
}
|
||||
|
||||
func mapV1ThresholdFormat(raw any) ThresholdFormat {
|
||||
s, _ := raw.(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "background":
|
||||
return ThresholdFormatBackground
|
||||
case "text":
|
||||
return ThresholdFormatText
|
||||
}
|
||||
return ThresholdFormatText
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Queries
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The
|
||||
// kind chosen depends on the v1 widget query shape:
|
||||
// - single promql → signoz/PromQLQuery
|
||||
// - single clickhouse_sql → signoz/ClickHouseSQL
|
||||
// - exactly one builder query → signoz/BuilderQuery (PanelKindList only)
|
||||
// - everything else → signoz/CompositeQuery wrapping all envelopes
|
||||
//
|
||||
// Builder queries are routed through qb.WrapInV5Envelope, which translates v4
|
||||
// builder-field names (orderBy/selectColumns/dataSource) into their v5
|
||||
// equivalents and adds the `signal` field required by BuilderQuerySpec's
|
||||
// per-signal dispatch.
|
||||
func convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
|
||||
envelopes, signal := collectV1QueryEnvelopes(widget)
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
requestType := requestTypeForPanel(panelKind)
|
||||
|
||||
// List panels must use signoz/BuilderQuery (the only kind in
|
||||
// allowedQueryKinds[PanelKindList]).
|
||||
if panelKind == PanelKindList {
|
||||
first := envelopes[0]
|
||||
if t, _ := first["type"].(string); t == string(qb.QueryTypeBuilder.StringValue()) {
|
||||
spec := parseBuilderQuerySpec(first["spec"], signal)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
return []Query{{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: valueAt[string](first["spec"], "name"),
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: spec},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// Single non-builder query → use its native kind directly. Cleaner JSON
|
||||
// than wrapping in CompositeQuery for the common single-query case.
|
||||
if len(envelopes) == 1 {
|
||||
if q := singleQueryFromEnvelope(envelopes[0], requestType); q != nil {
|
||||
return []Query{*q}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: wrap in CompositeQuery.
|
||||
composite, err := parseCompositeFromEnvelopes(envelopes)
|
||||
if err != nil || composite == nil {
|
||||
return nil
|
||||
}
|
||||
return []Query{{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
|
||||
// shape) its queries produce. Mirrors the shape each visualization consumes:
|
||||
// time series for line/bar, scalar for number/pie/table, distribution for
|
||||
// histogram, raw rows for list.
|
||||
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
|
||||
switch panelKind {
|
||||
case PanelKindTimeSeries, PanelKindBarChart:
|
||||
return qb.RequestTypeTimeSeries
|
||||
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
|
||||
return qb.RequestTypeScalar
|
||||
case PanelKindHistogram:
|
||||
return qb.RequestTypeDistribution
|
||||
case PanelKindList:
|
||||
return qb.RequestTypeRaw
|
||||
}
|
||||
return qb.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
|
||||
// flattened list of v5-shaped envelopes. The returned signal is the dominant
|
||||
// builder signal (if any), used for typed builder-query dispatch.
|
||||
func collectV1QueryEnvelopes(widget map[string]any) ([]map[string]any, telemetrytypes.Signal) {
|
||||
queryMap, ok := widget["query"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
queryType, _ := queryMap["queryType"].(string)
|
||||
|
||||
switch queryType {
|
||||
case "promql":
|
||||
var out []map[string]any
|
||||
for _, q := range readSliceOfMaps(queryMap["promql"]) {
|
||||
out = append(out, promQLEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
case "clickhouse_sql":
|
||||
var out []map[string]any
|
||||
for _, q := range readSliceOfMaps(queryMap["clickhouse_sql"]) {
|
||||
out = append(out, clickhouseEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
case "builder":
|
||||
builder, _ := queryMap["builder"].(map[string]any)
|
||||
if builder == nil {
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
var out []map[string]any
|
||||
var signal telemetrytypes.Signal
|
||||
for _, q := range readSliceOfMaps(builder["queryData"]) {
|
||||
name := valueAt[string](q, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
|
||||
if signal.IsZero() {
|
||||
signal = signalFromDataSource(q["dataSource"])
|
||||
}
|
||||
}
|
||||
for _, f := range readSliceOfMaps(builder["queryFormulas"]) {
|
||||
name := valueAt[string](f, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue())))
|
||||
}
|
||||
for _, op := range readSliceOfMaps(builder["queryTraceOperator"]) {
|
||||
name := valueAt[string](op, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, op, string(qb.QueryTypeTraceOperator.StringValue())))
|
||||
}
|
||||
return out, signal
|
||||
}
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
|
||||
func promQLEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypePromQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clickhouseEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypeClickHouseSQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// singleQueryFromEnvelope returns a typed Query for an envelope whose type is
|
||||
// promql/clickhouse_sql. Builder envelopes always fall through to Composite so
|
||||
// composite-only panel kinds (TimeSeries/BarChart/etc.) get uniform queries.
|
||||
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType) *Query {
|
||||
t, _ := envelope["type"].(string)
|
||||
spec, _ := envelope["spec"].(map[string]any)
|
||||
switch t {
|
||||
case qb.QueryTypePromQL.StringValue():
|
||||
prom, err := decodeMapInto[qb.PromQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: prom.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
|
||||
},
|
||||
}
|
||||
case qb.QueryTypeClickHouseSQL.StringValue():
|
||||
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: ch.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
|
||||
bytes, err := json.Marshal(envelopes)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
|
||||
}
|
||||
var parsed []qb.QueryEnvelope
|
||||
if err := json.Unmarshal(bytes, &parsed); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
|
||||
}
|
||||
return &CompositeQuerySpec{Queries: parsed}, nil
|
||||
}
|
||||
|
||||
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
|
||||
spec, ok := rawSpec.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !signal.IsZero() {
|
||||
spec["signal"] = signal.StringValue()
|
||||
}
|
||||
bytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// signalFromDataSource maps a v1 data-source string to a v5 signal. Casing
|
||||
// varies by source: builder queries store lowercase ("traces"), while variable
|
||||
// `dynamicVariablesSource` stores capitalized ("Traces"), so match
|
||||
// case-insensitively. Unknown values (e.g. "All telemetry") map to the zero
|
||||
// Signal.
|
||||
func signalFromDataSource(raw any) telemetrytypes.Signal {
|
||||
s, _ := raw.(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "traces":
|
||||
return telemetrytypes.SignalTraces
|
||||
case "logs":
|
||||
return telemetrytypes.SignalLogs
|
||||
case "metrics":
|
||||
return telemetrytypes.SignalMetrics
|
||||
}
|
||||
return telemetrytypes.Signal{}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Tags
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
|
||||
// string is normalized into a pair (separator split, empty-side fallback,
|
||||
// reserved-key prefix, `/` scrub). Tags that normalize to the same
|
||||
// (lower(key), lower(value)) within a dashboard are collapsed, first occurrence
|
||||
// winning the display casing.
|
||||
//
|
||||
// Characters still illegal after normalization (spaces, punctuation) are molded
|
||||
// to fit the tag validators: disallowed runs collapse to "_" (see moldTagField).
|
||||
|
||||
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
|
||||
// separator (or one side of the split is empty).
|
||||
const defaultV1TagKey = "tag"
|
||||
|
||||
func convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
|
||||
rawSlice, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(rawSlice))
|
||||
out := make([]*tagtypes.Tag, 0, len(rawSlice))
|
||||
for _, item := range rawSlice {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, value, ok := normalizeV1Tag(s)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
|
||||
if _, dup := seen[dedupKey]; dup {
|
||||
continue
|
||||
}
|
||||
seen[dedupKey] = struct{}{}
|
||||
out = append(out, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeV1Tag derives a (key, value) pair from one v1 tag string. After
|
||||
// splitting and molding both sides, a lone survivor becomes a value under the
|
||||
// default key; ok is false if neither survives.
|
||||
func normalizeV1Tag(s string) (string, string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var rawKey, rawValue string
|
||||
switch {
|
||||
case strings.Contains(s, ":"):
|
||||
rawKey, rawValue, _ = strings.Cut(s, ":")
|
||||
// Only the first ":" separates key from value; collapse the rest.
|
||||
rawValue = strings.ReplaceAll(rawValue, ":", "_")
|
||||
case strings.Contains(s, "/"):
|
||||
rawKey, rawValue, _ = strings.Cut(s, "/")
|
||||
default:
|
||||
rawValue = s
|
||||
}
|
||||
rawKey = strings.TrimSpace(rawKey)
|
||||
rawValue = strings.TrimSpace(rawValue)
|
||||
|
||||
// Reserved-key collision: prefix "_" so the list-query DSL stays unambiguous.
|
||||
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(rawKey))]; rawKey != "" && reserved {
|
||||
rawKey = "_" + rawKey
|
||||
}
|
||||
|
||||
key := moldTagField(rawKey, tagKeyDisallowed, tagKeyNotLead, tagtypes.MAX_LEN_TAG_KEY)
|
||||
value := moldTagField(rawValue, tagValueDisallowed, nil, tagtypes.MAX_LEN_TAG_VALUE)
|
||||
switch {
|
||||
case key == "" && value == "":
|
||||
return "", "", false
|
||||
case key == "":
|
||||
return defaultV1TagKey, value, true
|
||||
case value == "":
|
||||
return defaultV1TagKey, key, true
|
||||
default:
|
||||
return key, value, true
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of tagKeyRegex/tagValueRegex ("/" always rejected); tagKeyNotLead
|
||||
// matches a bad first char for a key. TestMoldedV1TagsPassValidation guards drift.
|
||||
var (
|
||||
tagKeyDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:-]+`)
|
||||
tagValueDisallowed = regexp.MustCompile(`[^a-zA-Z0-9$_@#{}:.+=-]+`)
|
||||
tagKeyNotLead = regexp.MustCompile(`^[^a-zA-Z$_@{#]`)
|
||||
)
|
||||
|
||||
// moldTagField collapses disallowed runs to "_", prefixes "_" if notLead hits
|
||||
// the first char, and caps at max. Keeps a leading "_", trims a trailing one.
|
||||
func moldTagField(s string, disallowed, notLead *regexp.Regexp, max int) string {
|
||||
s = strings.TrimRight(disallowed.ReplaceAllString(s, "_"), "_")
|
||||
if s != "" && notLead != nil && notLead.MatchString(s) {
|
||||
s = "_" + s
|
||||
}
|
||||
if len(s) > max {
|
||||
s = strings.TrimRight(s[:max], "_")
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,873 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertV1TagsForOrg(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
type kv struct{ key, value string }
|
||||
|
||||
cases := []struct {
|
||||
scenario string
|
||||
rawTags any
|
||||
expectedTags []kv
|
||||
}{
|
||||
{
|
||||
scenario: "no separator uses the default key",
|
||||
rawTags: []any{"apm", "latency", "throughput"},
|
||||
expectedTags: []kv{{"tag", "apm"}, {"tag", "latency"}, {"tag", "throughput"}},
|
||||
},
|
||||
{
|
||||
scenario: "colon splits into key and value",
|
||||
rawTags: []any{"env:prod", "team : backend"},
|
||||
expectedTags: []kv{{"env", "prod"}, {"team", "backend"}},
|
||||
},
|
||||
{
|
||||
scenario: "slash splits into key and value when no colon present",
|
||||
rawTags: []any{"team/backend"},
|
||||
expectedTags: []kv{{"team", "backend"}},
|
||||
},
|
||||
{
|
||||
scenario: "colon takes precedence over slash and slash is scrubbed",
|
||||
rawTags: []any{"team/eng:prod", "team/eng:my/path"},
|
||||
expectedTags: []kv{{"team_eng", "prod"}, {"team_eng", "my_path"}},
|
||||
},
|
||||
{
|
||||
scenario: "empty left side falls back to the default key",
|
||||
rawTags: []any{":prod"},
|
||||
expectedTags: []kv{{"tag", "prod"}},
|
||||
},
|
||||
{
|
||||
scenario: "empty right side keeps the left side as the value",
|
||||
rawTags: []any{"env:"},
|
||||
expectedTags: []kv{{"tag", "env"}},
|
||||
},
|
||||
{
|
||||
scenario: "extra colons in the value collapse to underscores",
|
||||
rawTags: []any{"a:b:c"},
|
||||
expectedTags: []kv{{"a", "b_c"}},
|
||||
},
|
||||
{
|
||||
scenario: "extra slashes in the value are scrubbed",
|
||||
rawTags: []any{"a/b/c"},
|
||||
expectedTags: []kv{{"a", "b_c"}},
|
||||
},
|
||||
{
|
||||
scenario: "reserved key gets an underscore prefix",
|
||||
rawTags: []any{"name:foo", "Source:bar"},
|
||||
expectedTags: []kv{{"_name", "foo"}, {"_Source", "bar"}},
|
||||
},
|
||||
{
|
||||
scenario: "drops empty, whitespace-only, and bare-separator entries",
|
||||
rawTags: []any{"", " ", ":", "/", "apm"},
|
||||
expectedTags: []kv{{"tag", "apm"}},
|
||||
},
|
||||
{
|
||||
scenario: "dedupes case-insensitive duplicates, first casing wins",
|
||||
rawTags: []any{"Env:Prod", "env:PROD"},
|
||||
expectedTags: []kv{{"Env", "Prod"}},
|
||||
},
|
||||
{
|
||||
scenario: "spaces in key and value collapse to underscores",
|
||||
rawTags: []any{"env:spaced out", "spaced key:prod", "spaced out"},
|
||||
expectedTags: []kv{{"env", "spaced_out"}, {"spaced_key", "prod"}, {"tag", "spaced_out"}},
|
||||
},
|
||||
{
|
||||
scenario: "runs of disallowed punctuation collapse to a single underscore",
|
||||
rawTags: []any{"team (eng):prod!!one"},
|
||||
expectedTags: []kv{{"team_eng", "prod_one"}},
|
||||
},
|
||||
{
|
||||
scenario: "key that would start with a non-leading char is prefixed with underscore",
|
||||
rawTags: []any{"2nd tier:x"},
|
||||
expectedTags: []kv{{"_2nd_tier", "x"}},
|
||||
},
|
||||
{
|
||||
scenario: "a side that molds to empty falls back to the default key with the survivor",
|
||||
rawTags: []any{"(:x", "y:!!!", "good:tag"},
|
||||
expectedTags: []kv{{"tag", "x"}, {"tag", "y"}, {"good", "tag"}},
|
||||
},
|
||||
{
|
||||
scenario: "pairs with no representable content on either side are skipped",
|
||||
rawTags: []any{"(:!!!", "()", "ok"},
|
||||
expectedTags: []kv{{"tag", "ok"}},
|
||||
},
|
||||
{
|
||||
scenario: "returns nil for missing tags field",
|
||||
rawTags: nil,
|
||||
expectedTags: nil,
|
||||
},
|
||||
{
|
||||
scenario: "ignores non-string elements",
|
||||
rawTags: []any{"apm", 42, true, "logs"},
|
||||
expectedTags: []kv{{"tag", "apm"}, {"tag", "logs"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
tags := convertV1TagsForOrg(orgID, tc.rawTags)
|
||||
require.Len(t, tags, len(tc.expectedTags))
|
||||
for i, expected := range tc.expectedTags {
|
||||
assert.Equal(t, expected.key, tags[i].Key)
|
||||
assert.Equal(t, expected.value, tags[i].Value)
|
||||
assert.Equal(t, orgID, tags[i].OrgID)
|
||||
assert.Equal(t, coretypes.KindDashboard, tags[i].Kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoldedV1TagsPassValidation guards against the mold rules drifting from
|
||||
// the tag validators: every tag the converter emits for messy input must pass
|
||||
// the real tagtypes.ValidatePostableTag, and over-long fields must be capped.
|
||||
func TestMoldedV1TagsPassValidation(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
raw := []any{
|
||||
"env:spaced out",
|
||||
"team (eng):prod!!one",
|
||||
"2nd tier:x",
|
||||
"k:" + strings.Repeat("a", 50),
|
||||
strings.Repeat("verylongkeysegment ", 4) + ":v",
|
||||
"weird*&^chars:val#1",
|
||||
}
|
||||
|
||||
tags := convertV1TagsForOrg(orgID, raw)
|
||||
require.NotEmpty(t, tags)
|
||||
for _, tag := range tags {
|
||||
_, _, err := tagtypes.ValidatePostableTag(tagtypes.PostableTag{Key: tag.Key, Value: tag.Value})
|
||||
assert.NoError(t, err, "molded tag %q=%q must pass validation", tag.Key, tag.Value)
|
||||
assert.LessOrEqual(t, len(tag.Key), tagtypes.MAX_LEN_TAG_KEY)
|
||||
assert.LessOrEqual(t, len(tag.Value), tagtypes.MAX_LEN_TAG_VALUE)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGraphWidgetToTimeSeriesPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "widget-1",
|
||||
"panelTypes": "graph",
|
||||
"title": "Request rate",
|
||||
"description": "RPS over time",
|
||||
"timePreferance": "LAST_1_HR",
|
||||
"fillSpans": true,
|
||||
"yAxisUnit": "reqps",
|
||||
"decimalPrecision": float64(3),
|
||||
"lineInterpolation": "linear",
|
||||
"lineStyle": "dashed",
|
||||
"fillMode": "gradient",
|
||||
"showPoints": true,
|
||||
"spanGaps": float64(60),
|
||||
"softMin": float64(0),
|
||||
"softMax": float64(100),
|
||||
"isLogScale": true,
|
||||
"legendPosition": "right",
|
||||
"customLegendColors": map[string]any{"A": "#ff0000", "B": "#00ff00"},
|
||||
"thresholds": []any{
|
||||
map[string]any{
|
||||
"thresholdValue": float64(90),
|
||||
"thresholdUnit": "reqps",
|
||||
"thresholdColor": "#ff0000",
|
||||
"thresholdLabel": "high",
|
||||
},
|
||||
map[string]any{
|
||||
"thresholdValue": float64(50),
|
||||
"thresholdColor": "", // missing — must be dropped
|
||||
"thresholdLabel": "missing-color",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertGraphWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
|
||||
assert.Equal(t, PanelKindPanel, panel.Kind)
|
||||
assert.Equal(t, "Request rate", panel.Spec.Display.Name)
|
||||
assert.Equal(t, "RPS over time", panel.Spec.Display.Description)
|
||||
|
||||
assert.Equal(t, PanelKindTimeSeries, panel.Spec.Plugin.Kind)
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.True(t, ok, "panel plugin spec should be *TimeSeriesPanelSpec")
|
||||
|
||||
assert.Equal(t, TimePreferenceLast1Hr, spec.Visualization.TimePreference)
|
||||
assert.True(t, spec.Visualization.FillSpans)
|
||||
|
||||
assert.Equal(t, "reqps", spec.Formatting.Unit)
|
||||
assert.Equal(t, PrecisionOption3, spec.Formatting.DecimalPrecision)
|
||||
|
||||
assert.Equal(t, LineInterpolationLinear, spec.ChartAppearance.LineInterpolation)
|
||||
assert.True(t, spec.ChartAppearance.ShowPoints)
|
||||
assert.Equal(t, LineStyleDashed, spec.ChartAppearance.LineStyle)
|
||||
assert.Equal(t, FillModeGradient, spec.ChartAppearance.FillMode)
|
||||
assert.True(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow)
|
||||
assert.Equal(t, "1m0s", spec.ChartAppearance.SpanGaps.FillLessThan.StringValue())
|
||||
|
||||
require.NotNil(t, spec.Axes.SoftMin)
|
||||
assert.Equal(t, float64(0), *spec.Axes.SoftMin)
|
||||
require.NotNil(t, spec.Axes.SoftMax)
|
||||
assert.Equal(t, float64(100), *spec.Axes.SoftMax)
|
||||
assert.True(t, spec.Axes.IsLogScale)
|
||||
|
||||
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
|
||||
assert.Equal(t, map[string]string{"A": "#ff0000", "B": "#00ff00"}, spec.Legend.CustomColors)
|
||||
|
||||
require.Len(t, spec.Thresholds, 1, "threshold with missing color should be dropped")
|
||||
assert.Equal(t, ThresholdWithLabel{Value: 90, Unit: "reqps", Color: "#ff0000", Label: "high"}, spec.Thresholds[0])
|
||||
}
|
||||
|
||||
func TestConvertGraphWidgetDefaultsForMissingFields(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "widget-1",
|
||||
"panelTypes": "graph",
|
||||
"title": "minimal",
|
||||
}
|
||||
|
||||
panel := convertGraphWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, TimePreferenceGlobalTime, spec.Visualization.TimePreference)
|
||||
assert.Equal(t, PrecisionOption2, spec.Formatting.DecimalPrecision)
|
||||
assert.Equal(t, LineInterpolationSpline, spec.ChartAppearance.LineInterpolation)
|
||||
assert.Equal(t, LineStyleSolid, spec.ChartAppearance.LineStyle)
|
||||
assert.Equal(t, FillModeSolid, spec.ChartAppearance.FillMode)
|
||||
assert.Equal(t, LegendPositionBottom, spec.Legend.Position)
|
||||
assert.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow)
|
||||
assert.Nil(t, spec.Axes.SoftMin)
|
||||
assert.Nil(t, spec.Axes.SoftMax)
|
||||
assert.Empty(t, spec.Thresholds)
|
||||
}
|
||||
|
||||
func TestConvertV1ToV2HappyPath(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
storable := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: "alice", UpdatedBy: "bob"},
|
||||
OrgID: orgID,
|
||||
Source: SourceUser,
|
||||
Name: "apm-metrics",
|
||||
Data: StorableDashboardData{
|
||||
"title": "APM Metrics",
|
||||
"description": "service overview",
|
||||
"image": "data:image/png;base64,abc",
|
||||
"tags": []any{"apm", "team:platform"},
|
||||
"widgets": []any{
|
||||
// section header — owned by the layout pass, not a panel
|
||||
map[string]any{"id": "row-1", "panelTypes": "row", "title": "Overview"},
|
||||
// graph widget → TimeSeries panel
|
||||
map[string]any{
|
||||
"id": "panel-1",
|
||||
"panelTypes": "graph",
|
||||
"title": "Latency",
|
||||
},
|
||||
// table widget → Table panel
|
||||
map[string]any{"id": "panel-2", "panelTypes": "table"},
|
||||
// widget with missing id — dropped
|
||||
map[string]any{"panelTypes": "graph", "title": "no id"},
|
||||
// unknown panel kind — silently dropped
|
||||
map[string]any{"id": "panel-3", "panelTypes": "totally-new"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dashboard, err := storable.ConvertV1ToV2()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dashboard)
|
||||
|
||||
assert.Equal(t, storable.ID, dashboard.ID)
|
||||
assert.Equal(t, storable.OrgID, dashboard.OrgID)
|
||||
assert.Equal(t, storable.Source, dashboard.Source)
|
||||
assert.Equal(t, storable.Name, dashboard.Name)
|
||||
assert.Equal(t, SchemaVersion, dashboard.SchemaVersion)
|
||||
assert.Equal(t, "data:image/png;base64,abc", dashboard.Image)
|
||||
|
||||
assert.Equal(t, "APM Metrics", dashboard.Spec.Display.Name)
|
||||
assert.Equal(t, "service overview", dashboard.Spec.Display.Description)
|
||||
|
||||
require.Len(t, dashboard.Tags, 2)
|
||||
assert.Equal(t, "tag", dashboard.Tags[0].Key)
|
||||
assert.Equal(t, "apm", dashboard.Tags[0].Value)
|
||||
assert.Equal(t, "team", dashboard.Tags[1].Key)
|
||||
assert.Equal(t, "platform", dashboard.Tags[1].Value)
|
||||
|
||||
require.Len(t, dashboard.Spec.Panels, 2, "graph and table map; row, no-id, and unknown kinds are dropped")
|
||||
require.Contains(t, dashboard.Spec.Panels, "panel-1")
|
||||
require.Contains(t, dashboard.Spec.Panels, "panel-2")
|
||||
assert.Equal(t, PanelKindTimeSeries, dashboard.Spec.Panels["panel-1"].Spec.Plugin.Kind)
|
||||
assert.Equal(t, PanelKindTable, dashboard.Spec.Panels["panel-2"].Spec.Plugin.Kind)
|
||||
}
|
||||
|
||||
func TestConvertV1ToV2RejectsAlreadyV2(t *testing.T) {
|
||||
storable := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
Source: SourceUser,
|
||||
Name: "already-v2",
|
||||
Data: StorableDashboardData{
|
||||
"metadata": map[string]any{"schemaVersion": SchemaVersion},
|
||||
"spec": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
dashboard, err := storable.ConvertV1ToV2()
|
||||
assert.Nil(t, dashboard)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already in")
|
||||
}
|
||||
|
||||
func TestSpanGapsMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
scenario string
|
||||
rawSpanGaps any
|
||||
expectedFillOnlyBelow bool
|
||||
expectedFillLessThan string
|
||||
}{
|
||||
{scenario: "true spans every gap", rawSpanGaps: true, expectedFillOnlyBelow: false, expectedFillLessThan: "0s"},
|
||||
{scenario: "false spans no gaps", rawSpanGaps: false, expectedFillOnlyBelow: true, expectedFillLessThan: "0s"},
|
||||
{scenario: "number is seconds threshold", rawSpanGaps: float64(30), expectedFillOnlyBelow: true, expectedFillLessThan: "30s"},
|
||||
{scenario: "missing defaults to span all", rawSpanGaps: nil, expectedFillOnlyBelow: false, expectedFillLessThan: "0s"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
got := mapV1SpanGaps(tc.rawSpanGaps)
|
||||
assert.Equal(t, tc.expectedFillOnlyBelow, got.FillOnlyBelow)
|
||||
assert.Equal(t, tc.expectedFillLessThan, got.FillLessThan.StringValue())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Other panel-kind converters
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertBarWidgetToBarChartPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "bar-1",
|
||||
"panelTypes": "bar",
|
||||
"title": "Requests by status",
|
||||
"fillSpans": true,
|
||||
"stackedBarChart": true,
|
||||
"yAxisUnit": "reqps",
|
||||
"softMin": float64(0),
|
||||
"isLogScale": true,
|
||||
"legendPosition": "right",
|
||||
}
|
||||
|
||||
panel := convertBarWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindBarChart, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*BarChartPanelSpec)
|
||||
require.True(t, ok)
|
||||
assert.True(t, spec.Visualization.FillSpans)
|
||||
assert.True(t, spec.Visualization.StackedBarChart)
|
||||
assert.Equal(t, "reqps", spec.Formatting.Unit)
|
||||
require.NotNil(t, spec.Axes.SoftMin)
|
||||
assert.Equal(t, float64(0), *spec.Axes.SoftMin)
|
||||
assert.True(t, spec.Axes.IsLogScale)
|
||||
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
|
||||
}
|
||||
|
||||
func TestConvertValueWidgetToNumberPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "val-1",
|
||||
"panelTypes": "value",
|
||||
"title": "Active services",
|
||||
"yAxisUnit": "count",
|
||||
"thresholds": []any{
|
||||
map[string]any{
|
||||
"thresholdValue": float64(100),
|
||||
"thresholdOperator": ">=",
|
||||
"thresholdColor": "#ff0000",
|
||||
"thresholdFormat": "Background",
|
||||
"thresholdUnit": "count",
|
||||
},
|
||||
map[string]any{
|
||||
// missing color — must be dropped
|
||||
"thresholdValue": float64(10),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertValueWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindNumber, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
assert.Equal(t, float64(100), spec.Thresholds[0].Value)
|
||||
assert.Equal(t, ComparisonOperatorAboveOrEqual, spec.Thresholds[0].Operator)
|
||||
assert.Equal(t, "#ff0000", spec.Thresholds[0].Color)
|
||||
assert.Equal(t, ThresholdFormatBackground, spec.Thresholds[0].Format)
|
||||
}
|
||||
|
||||
func TestConvertTableWidgetToTablePanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "tbl-1",
|
||||
"panelTypes": "table",
|
||||
"title": "Top services",
|
||||
"columnUnits": map[string]any{
|
||||
"latency": "ms",
|
||||
"errors": "count",
|
||||
},
|
||||
"thresholds": []any{
|
||||
map[string]any{
|
||||
"thresholdValue": float64(500),
|
||||
"thresholdColor": "#ff0000",
|
||||
"thresholdTableOptions": "latency",
|
||||
"thresholdOperator": ">",
|
||||
},
|
||||
map[string]any{
|
||||
// missing columnName — dropped
|
||||
"thresholdValue": float64(1),
|
||||
"thresholdColor": "#00ff00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertTableWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindTable, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*TablePanelSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "ms", spec.Formatting.ColumnUnits["latency"])
|
||||
assert.Equal(t, "count", spec.Formatting.ColumnUnits["errors"])
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
assert.Equal(t, "latency", spec.Thresholds[0].ColumnName)
|
||||
assert.Equal(t, ComparisonOperatorAbove, spec.Thresholds[0].Operator)
|
||||
}
|
||||
|
||||
func TestConvertPieWidgetToPieChartPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "pie-1",
|
||||
"panelTypes": "pie",
|
||||
"title": "Share",
|
||||
"legendPosition": "right",
|
||||
}
|
||||
|
||||
panel := convertPieWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindPieChart, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*PieChartPanelSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
|
||||
}
|
||||
|
||||
func TestConvertHistogramWidget(t *testing.T) {
|
||||
bucketCount := float64(20)
|
||||
widget := map[string]any{
|
||||
"id": "hist-1",
|
||||
"panelTypes": "histogram",
|
||||
"title": "Latency distribution",
|
||||
"bucketCount": bucketCount,
|
||||
"mergeAllActiveQueries": true,
|
||||
}
|
||||
|
||||
panel := convertHistogramWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindHistogram, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*HistogramPanelSpec)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, spec.HistogramBuckets.BucketCount)
|
||||
assert.Equal(t, bucketCount, *spec.HistogramBuckets.BucketCount)
|
||||
assert.Nil(t, spec.HistogramBuckets.BucketWidth)
|
||||
assert.True(t, spec.HistogramBuckets.MergeAllActiveQueries)
|
||||
}
|
||||
|
||||
func TestConvertListWidget(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "list-1",
|
||||
"panelTypes": "list",
|
||||
"title": "Recent logs",
|
||||
"selectedLogFields": []any{
|
||||
map[string]any{"name": "body", "fieldDataType": "string", "fieldContext": "log"},
|
||||
map[string]any{"name": "severity_text", "fieldDataType": "string", "fieldContext": "log"},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertListWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindList, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*ListPanelSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, spec.SelectFields, 2)
|
||||
assert.Equal(t, "body", spec.SelectFields[0].Name)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Query translation
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertV1WidgetQuerySinglePromQL(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "p-1",
|
||||
"panelTypes": "graph",
|
||||
"query": map[string]any{
|
||||
"queryType": "promql",
|
||||
"promql": []any{
|
||||
map[string]any{"name": "A", "query": "up", "legend": "{{job}}"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeTimeSeries, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindPromQL, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
prom, ok := queries[0].Spec.Plugin.Spec.(*qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "A", prom.Name)
|
||||
assert.Equal(t, "up", prom.Query)
|
||||
assert.Equal(t, "{{job}}", prom.Legend)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQuerySingleClickHouse(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "c-1",
|
||||
"panelTypes": "table",
|
||||
"query": map[string]any{
|
||||
"queryType": "clickhouse_sql",
|
||||
"clickhouse_sql": []any{
|
||||
map[string]any{"name": "Q", "query": "SELECT 1", "legend": "x"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTable)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeScalar, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindClickHouseSQL, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
ch, ok := queries[0].Spec.Plugin.Spec.(*qb.ClickHouseQuery)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Q", ch.Name)
|
||||
assert.Equal(t, "SELECT 1", ch.Query)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryMultipleBuilderWrapsInComposite(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "b-1",
|
||||
"panelTypes": "graph",
|
||||
"query": map[string]any{
|
||||
"queryType": "builder",
|
||||
"builder": map[string]any{
|
||||
"queryData": []any{
|
||||
map[string]any{
|
||||
"queryName": "A",
|
||||
"expression": "A",
|
||||
"dataSource": "metrics",
|
||||
"aggregations": []any{map[string]any{"metricName": "signoz_calls_total"}},
|
||||
},
|
||||
map[string]any{
|
||||
"queryName": "B",
|
||||
"expression": "B",
|
||||
"dataSource": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
},
|
||||
},
|
||||
"queryFormulas": []any{
|
||||
map[string]any{"queryName": "F1", "expression": "A + B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeTimeSeries, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindComposite, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
composite, ok := queries[0].Spec.Plugin.Spec.(*CompositeQuerySpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, composite.Queries, 3)
|
||||
assert.Equal(t, qb.QueryTypeBuilder, composite.Queries[0].Type)
|
||||
assert.Equal(t, qb.QueryTypeBuilder, composite.Queries[1].Type)
|
||||
assert.Equal(t, qb.QueryTypeFormula, composite.Queries[2].Type)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryListPanelUsesBuilderDirectly(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "l-1",
|
||||
"panelTypes": "list",
|
||||
"query": map[string]any{
|
||||
"queryType": "builder",
|
||||
"builder": map[string]any{
|
||||
"queryData": []any{
|
||||
map[string]any{
|
||||
"queryName": "A",
|
||||
"expression": "A",
|
||||
"dataSource": "logs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindList)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeRaw, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindBuilder, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
wrapper, ok := queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec)
|
||||
require.True(t, ok)
|
||||
spec, ok := wrapper.Spec.(qb.QueryBuilderQuery[qb.LogAggregation])
|
||||
require.True(t, ok, "list builder query should dispatch to LogAggregation, got %T", wrapper.Spec)
|
||||
assert.Equal(t, "A", spec.Name)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryNoQuery(t *testing.T) {
|
||||
widget := map[string]any{"id": "x", "panelTypes": "graph"}
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
assert.Nil(t, queries)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Layouts and sections
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertV1LayoutsRootOnly(t *testing.T) {
|
||||
data := StorableDashboardData{
|
||||
"layout": []any{
|
||||
map[string]any{"i": "p-1", "x": float64(0), "y": float64(0), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "p-2", "x": float64(6), "y": float64(0), "w": float64(6), "h": float64(6)},
|
||||
},
|
||||
"widgets": []any{
|
||||
map[string]any{"id": "p-1", "panelTypes": "graph"},
|
||||
map[string]any{"id": "p-2", "panelTypes": "graph"},
|
||||
},
|
||||
}
|
||||
|
||||
layouts := convertV1Layouts(data)
|
||||
require.Len(t, layouts, 1)
|
||||
assert.Equal(t, dashboard.KindGridLayout, layouts[0].Kind)
|
||||
|
||||
spec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, spec.Items, 2)
|
||||
assert.Equal(t, "#/spec/panels/p-1", spec.Items[0].Content.Ref)
|
||||
assert.Equal(t, 6, spec.Items[1].Width)
|
||||
assert.Nil(t, spec.Display, "root-only grid should have no display block")
|
||||
}
|
||||
|
||||
func TestConvertV1LayoutsWithCollapsedSection(t *testing.T) {
|
||||
data := StorableDashboardData{
|
||||
"widgets": []any{
|
||||
map[string]any{"id": "row-1", "panelTypes": "row", "title": "Latency"},
|
||||
map[string]any{"id": "p-1", "panelTypes": "graph"},
|
||||
map[string]any{"id": "p-2", "panelTypes": "graph"},
|
||||
},
|
||||
"layout": []any{
|
||||
map[string]any{"i": "row-1", "x": float64(0), "y": float64(0), "w": float64(12), "h": float64(1)},
|
||||
map[string]any{"i": "p-1", "x": float64(0), "y": float64(1), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "p-2", "x": float64(0), "y": float64(7), "w": float64(6), "h": float64(6)},
|
||||
},
|
||||
"panelMap": map[string]any{
|
||||
"row-1": map[string]any{
|
||||
"collapsed": true,
|
||||
"widgets": []any{
|
||||
map[string]any{"i": "p-1", "x": float64(0), "y": float64(1), "w": float64(6), "h": float64(6)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
layouts := convertV1Layouts(data)
|
||||
require.Len(t, layouts, 2, "one root grid (p-2) + one section grid (row-1 with p-1)")
|
||||
|
||||
rootSpec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, rootSpec.Items, 1)
|
||||
assert.Equal(t, "#/spec/panels/p-2", rootSpec.Items[0].Content.Ref)
|
||||
assert.Nil(t, rootSpec.Display)
|
||||
|
||||
sectionSpec, ok := layouts[1].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, sectionSpec.Display)
|
||||
assert.Equal(t, "Latency", sectionSpec.Display.Title)
|
||||
require.NotNil(t, sectionSpec.Display.Collapse)
|
||||
assert.False(t, sectionSpec.Display.Collapse.Open, "collapsed=true → open=false")
|
||||
require.Len(t, sectionSpec.Items, 1)
|
||||
assert.Equal(t, "#/spec/panels/p-1", sectionSpec.Items[0].Content.Ref)
|
||||
}
|
||||
|
||||
// TestConvertV1LayoutsExpandedSectionsNoPanelMap covers the common real-world
|
||||
// shape: multiple expanded row sections, each with its own panels, and no
|
||||
// panelMap at all. Section membership must come from the layout y/x positions —
|
||||
// each row owns the panels below it until the next row.
|
||||
func TestConvertV1LayoutsExpandedSectionsNoPanelMap(t *testing.T) {
|
||||
data := StorableDashboardData{
|
||||
"widgets": []any{
|
||||
map[string]any{"id": "row_s1", "panelTypes": "row", "title": "section 1"},
|
||||
map[string]any{"id": "s1p1", "panelTypes": "graph"},
|
||||
map[string]any{"id": "s1p2", "panelTypes": "graph"},
|
||||
map[string]any{"id": "row_s2", "panelTypes": "row", "title": "section 2"},
|
||||
map[string]any{"id": "s2p1", "panelTypes": "graph"},
|
||||
map[string]any{"id": "s2p2", "panelTypes": "graph"},
|
||||
},
|
||||
"layout": []any{
|
||||
map[string]any{"i": "row_s1", "x": float64(0), "y": float64(0), "w": float64(12), "h": float64(1)},
|
||||
map[string]any{"i": "s1p1", "x": float64(0), "y": float64(1), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "s1p2", "x": float64(6), "y": float64(1), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "row_s2", "x": float64(0), "y": float64(7), "w": float64(12), "h": float64(1)},
|
||||
map[string]any{"i": "s2p1", "x": float64(0), "y": float64(8), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "s2p2", "x": float64(6), "y": float64(8), "w": float64(6), "h": float64(6)},
|
||||
},
|
||||
}
|
||||
|
||||
layouts := convertV1Layouts(data)
|
||||
require.Len(t, layouts, 2, "two row sections, no root grid")
|
||||
|
||||
s1, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, s1.Display)
|
||||
assert.Equal(t, "section 1", s1.Display.Title)
|
||||
require.NotNil(t, s1.Display.Collapse)
|
||||
assert.True(t, s1.Display.Collapse.Open)
|
||||
require.Len(t, s1.Items, 2)
|
||||
assert.Equal(t, "#/spec/panels/s1p1", s1.Items[0].Content.Ref)
|
||||
assert.Equal(t, "#/spec/panels/s1p2", s1.Items[1].Content.Ref)
|
||||
// y normalized within the section: the row header's row is dropped.
|
||||
assert.Equal(t, 0, s1.Items[0].Y)
|
||||
assert.Equal(t, 0, s1.Items[1].Y)
|
||||
assert.Equal(t, 6, s1.Items[1].X)
|
||||
|
||||
s2, ok := layouts[1].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "section 2", s2.Display.Title)
|
||||
require.Len(t, s2.Items, 2)
|
||||
assert.Equal(t, "#/spec/panels/s2p1", s2.Items[0].Content.Ref)
|
||||
assert.Equal(t, "#/spec/panels/s2p2", s2.Items[1].Content.Ref)
|
||||
assert.Equal(t, 0, s2.Items[0].Y)
|
||||
assert.Equal(t, 0, s2.Items[1].Y)
|
||||
}
|
||||
|
||||
func TestConvertV1LayoutsEmpty(t *testing.T) {
|
||||
assert.Nil(t, convertV1Layouts(StorableDashboardData{}))
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Variables
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertV1VariablesAllTypes(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"u-1": map[string]any{
|
||||
"name": "service.name",
|
||||
"description": "the service",
|
||||
"type": "QUERY",
|
||||
"queryValue": "SELECT name FROM s",
|
||||
"multiSelect": true,
|
||||
"showALLOption": true,
|
||||
"sort": "ASC",
|
||||
"order": float64(1),
|
||||
},
|
||||
"u-2": map[string]any{
|
||||
"name": "env",
|
||||
"type": "CUSTOM",
|
||||
"customValue": "prod,staging,dev",
|
||||
"order": float64(2),
|
||||
"selectedValue": "prod",
|
||||
},
|
||||
"u-3": map[string]any{
|
||||
"name": "deployment.environment",
|
||||
"type": "DYNAMIC",
|
||||
"dynamicVariablesAttribute": "deployment.environment",
|
||||
"dynamicVariablesSource": "Traces",
|
||||
"order": float64(0),
|
||||
},
|
||||
"u-4": map[string]any{
|
||||
"name": "freetext",
|
||||
"type": "TEXTBOX",
|
||||
"textboxValue": "hello",
|
||||
"order": float64(3),
|
||||
},
|
||||
}
|
||||
|
||||
vars := convertV1Variables(raw)
|
||||
require.Len(t, vars, 4)
|
||||
|
||||
// Ordered by `order` ascending: u-3 (0), u-1 (1), u-2 (2), u-4 (3)
|
||||
assert.Equal(t, variable.KindList, vars[0].Kind)
|
||||
dyn, ok := vars[0].Spec.(*ListVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "deployment.environment", dyn.Name)
|
||||
assert.Equal(t, VariableKindDynamic, dyn.Plugin.Kind)
|
||||
dynSpec, ok := dyn.Plugin.Spec.(*DynamicVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, dynSpec.Signal)
|
||||
|
||||
q, ok := vars[1].Spec.(*ListVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "service.name", q.Name)
|
||||
assert.Equal(t, VariableKindQuery, q.Plugin.Kind)
|
||||
assert.True(t, q.AllowMultiple)
|
||||
assert.True(t, q.AllowAllValue)
|
||||
require.NotNil(t, q.Sort)
|
||||
assert.Equal(t, variable.SortAlphabeticalAsc, *q.Sort)
|
||||
|
||||
c, ok := vars[2].Spec.(*ListVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "env", c.Name)
|
||||
assert.Equal(t, VariableKindCustom, c.Plugin.Kind)
|
||||
require.NotNil(t, c.DefaultValue)
|
||||
assert.Equal(t, "prod", c.DefaultValue.SingleValue)
|
||||
|
||||
assert.Equal(t, variable.KindText, vars[3].Kind)
|
||||
text, ok := vars[3].Spec.(*dashboard.TextVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "freetext", text.Name)
|
||||
assert.Equal(t, "hello", text.Value)
|
||||
}
|
||||
|
||||
func TestConvertV1VariablesSkipsUnnamedAndUnknownTypes(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"u-1": map[string]any{"name": "", "type": "QUERY"},
|
||||
"u-2": map[string]any{"name": "ok", "type": "WHATEVER"},
|
||||
"u-3": map[string]any{"name": "good", "type": "CUSTOM", "customValue": "a"},
|
||||
}
|
||||
vars := convertV1Variables(raw)
|
||||
require.Len(t, vars, 1)
|
||||
spec := vars[0].Spec.(*ListVariableSpec)
|
||||
assert.Equal(t, "good", spec.Name)
|
||||
}
|
||||
|
||||
func TestConvertV1VariablesDefaultFromSelectedSlice(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"u-1": map[string]any{
|
||||
"name": "svc",
|
||||
"type": "QUERY",
|
||||
"queryValue": "SELECT 1",
|
||||
"selectedValue": []any{"foo", "", "bar"},
|
||||
},
|
||||
}
|
||||
vars := convertV1Variables(raw)
|
||||
require.Len(t, vars, 1)
|
||||
spec := vars[0].Spec.(*ListVariableSpec)
|
||||
require.NotNil(t, spec.DefaultValue)
|
||||
assert.Equal(t, []string{"foo", "bar"}, spec.DefaultValue.SliceValues)
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Variables
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
|
||||
// ordered []Variable. Variables sort by `order` first, then by id for stable
|
||||
// output. v1 variable types map as follows:
|
||||
//
|
||||
// QUERY → ListVariable + signoz/QueryVariable
|
||||
// CUSTOM → ListVariable + signoz/CustomVariable
|
||||
// DYNAMIC → ListVariable + signoz/DynamicVariable
|
||||
// TEXTBOX → TextVariable
|
||||
func convertV1Variables(raw any) []Variable {
|
||||
rawMap, ok := raw.(map[string]any)
|
||||
if !ok || len(rawMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
type ordered struct {
|
||||
key string
|
||||
val map[string]any
|
||||
ord float64
|
||||
}
|
||||
entries := make([]ordered, 0, len(rawMap))
|
||||
for key, value := range rawMap {
|
||||
m, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ord, _ := m["order"].(float64)
|
||||
entries = append(entries, ordered{key: key, val: m, ord: ord})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].ord != entries[j].ord {
|
||||
return entries[i].ord < entries[j].ord
|
||||
}
|
||||
return entries[i].key < entries[j].key
|
||||
})
|
||||
|
||||
out := make([]Variable, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
v, ok := convertV1Variable(e.val)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertV1Variable(v map[string]any) (Variable, bool) {
|
||||
name, _ := v["name"].(string)
|
||||
if name == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
description, _ := v["description"].(string)
|
||||
kind, _ := v["type"].(string)
|
||||
|
||||
switch kind {
|
||||
case "TEXTBOX":
|
||||
value, _ := v["textboxValue"].(string)
|
||||
spec := &dashboard.TextVariableSpec{
|
||||
TextSpec: variable.TextSpec{
|
||||
Display: &variable.Display{Name: name, Description: description},
|
||||
Value: value,
|
||||
},
|
||||
Name: name,
|
||||
}
|
||||
return Variable{Kind: variable.KindText, Spec: spec}, true
|
||||
|
||||
case "QUERY", "CUSTOM", "DYNAMIC":
|
||||
listSpec := &ListVariableSpec{
|
||||
Display: Display{Name: name, Description: description},
|
||||
AllowAllValue: valueAt[bool](v, "showALLOption"),
|
||||
AllowMultiple: valueAt[bool](v, "multiSelect"),
|
||||
CustomAllValue: valueAt[string](v, "customAllValue"),
|
||||
CapturingRegexp: valueAt[string](v, "capturingRegexp"),
|
||||
Sort: mapV1Sort(v["sort"]),
|
||||
Plugin: variablePluginFor(kind, v),
|
||||
Name: name,
|
||||
}
|
||||
if dv := mapV1VariableDefault(v); dv != nil {
|
||||
listSpec.DefaultValue = dv
|
||||
}
|
||||
return Variable{Kind: variable.KindList, Spec: listSpec}, true
|
||||
}
|
||||
|
||||
return Variable{}, false
|
||||
}
|
||||
|
||||
func variablePluginFor(kind string, v map[string]any) VariablePlugin {
|
||||
switch kind {
|
||||
case "QUERY":
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindQuery,
|
||||
Spec: &QueryVariableSpec{QueryValue: valueAt[string](v, "queryValue")},
|
||||
}
|
||||
case "CUSTOM":
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindCustom,
|
||||
Spec: &CustomVariableSpec{CustomValue: valueAt[string](v, "customValue")},
|
||||
}
|
||||
case "DYNAMIC":
|
||||
spec := &DynamicVariableSpec{Name: valueAt[string](v, "dynamicVariablesAttribute")}
|
||||
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
|
||||
spec.Signal = signal
|
||||
}
|
||||
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
|
||||
}
|
||||
return VariablePlugin{}
|
||||
}
|
||||
|
||||
func mapV1VariableDefault(v map[string]any) *variable.DefaultValue {
|
||||
if raw, ok := v["selectedValue"]; ok {
|
||||
return defaultValueFromAny(raw)
|
||||
}
|
||||
if raw, ok := v["defaultValue"]; ok {
|
||||
return defaultValueFromAny(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultValueFromAny(raw any) *variable.DefaultValue {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &variable.DefaultValue{SingleValue: v}
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
values = append(values, s)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &variable.DefaultValue{SliceValues: values}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapV1Sort(raw any) *variable.Sort {
|
||||
s, _ := raw.(string)
|
||||
var sort variable.Sort
|
||||
switch s {
|
||||
case "ASC":
|
||||
sort = variable.SortAlphabeticalAsc
|
||||
case "DESC":
|
||||
sort = variable.SortAlphabeticalDesc
|
||||
case "DISABLED", "":
|
||||
return nil // SortNone is the implicit default
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &sort
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
### Phases
|
||||
|
||||
1. **Pre-migration (dev)**: new tables `tag`, `tag_relations`, `pinned_dashboard`, `dashboard_view`
|
||||
2. **Validation**: run the migration script against a few prod snapshots locally. Verify counts match, spot-check shapes, time the run to estimate downtime.
|
||||
3. **Dry-run in cloud prod (cloud only).** Ship a build that runs the migration script in read-only
|
||||
mode against live prod data. Whenever the v1 get API is called for a dashboard, we dry-run the migration script for it in an async process. If there is a failure, schema mismatches, tag normalization rejections, etc, it is logged. Reach out to affected customers to fix their dashboards before the real migration. Re-run closer to migration day to confirm resolution.
|
||||
4. **Migration deploy**: script runs, FF flips on. Integration dashboards materialized in the `dashboard` table using an internal system account with `Locked = true`.
|
||||
5. **Post-migration**: v1 APIs deprecated but still respond.
|
||||
|
||||
#### **Rejected idea: dry run in a background job**
|
||||
|
||||
In the above plan, we only check the dashboards that the users access. However, that should be enough to cover enough dashboards to be able to find out possible issues. The extra effort of a background job doesn't have enough ROI.
|
||||
|
||||
### What gets migrated
|
||||
|
||||
Existing v1 dashboards → full v2 data shape (tags extracted from `data.tags` into `tag` and `tag_relations`; the field is removed from the blob). Integration dashboards → materialized rows. Pinned dashboards and saved views start empty.
|
||||
|
||||
### Tag normalization (v1 strings → v2 tag rows)
|
||||
|
||||
Each v1 dashboard `data.tags` is `[]string`. For every string `s`, derive `(key, value)`.
|
||||
|
||||
**Order of rules:**
|
||||
|
||||
1. **Trim** leading/trailing whitespace from `s`. If empty after trim → **skip silently** (log dashboard id + index, continue).
|
||||
2. **If `s` contains `:`** → split at the **first** `:`. Let `k` = left side, `v` = right side.
|
||||
- If `k` is empty (input was `:val`) → `key = "tag"`, `value = val`.
|
||||
- If `v` is empty (input was `key:`) → `key = "tag"`, `value = k` (the literal left side becomes the value).
|
||||
- Otherwise → `key = k`, `value = v`.
|
||||
- Other `:` are replaced with `_`.
|
||||
3. **Else if `s` contains `/`** → split at the **first** `/`. Let `k` = left side, `v` = right side.
|
||||
- Same empty-side handling: empty left → `key="tag", value=v`; empty right → `key="tag", value=k`. Otherwise → `key=k, value=v`.
|
||||
4. **Else** (no separator) → `key = "tag"`, `value = s`.
|
||||
5. Reserved-key collision. After steps 2–4, if the resulting key (case-insensitively) matches a reserved DSL key (name, description, created_at, updated_at, created_by, locked, public), prefix it with _ (e.g. name → _name). Silent — extremely unlikely in practice, but the rename keeps the dashboard alive without ambiguating the query DSL.
|
||||
6. **`/` scrub.** Output tags must never contain `/` (input validation forbids it). After the above steps, replace any remaining `/` in `key` and `value` with `_`:
|
||||
- `a/b/c` → step 3 splits at first `/` → `key="a", value="b/c"` → after scrub → `key="a", value="b_c"`
|
||||
- `team/eng:prod` → step 2 splits at `:` → `key="team/eng", value="prod"` → after scrub → `key="team_eng", value="prod"`
|
||||
- `team/eng:my/path` → step 2 → `key="team/eng", value="my/path"` → scrub → `key="team_eng", value="my_path"`
|
||||
|
||||
Trailing/leading whitespace within `key` and `value` after split is also trimmed; if either side becomes empty after that, apply the empty-side rules above. If both sides are effectively empty (e.g. input was `:` or `/`), skip silently.
|
||||
|
||||
**Case-collision dedup:**
|
||||
|
||||
Multiple v1 strings can normalize to the same `(LOWER(key), LOWER(value))` across an org (e.g. `Env:Prod` and `env:PROD`). The functional unique index ensures only one row exists. Display casing is taken from the variant on the dashboard with the **earliest `created_at`** (ties broken by `dashboard.id`) — same rule as the previous spec, just applied to `(key, value)` instead of `name`.
|
||||
|
||||
**Tag relations:**
|
||||
|
||||
After tag rows are upserted, build `tag_relations` from each (dashboard, tag-id-after-dedup) pair. `ON CONFLICT` clause in the query makes this idempotent.
|
||||
|
||||
### Script properties
|
||||
|
||||
- Per-dashboard transactional. One failure logs the dashboard id and continues.
|
||||
- Idempotent: `ON CONFLICT DO NOTHING` on tag and tag_relations upserts; dashboards already in v2 shape are skipped.
|
||||
- Progress logged every N dashboards; final summary includes totals and failure list.
|
||||
|
||||
### Rollback
|
||||
|
||||
Forward-only — no v2→v1 reverse script. The FF is the kill-switch pre-frontend-cutover. After cutover, rollback = another deploy with the fix.
|
||||
|
||||
### What about dashboards that fail to migrate after all this?
|
||||
|
||||
In Get API (v2) there will be a check on the dashboard fetched.
|
||||
|
||||
- `v2` → normal flow.
|
||||
- `v1` → return `422 Unprocessable Entity`.
|
||||
|
||||
The deprecated v1 APIs will still exist, so if any support ticket comes, we can check via the v1 API and see what’s wrong.
|
||||
@@ -1,127 +0,0 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
// WrapInV5Envelope translates a single v4 builder query/formula map into a
|
||||
// v5 query envelope ({"type": ..., "spec": ...}). It is a pure shape transform
|
||||
// over untyped maps: v4 builder field names (groupBy/orderBy/selectColumns/
|
||||
// dataSource) are rewritten to their v5 equivalents and a `signal` is derived
|
||||
// from the data source. queryType selects the envelope type, except a formula
|
||||
// (detected when name != queryMap["expression"]) is always emitted as
|
||||
// "builder_formula".
|
||||
//
|
||||
// Migration code (pkg/transition) and the v1→v2 dashboard conversion both
|
||||
// produce v5 envelopes, so this lives here with the v5 query types rather than
|
||||
// in an infra-level package.
|
||||
func WrapInV5Envelope(name string, queryMap map[string]any, queryType string) map[string]any {
|
||||
// Create a properly structured v5 query
|
||||
v5Query := map[string]any{
|
||||
"name": name,
|
||||
"disabled": queryMap["disabled"],
|
||||
"legend": queryMap["legend"],
|
||||
}
|
||||
|
||||
if name != queryMap["expression"] {
|
||||
// formula
|
||||
queryType = "builder_formula"
|
||||
v5Query["expression"] = queryMap["expression"]
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
|
||||
// Add signal based on data source
|
||||
if dataSource, ok := queryMap["dataSource"].(string); ok {
|
||||
switch dataSource {
|
||||
case "traces":
|
||||
v5Query["signal"] = "traces"
|
||||
case "logs":
|
||||
v5Query["signal"] = "logs"
|
||||
case "metrics":
|
||||
v5Query["signal"] = "metrics"
|
||||
}
|
||||
}
|
||||
|
||||
if stepInterval, ok := queryMap["stepInterval"]; ok {
|
||||
v5Query["stepInterval"] = stepInterval
|
||||
}
|
||||
|
||||
if aggregations, ok := queryMap["aggregations"]; ok {
|
||||
v5Query["aggregations"] = aggregations
|
||||
}
|
||||
|
||||
if filter, ok := queryMap["filter"]; ok {
|
||||
v5Query["filter"] = filter
|
||||
}
|
||||
|
||||
// Copy groupBy with proper structure
|
||||
if groupBy, ok := queryMap["groupBy"].([]any); ok {
|
||||
v5GroupBy := make([]any, len(groupBy))
|
||||
for i, gb := range groupBy {
|
||||
if gbMap, ok := gb.(map[string]any); ok {
|
||||
v5GroupBy[i] = map[string]any{
|
||||
"name": gbMap["key"],
|
||||
"fieldDataType": gbMap["dataType"],
|
||||
"fieldContext": gbMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["groupBy"] = v5GroupBy
|
||||
}
|
||||
|
||||
// Copy orderBy with proper structure
|
||||
if orderBy, ok := queryMap["orderBy"].([]any); ok {
|
||||
v5OrderBy := make([]any, len(orderBy))
|
||||
for i, ob := range orderBy {
|
||||
if obMap, ok := ob.(map[string]any); ok {
|
||||
v5OrderBy[i] = map[string]any{
|
||||
"key": map[string]any{
|
||||
"name": obMap["columnName"],
|
||||
"fieldDataType": obMap["dataType"],
|
||||
"fieldContext": obMap["type"],
|
||||
},
|
||||
"direction": obMap["order"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["order"] = v5OrderBy
|
||||
}
|
||||
|
||||
// Copy selectColumns as selectFields
|
||||
if selectColumns, ok := queryMap["selectColumns"].([]any); ok {
|
||||
v5SelectFields := make([]any, len(selectColumns))
|
||||
for i, col := range selectColumns {
|
||||
if colMap, ok := col.(map[string]any); ok {
|
||||
v5SelectFields[i] = map[string]any{
|
||||
"name": colMap["key"],
|
||||
"fieldDataType": colMap["dataType"],
|
||||
"fieldContext": colMap["type"],
|
||||
}
|
||||
}
|
||||
}
|
||||
v5Query["selectFields"] = v5SelectFields
|
||||
}
|
||||
|
||||
// Copy limit and offset
|
||||
if limit, ok := queryMap["limit"]; ok {
|
||||
v5Query["limit"] = limit
|
||||
}
|
||||
if offset, ok := queryMap["offset"]; ok {
|
||||
v5Query["offset"] = offset
|
||||
}
|
||||
|
||||
if having, ok := queryMap["having"]; ok {
|
||||
v5Query["having"] = having
|
||||
}
|
||||
|
||||
if functions, ok := queryMap["functions"]; ok {
|
||||
v5Query["functions"] = functions
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": queryType,
|
||||
"spec": v5Query,
|
||||
}
|
||||
}
|
||||
151
pkg/types/spantypes/spanmappersimulator.go
Normal file
151
pkg/types/spantypes/spanmappersimulator.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"go.opentelemetry.io/collector/pdata/ptrace"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeProcessorFactoryMapFailed = errors.MustNewCode("processor_factory_map_failed")
|
||||
ErrCodeSpanMapperSimulationFailed = errors.MustNewCode("span_mapper_simulation_failed")
|
||||
)
|
||||
|
||||
// spanInputOrderAttr tags each input span with its index so the simulator
|
||||
// output can be sorted back into input order. The collector simulator does
|
||||
// not guarantee that traces come out in the order they went in.
|
||||
const spanInputOrderAttr = "__signoz_input_idx__"
|
||||
|
||||
// SimulateSpanMappersProcessing runs the given spans through an in-memory
|
||||
// collector pipeline that hosts signozspanmapperprocessor configured by the
|
||||
// supplied groups, and returns the transformed spans. Mirrors
|
||||
// SimulatePipelinesProcessing in pkg/query-service/app/logparsingpipeline.
|
||||
// func SimulateSpanMappersProcessing(
|
||||
// ctx context.Context,
|
||||
// groups []*SpanMapperGroupWithMappers,
|
||||
// spans []SpanMapperTestSpan,
|
||||
// ) (
|
||||
// []SpanMapperTestSpan, []string, error,
|
||||
// ) {
|
||||
// enabled := filterEnabledGroupsWithMappers(groups)
|
||||
// if len(enabled) < 1 {
|
||||
// return spans, nil, nil
|
||||
// }
|
||||
|
||||
// for i := range spans {
|
||||
// if spans[i].Attributes == nil {
|
||||
// spans[i].Attributes = map[string]any{}
|
||||
// }
|
||||
// spans[i].Attributes[spanInputOrderAttr] = int64(i)
|
||||
// }
|
||||
// simulatorInput := SpansToPTraces(spans)
|
||||
|
||||
// processorFactories, err := otelcol.MakeFactoryMap(signozspanmapperprocessor.NewFactory())
|
||||
// if err != nil {
|
||||
// return nil, nil, errors.WrapInternalf(err, ErrCodeProcessorFactoryMapFailed, "could not construct processor factory map")
|
||||
// }
|
||||
|
||||
// configGenerator := func(baseConf []byte) ([]byte, error) {
|
||||
// return GenerateCollectorConfigWithSpanMapperProcessor(baseConf, enabled)
|
||||
// }
|
||||
|
||||
// // signozspanmapperprocessor does no batching; spans flow through immediately.
|
||||
// timeout := 200 * time.Millisecond
|
||||
|
||||
// outputTraces, collectorErrs, simErr := collectorsimulator.SimulateTracesProcessing(
|
||||
// ctx,
|
||||
// processorFactories,
|
||||
// configGenerator,
|
||||
// simulatorInput,
|
||||
// timeout,
|
||||
// )
|
||||
// if simErr != nil {
|
||||
// if errors.Is(simErr, collectorsimulator.ErrInvalidConfig) {
|
||||
// return nil, nil, errors.WrapInvalidInputf(simErr, errors.CodeInvalidInput, "invalid config")
|
||||
// }
|
||||
// return nil, nil, errors.WrapInternalf(simErr, ErrCodeSpanMapperSimulationFailed, "could not simulate span mapper processing")
|
||||
// }
|
||||
|
||||
// outputSpans := PTracesToSpans(outputTraces)
|
||||
|
||||
// sort.Slice(outputSpans, func(i, j int) bool {
|
||||
// iIdx, _ := outputSpans[i].Attributes[spanInputOrderAttr].(int64)
|
||||
// jIdx, _ := outputSpans[j].Attributes[spanInputOrderAttr].(int64)
|
||||
// return iIdx < jIdx
|
||||
// })
|
||||
// for _, s := range outputSpans {
|
||||
// delete(s.Attributes, spanInputOrderAttr)
|
||||
// }
|
||||
|
||||
// collectorWarnAndErrorLogs := []string{}
|
||||
// for _, log := range collectorErrs {
|
||||
// if log == "" || strings.Contains(log, "featuregate.go") {
|
||||
// continue
|
||||
// }
|
||||
// collectorWarnAndErrorLogs = append(collectorWarnAndErrorLogs, log)
|
||||
// }
|
||||
|
||||
// return outputSpans, collectorWarnAndErrorLogs, nil
|
||||
// }
|
||||
|
||||
// SpansToPTraces packs each input span into its own ptrace.Traces with one
|
||||
// ResourceSpans / ScopeSpans / Span carrying its attribute and resource maps.
|
||||
func SpansToPTraces(spans []SpanMapperTestSpan) []ptrace.Traces {
|
||||
result := make([]ptrace.Traces, 0, len(spans))
|
||||
for _, s := range spans {
|
||||
td := ptrace.NewTraces()
|
||||
rs := td.ResourceSpans().AppendEmpty()
|
||||
if s.Resource != nil {
|
||||
_ = rs.Resource().Attributes().FromRaw(s.Resource)
|
||||
}
|
||||
sl := rs.ScopeSpans().AppendEmpty()
|
||||
span := sl.Spans().AppendEmpty()
|
||||
if s.Attributes != nil {
|
||||
_ = span.Attributes().FromRaw(s.Attributes)
|
||||
}
|
||||
result = append(result, td)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// PTracesToSpans flattens simulator output back into SpanMapperTestSpan: one
|
||||
// entry per individual Span across all ResourceSpans / ScopeSpans.
|
||||
func PTracesToSpans(traces []ptrace.Traces) []SpanMapperTestSpan {
|
||||
result := []SpanMapperTestSpan{}
|
||||
for _, td := range traces {
|
||||
rss := td.ResourceSpans()
|
||||
for i := 0; i < rss.Len(); i++ {
|
||||
rs := rss.At(i)
|
||||
resourceAttrs := rs.Resource().Attributes().AsRaw()
|
||||
ilss := rs.ScopeSpans()
|
||||
for j := 0; j < ilss.Len(); j++ {
|
||||
spans := ilss.At(j).Spans()
|
||||
for k := 0; k < spans.Len(); k++ {
|
||||
result = append(result, SpanMapperTestSpan{
|
||||
Attributes: spans.At(k).Attributes().AsRaw(),
|
||||
Resource: resourceAttrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func filterEnabledGroupsWithMappers(groups []*SpanMapperGroupWithMappers) []*SpanMapperGroupWithMappers {
|
||||
out := make([]*SpanMapperGroupWithMappers, 0, len(groups))
|
||||
for _, gm := range groups {
|
||||
if gm == nil || gm.Group == nil || !gm.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
enabled := make([]*SpanMapper, 0, len(gm.Mappers))
|
||||
for _, m := range gm.Mappers {
|
||||
if m != nil && m.Enabled {
|
||||
enabled = append(enabled, m)
|
||||
}
|
||||
}
|
||||
if len(enabled) > 0 {
|
||||
out = append(out, &SpanMapperGroupWithMappers{Group: gm.Group, Mappers: enabled})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
53
pkg/types/spantypes/spanmappertest.go
Normal file
53
pkg/types/spantypes/spanmappertest.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type SpanMapperTestSpan struct {
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
Resource map[string]any `json:"resource"`
|
||||
}
|
||||
|
||||
// Mappers is optional because the module can backfill from the store by Group.Name.
|
||||
type PostableSpanMapperTestGroup struct {
|
||||
PostableSpanMapperGroup
|
||||
Mappers []PostableSpanMapper `json:"mappers"`
|
||||
}
|
||||
|
||||
type PostableSpanMapperTest struct {
|
||||
Spans []SpanMapperTestSpan `json:"spans" required:"true"`
|
||||
Groups []PostableSpanMapperTestGroup `json:"groups" required:"true"`
|
||||
}
|
||||
|
||||
type GettableSpanMapperTest struct {
|
||||
Spans []SpanMapperTestSpan `json:"spans" required:"true"`
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupsWithMappersFromPostable(orgID valuer.UUID, in []PostableSpanMapperTestGroup) []*SpanMapperGroupWithMappers {
|
||||
out := make([]*SpanMapperGroupWithMappers, 0, len(in))
|
||||
for _, pg := range in {
|
||||
var mappers []*SpanMapper
|
||||
if pg.Mappers != nil {
|
||||
mappers = make([]*SpanMapper, 0, len(pg.Mappers))
|
||||
for _, pm := range pg.Mappers {
|
||||
mappers = append(mappers, &SpanMapper{
|
||||
Name: pm.Name,
|
||||
FieldContext: pm.FieldContext,
|
||||
Config: pm.Config,
|
||||
Enabled: pm.Enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, &SpanMapperGroupWithMappers{
|
||||
Group: &SpanMapperGroup{
|
||||
OrgID: orgID,
|
||||
Name: pg.Name,
|
||||
Condition: pg.Condition,
|
||||
Enabled: pg.Enabled,
|
||||
},
|
||||
Mappers: mappers,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -22,7 +21,7 @@ const (
|
||||
// BodyJSONStringSearchPrefix is the prefix used for body JSON search queries.
|
||||
// e.g., "body.status" where "body." is the prefix.
|
||||
BodyJSONStringSearchPrefix = "body."
|
||||
ArraySep = jsontypeexporter.ArraySeparator
|
||||
ArraySep = "[]."
|
||||
ArraySepSuffix = "[]"
|
||||
// TODO(Piyush): Remove once we've migrated to the new array syntax.
|
||||
ArrayAnyIndex = "[*]."
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -76,7 +75,7 @@ func (n *JSONAccessNode) Alias() string {
|
||||
parentAlias := strings.TrimLeft(n.Parent.Alias(), "`")
|
||||
parentAlias = strings.TrimRight(parentAlias, "`")
|
||||
|
||||
sep := jsontypeexporter.ArraySeparator
|
||||
sep := "[]."
|
||||
if n.Parent.isRoot {
|
||||
sep = "."
|
||||
}
|
||||
|
||||
27
tests/integration/testdata/inframonitoring/namespaces_same_name_across_clusters.jsonl
vendored
Normal file
27
tests/integration/testdata/inframonitoring/namespaces_same_name_across_clusters.jsonl
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
60
tests/integration/testdata/inframonitoring/volumes_same_name_across_ns_and_clusters.jsonl
vendored
Normal file
60
tests/integration/testdata/inframonitoring/volumes_same_name_across_ns_and_clusters.jsonl
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
@@ -317,23 +317,94 @@ def test_namespaces_pod_phase_aggregation(
|
||||
}
|
||||
|
||||
|
||||
# Float record fields compared with tolerance; everything else compared with ==.
|
||||
_GROUPBY_FLOAT_FIELDS = {
|
||||
"namespaceCPU",
|
||||
"namespaceMemory",
|
||||
}
|
||||
|
||||
|
||||
def _phase(pending=0, running=0, succeeded=0, failed=0, unknown=0) -> dict:
|
||||
return {"pending": pending, "running": running, "succeeded": succeeded, "failed": failed, "unknown": unknown}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"group_key,expected_running",
|
||||
"scenario",
|
||||
[
|
||||
# groupBy=[k8s.namespace.name]: one record per namespace, namespaceName
|
||||
# populated (namespaces.go:27-30). Each namespace has 1 running pod.
|
||||
# Explicit groupBy=[k8s.namespace.name]: one record per namespace,
|
||||
# namespaceName populated (namespaces.go:27-30), response grouped_list.
|
||||
# Each namespace has 1 running pod.
|
||||
pytest.param(
|
||||
"k8s.namespace.name",
|
||||
{"gb-ns-1": 1, "gb-ns-2": 1, "gb-ns-3": 1, "gb-ns-4": 1},
|
||||
{
|
||||
"fixture": "namespaces_groupby.jsonl",
|
||||
"group_by": "k8s.namespace.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.namespace.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-ns-1": {"namespaceName": "gb-ns-1", "podCountsByPhase": _phase(running=1)},
|
||||
"gb-ns-2": {"namespaceName": "gb-ns-2", "podCountsByPhase": _phase(running=1)},
|
||||
"gb-ns-3": {"namespaceName": "gb-ns-3", "podCountsByPhase": _phase(running=1)},
|
||||
"gb-ns-4": {"namespaceName": "gb-ns-4", "podCountsByPhase": _phase(running=1)},
|
||||
},
|
||||
},
|
||||
id="namespace_name",
|
||||
),
|
||||
# groupBy=[k8s.cluster.name]: aggregated across each cluster's 2
|
||||
# namespaces, namespaceName empty. Each cluster has 2 x 1 = 2 running pods.
|
||||
# Explicit groupBy=[k8s.cluster.name]: aggregated across each cluster's 2
|
||||
# namespaces, namespaceName empty, response grouped_list. 2 running each.
|
||||
pytest.param(
|
||||
"k8s.cluster.name",
|
||||
{"gb-cluster-a": 2, "gb-cluster-b": 2},
|
||||
{
|
||||
"fixture": "namespaces_groupby.jsonl",
|
||||
"group_by": "k8s.cluster.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.cluster.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-cluster-a": {"namespaceName": "", "podCountsByPhase": _phase(running=2)},
|
||||
"gb-cluster-b": {"namespaceName": "", "podCountsByPhase": _phase(running=2)},
|
||||
},
|
||||
},
|
||||
id="cluster",
|
||||
),
|
||||
# Default groupBy (no groupBy in request) => [k8s.namespace.name,
|
||||
# k8s.cluster.name] (module.go ListNamespaces), response list. Namespaces
|
||||
# are cluster-scoped, so a same-named namespace must NOT collapse across
|
||||
# clusters; the empty-cluster group (k8s.cluster.name label absent on the
|
||||
# source pods) must appear as its own row with real metrics, not be dropped.
|
||||
# Single pod per group => SpaceAggregationSum == seeded value.
|
||||
# Fails on the pre-cluster default (name only) — the three groups would
|
||||
# collapse into one summed row.
|
||||
pytest.param(
|
||||
{
|
||||
"fixture": "namespaces_same_name_across_clusters.jsonl",
|
||||
"group_by": None,
|
||||
"filter": "k8s.namespace.name = 'dup-ns'",
|
||||
"group_meta_keys": ["k8s.namespace.name", "k8s.cluster.name"],
|
||||
"expected_type": "list",
|
||||
"groups": {
|
||||
("dup-ns", "cluster-a"): {
|
||||
"namespaceName": "dup-ns",
|
||||
"namespaceCPU": 0.3,
|
||||
"namespaceMemory": 100000000.0,
|
||||
"podCountsByPhase": _phase(running=1),
|
||||
},
|
||||
("dup-ns", "cluster-b"): {
|
||||
"namespaceName": "dup-ns",
|
||||
"namespaceCPU": 0.5,
|
||||
"namespaceMemory": 300000000.0,
|
||||
"podCountsByPhase": _phase(failed=1),
|
||||
},
|
||||
# empty-cluster group: k8s.cluster.name label absent on the source pods.
|
||||
("dup-ns", ""): {
|
||||
"namespaceName": "dup-ns",
|
||||
"namespaceCPU": 0.1,
|
||||
"namespaceMemory": 200000000.0,
|
||||
"podCountsByPhase": _phase(pending=1),
|
||||
},
|
||||
},
|
||||
},
|
||||
id="default_disambiguates_cluster",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_namespaces_groupby(
|
||||
@@ -341,55 +412,64 @@ def test_namespaces_groupby(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
group_key: str,
|
||||
expected_running: dict,
|
||||
scenario: dict,
|
||||
) -> None:
|
||||
"""groupBy returns one record per distinct group with aggregated pod-phase
|
||||
counts. namespaceName is populated only when grouping by k8s.namespace.name
|
||||
(namespaces.go:27-30 list-vs-grouped branch); meta surfaces the groupBy key."""
|
||||
"""groupBy determines row identity. Explicit groupBy returns one grouped_list
|
||||
record per distinct group (namespaceName populated only when grouping by
|
||||
k8s.namespace.name; namespaces.go:27-30). With no groupBy the default is
|
||||
[k8s.namespace.name, k8s.cluster.name] (module.go ListNamespaces), so
|
||||
same-named namespaces across clusters stay as separate, un-collapsed list rows
|
||||
(incl. an absent-cluster group keyed by ""). meta always surfaces the grouping
|
||||
key(s)."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/namespaces_groupby.jsonl"),
|
||||
get_testdata_file_path(f"inframonitoring/{scenario['fixture']}"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
|
||||
body: dict = {
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
}
|
||||
if scenario["group_by"] is not None:
|
||||
body["groupBy"] = [{"name": scenario["group_by"], "fieldDataType": "string", "fieldContext": "resource"}]
|
||||
if scenario["filter"] is not None:
|
||||
body["filter"] = {"expression": scenario["filter"]}
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
"groupBy": [
|
||||
{
|
||||
"name": group_key,
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "resource",
|
||||
}
|
||||
],
|
||||
},
|
||||
json=body,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["total"] == len(expected_running)
|
||||
|
||||
group_of = lambda r: r["namespaceName"] if group_key == "k8s.namespace.name" else r["meta"][group_key] # noqa: E731 # pylint: disable=unnecessary-lambda-assignment
|
||||
by_group = {group_of(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == set(expected_running.keys())
|
||||
groups = scenario["groups"]
|
||||
meta_keys = scenario["group_meta_keys"]
|
||||
assert data["type"] == scenario["expected_type"]
|
||||
assert data["total"] == len(groups)
|
||||
|
||||
for group, running in expected_running.items():
|
||||
rec = by_group[group]
|
||||
# namespaceName populated per namespace when grouping by k8s.namespace.name,
|
||||
# empty otherwise.
|
||||
assert rec["namespaceName"] == (group if group_key == "k8s.namespace.name" else "")
|
||||
assert rec["podCountsByPhase"]["running"] == running
|
||||
for other in ("pending", "succeeded", "failed", "unknown"):
|
||||
assert rec["podCountsByPhase"][other] == 0
|
||||
assert group_key in rec["meta"], rec["meta"]
|
||||
def _gid(rec: dict):
|
||||
vals = [rec["meta"][k] for k in meta_keys]
|
||||
return vals[0] if len(vals) == 1 else tuple(vals)
|
||||
|
||||
by_group = {_gid(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == set(groups.keys())
|
||||
|
||||
for gid, exp in groups.items():
|
||||
rec = by_group[gid]
|
||||
for k in meta_keys:
|
||||
assert k in rec["meta"], rec["meta"]
|
||||
for field, val in exp.items():
|
||||
if field in _GROUPBY_FLOAT_FIELDS:
|
||||
assert compare_values(rec[field], val, 1e-6), f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
else:
|
||||
assert rec[field] == val, f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
|
||||
|
||||
def test_namespaces_pagination(
|
||||
|
||||
@@ -376,23 +376,111 @@ def test_volumes_non_pvc_volume_filtered(
|
||||
assert rec["persistentVolumeClaimName"] == "np-real-pvc"
|
||||
|
||||
|
||||
# Float record fields compared with tolerance; everything else compared with ==.
|
||||
_GROUPBY_FLOAT_FIELDS = {
|
||||
"volumeAvailable",
|
||||
"volumeCapacity",
|
||||
"volumeUsage",
|
||||
"volumeInodes",
|
||||
"volumeInodesFree",
|
||||
"volumeInodesUsed",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"group_key,expected_groups",
|
||||
"scenario",
|
||||
[
|
||||
# groupBy=[k8s.persistentvolumeclaim.name]: one record per PVC,
|
||||
# persistentVolumeClaimName populated (volumes.go:26-29).
|
||||
# Explicit groupBy=[k8s.persistentvolumeclaim.name]: one record per PVC,
|
||||
# persistentVolumeClaimName populated (volumes.go:26-29), response grouped_list.
|
||||
pytest.param(
|
||||
"k8s.persistentvolumeclaim.name",
|
||||
{"gb-pvc-a1", "gb-pvc-a2", "gb-pvc-b1", "gb-pvc-b2"},
|
||||
{
|
||||
"fixture": "volumes_groupby.jsonl",
|
||||
"group_by": "k8s.persistentvolumeclaim.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.persistentvolumeclaim.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-pvc-a1": {"persistentVolumeClaimName": "gb-pvc-a1"},
|
||||
"gb-pvc-a2": {"persistentVolumeClaimName": "gb-pvc-a2"},
|
||||
"gb-pvc-b1": {"persistentVolumeClaimName": "gb-pvc-b1"},
|
||||
"gb-pvc-b2": {"persistentVolumeClaimName": "gb-pvc-b2"},
|
||||
},
|
||||
},
|
||||
id="pvc_name",
|
||||
),
|
||||
# groupBy=[k8s.namespace.name]: aggregated per namespace,
|
||||
# persistentVolumeClaimName cleared (custom-groupBy branch).
|
||||
# Explicit groupBy=[k8s.namespace.name]: aggregated per namespace,
|
||||
# persistentVolumeClaimName cleared, response grouped_list.
|
||||
pytest.param(
|
||||
"k8s.namespace.name",
|
||||
{"gb-ns-a", "gb-ns-b"},
|
||||
{
|
||||
"fixture": "volumes_groupby.jsonl",
|
||||
"group_by": "k8s.namespace.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.namespace.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-ns-a": {"persistentVolumeClaimName": ""},
|
||||
"gb-ns-b": {"persistentVolumeClaimName": ""},
|
||||
},
|
||||
},
|
||||
id="namespace",
|
||||
),
|
||||
# Default groupBy (no groupBy in request) => [k8s.persistentvolumeclaim.name,
|
||||
# k8s.namespace.name, k8s.cluster.name] (module.go ListVolumes), response list.
|
||||
# Same PVC name must NOT collapse across namespaces OR clusters; the
|
||||
# empty-cluster group (k8s.cluster.name label absent on the source series)
|
||||
# must appear as its own row with real metrics, not be dropped.
|
||||
# Single series per group => SpaceAggregationSum == seeded value.
|
||||
# Fails on the pre-cluster default (name+ns) — the three ns-x groups would
|
||||
# collapse into one summed row.
|
||||
pytest.param(
|
||||
{
|
||||
"fixture": "volumes_same_name_across_ns_and_clusters.jsonl",
|
||||
"group_by": None,
|
||||
"filter": "k8s.persistentvolumeclaim.name = 'dup-pvc'",
|
||||
"group_meta_keys": ["k8s.persistentvolumeclaim.name", "k8s.namespace.name", "k8s.cluster.name"],
|
||||
"expected_type": "list",
|
||||
"groups": {
|
||||
("dup-pvc", "ns-x", "cluster-a"): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 100.0,
|
||||
"volumeAvailable": 60.0,
|
||||
"volumeUsage": 40.0,
|
||||
"volumeInodes": 1000.0,
|
||||
"volumeInodesFree": 600.0,
|
||||
"volumeInodesUsed": 400.0,
|
||||
},
|
||||
("dup-pvc", "ns-y", "cluster-a"): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 500.0,
|
||||
"volumeAvailable": 100.0,
|
||||
"volumeUsage": 400.0,
|
||||
"volumeInodes": 5000.0,
|
||||
"volumeInodesFree": 1000.0,
|
||||
"volumeInodesUsed": 4000.0,
|
||||
},
|
||||
("dup-pvc", "ns-x", "cluster-b"): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 300.0,
|
||||
"volumeAvailable": 50.0,
|
||||
"volumeUsage": 250.0,
|
||||
"volumeInodes": 3000.0,
|
||||
"volumeInodesFree": 500.0,
|
||||
"volumeInodesUsed": 2500.0,
|
||||
},
|
||||
# empty-cluster group: k8s.cluster.name label absent on the source series.
|
||||
("dup-pvc", "ns-x", ""): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 200.0,
|
||||
"volumeAvailable": 0.0,
|
||||
"volumeUsage": 200.0,
|
||||
"volumeInodes": 2000.0,
|
||||
"volumeInodesFree": 0.0,
|
||||
"volumeInodesUsed": 2000.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
id="default_disambiguates_ns_and_cluster",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_volumes_groupby(
|
||||
@@ -400,51 +488,64 @@ def test_volumes_groupby(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
group_key: str,
|
||||
expected_groups: set,
|
||||
scenario: dict,
|
||||
) -> None:
|
||||
"""groupBy returns one record per distinct group. persistentVolumeClaimName
|
||||
is populated only when grouping by k8s.persistentvolumeclaim.name
|
||||
(volumes.go:26-29 list-vs-grouped branch); meta surfaces the groupBy key."""
|
||||
"""groupBy determines row identity. Explicit groupBy returns one grouped_list
|
||||
record per distinct group (persistentVolumeClaimName populated only when
|
||||
grouping by k8s.persistentvolumeclaim.name; volumes.go:26-29). With no groupBy
|
||||
the default is [k8s.persistentvolumeclaim.name, k8s.namespace.name,
|
||||
k8s.cluster.name] (module.go ListVolumes), so same-named PVCs across
|
||||
namespaces/clusters stay as separate, un-collapsed list rows (incl. an
|
||||
absent-cluster group keyed by ""). meta always surfaces the grouping key(s)."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/volumes_groupby.jsonl"),
|
||||
get_testdata_file_path(f"inframonitoring/{scenario['fixture']}"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
|
||||
body: dict = {
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
}
|
||||
if scenario["group_by"] is not None:
|
||||
body["groupBy"] = [{"name": scenario["group_by"], "fieldDataType": "string", "fieldContext": "resource"}]
|
||||
if scenario["filter"] is not None:
|
||||
body["filter"] = {"expression": scenario["filter"]}
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
"groupBy": [
|
||||
{
|
||||
"name": group_key,
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "resource",
|
||||
}
|
||||
],
|
||||
},
|
||||
json=body,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["total"] == len(expected_groups)
|
||||
|
||||
is_pvc_group = group_key == "k8s.persistentvolumeclaim.name"
|
||||
group_of = lambda r: r["persistentVolumeClaimName"] if is_pvc_group else r["meta"][group_key] # noqa: E731 # pylint: disable=unnecessary-lambda-assignment
|
||||
by_group = {group_of(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == expected_groups
|
||||
groups = scenario["groups"]
|
||||
meta_keys = scenario["group_meta_keys"]
|
||||
assert data["type"] == scenario["expected_type"]
|
||||
assert data["total"] == len(groups)
|
||||
|
||||
for group, rec in by_group.items():
|
||||
# persistentVolumeClaimName populated per PVC when grouping by it, empty otherwise.
|
||||
assert rec["persistentVolumeClaimName"] == (group if is_pvc_group else "")
|
||||
assert group_key in rec["meta"], rec["meta"]
|
||||
def _gid(rec: dict):
|
||||
vals = [rec["meta"][k] for k in meta_keys]
|
||||
return vals[0] if len(vals) == 1 else tuple(vals)
|
||||
|
||||
by_group = {_gid(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == set(groups.keys())
|
||||
|
||||
for gid, exp in groups.items():
|
||||
rec = by_group[gid]
|
||||
for k in meta_keys:
|
||||
assert k in rec["meta"], rec["meta"]
|
||||
for field, val in exp.items():
|
||||
if field in _GROUPBY_FLOAT_FIELDS:
|
||||
assert compare_values(rec[field], val, 1e-6), f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
else:
|
||||
assert rec[field] == val, f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
|
||||
|
||||
def test_volumes_pagination(
|
||||
|
||||
@@ -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_404(
|
||||
def test_non_existent_metrics_returns_warning(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
@@ -635,9 +635,11 @@ def test_non_existent_metrics_returns_404(
|
||||
|
||||
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.NOT_FOUND
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
assert get_error_message(response.json()) == "could not find the metric whatevergoennnsgoeshere"
|
||||
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}"
|
||||
|
||||
|
||||
def test_non_existent_internal_metrics_returns_no_warning(
|
||||
|
||||
Reference in New Issue
Block a user