mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-15 19:00:34 +01:00
Compare commits
15 Commits
feat/dashb
...
issue_5267
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72448fa163 | ||
|
|
a45f6341da | ||
|
|
f534ea2467 | ||
|
|
d4843ecd8b | ||
|
|
477b81892d | ||
|
|
891106d1ca | ||
|
|
9604438723 | ||
|
|
e0a66b7a10 | ||
|
|
b01be1f31c | ||
|
|
c8a007cd8d | ||
|
|
205b23b830 | ||
|
|
68b166f75e | ||
|
|
20d8ca6d7d | ||
|
|
1e1be670f1 | ||
|
|
d1682f2ab6 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -57,6 +57,7 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -8048,6 +8048,28 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableSpanMapperTest:
|
||||
properties:
|
||||
collectorLogs:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesGettableSpanMappers:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapper'
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -8135,6 +8157,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:
|
||||
@@ -8200,7 +8255,7 @@ components:
|
||||
type: boolean
|
||||
fieldContext:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
group_id:
|
||||
groupId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
@@ -8213,7 +8268,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- group_id
|
||||
- groupId
|
||||
- name
|
||||
- fieldContext
|
||||
- config
|
||||
@@ -8296,6 +8351,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:
|
||||
@@ -13792,7 +13858,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -14025,6 +14091,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
|
||||
|
||||
@@ -9258,6 +9258,118 @@ 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
|
||||
*/
|
||||
collectorLogs?: string[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans?: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
groupId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMappersDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
items: SpantypesSpanMapperDTO[];
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -9504,30 +9616,6 @@ export interface SpantypesPostableFlamegraphDTO {
|
||||
selectedSpanId?: string;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -9553,6 +9641,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;
|
||||
@@ -9576,45 +9691,6 @@ export interface SpantypesPostableWaterfallDTO {
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesUpdatableSpanMapperDTO {
|
||||
config?: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -10916,7 +10992,7 @@ export type ListSpanMappersPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperGroupsDTO;
|
||||
data: SpantypesGettableSpanMappersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10942,6 +11018,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));
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
return {
|
||||
id: 'mapper-1',
|
||||
group_id: 'group-1',
|
||||
groupId: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
fieldContext: FieldContext.attribute,
|
||||
@@ -89,5 +89,5 @@ export const mockGroups: MapperGroup[] = [
|
||||
];
|
||||
|
||||
export const mockMappers: Mapper[] = [
|
||||
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
|
||||
makeMapper({ id: 'mapper-1', groupId: 'group-1' }),
|
||||
];
|
||||
|
||||
@@ -3,7 +3,6 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
buildVariableReferencePattern,
|
||||
containsAnyVariableReference,
|
||||
extractQueryTextStrings,
|
||||
getVariableReferencesInQuery,
|
||||
textContainsVariableReference,
|
||||
@@ -449,25 +448,3 @@ describe('getVariableReferencesInQuery', () => {
|
||||
expect(getVariableReferencesInQuery(query, [])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsAnyVariableReference', () => {
|
||||
it.each([
|
||||
['SELECT count() FROM t WHERE service = $service.name', true],
|
||||
['up{env="$deployment_environment"}', true],
|
||||
['{{.service_name}}', true],
|
||||
['{{ service_name }}', true],
|
||||
['[[service_name]]', true],
|
||||
['$_private', true],
|
||||
])('detects a reference in %p', (text, expected) => {
|
||||
expect(containsAnyVariableReference(text)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['SELECT count() FROM t WHERE x = 1', false],
|
||||
['rate(http_requests[$__interval])', false],
|
||||
['SELECT $1 FROM t', false],
|
||||
['', false],
|
||||
])('does not falsely match %p', (text, expected) => {
|
||||
expect(containsAnyVariableReference(text)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeRegExp, isArray } from 'lodash-es';
|
||||
import { isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -33,23 +33,6 @@ export function textContainsVariableReference(
|
||||
return buildVariableReferencePattern(variableName).test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches *any* variable reference in a recognized syntax without knowing the
|
||||
* name: `{{name}}`, `{{.name}}`, `[[name]]`, or `$name`. The `$` form excludes
|
||||
* `$__…` macros and positional `$1` so built-ins don't read as variables.
|
||||
*/
|
||||
const ANY_VARIABLE_REFERENCE =
|
||||
/\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$(?!__)[a-zA-Z_][\w.]*/;
|
||||
|
||||
/**
|
||||
* Returns true if `text` contains a reference to any variable. Use when the set
|
||||
* of variable names isn't known yet (e.g. before the fetch context initializes),
|
||||
* so a name-based {@link textContainsVariableReference} check can't run.
|
||||
*/
|
||||
export function containsAnyVariableReference(text: string): boolean {
|
||||
return !!text && ANY_VARIABLE_REFERENCE.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all text strings from a widget Query that could contain variable
|
||||
* references. Covers:
|
||||
@@ -151,52 +134,3 @@ export function getVariableReferencesInQuery(
|
||||
texts.some((text) => textContainsVariableReference(text, name)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites every reference to `oldName` in `text` to `newName`, preserving the
|
||||
* surrounding syntax for each recognized form ({{.x}}, {{x}}, $x, [[x]]). Used
|
||||
* when a variable is renamed so its usages across queries stay valid.
|
||||
*/
|
||||
export function rewriteVariableReferences(
|
||||
text: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): string {
|
||||
if (!text || !oldName || oldName === newName) {
|
||||
return text;
|
||||
}
|
||||
const name = escapeRegExp(oldName);
|
||||
return text
|
||||
.replace(
|
||||
new RegExp(`(\\{\\{\\s*?\\.)${name}(\\s*?\\}\\})`, 'g'),
|
||||
`$1${newName}$2`,
|
||||
)
|
||||
.replace(new RegExp(`(\\{\\{\\s*)${name}(\\s*\\}\\})`, 'g'), `$1${newName}$2`)
|
||||
.replace(new RegExp(`\\$${name}\\b`, 'g'), `$${newName}`)
|
||||
.replace(
|
||||
new RegExp(`(\\[\\[\\s*)${name}(\\s*\\]\\])`, 'g'),
|
||||
`$1${newName}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort removal of the clause that references `variableName` from an
|
||||
* ` AND `-joined filter expression (e.g. a builder query's `filter.expression`).
|
||||
* Any top-level `AND` part that references the variable is dropped. It does not
|
||||
* understand `OR`/nested parentheses, so it is a starting point the user reviews
|
||||
* before applying — never an automatic edit of raw PromQL/ClickHouse.
|
||||
*/
|
||||
export function removeVariableReferenceClause(
|
||||
expression: string,
|
||||
variableName: string,
|
||||
): string {
|
||||
if (!expression) {
|
||||
return expression;
|
||||
}
|
||||
return expression
|
||||
.split(' AND ')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.filter((part) => !textContainsVariableReference(part, variableName))
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.intro {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.rowHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sourceLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kindTag {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning-foreground, #d97706);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea + Checkbox have no @signozhq/ui equivalent yet
|
||||
import { Checkbox, Input as AntdInput } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableImpactMode, VariableUsage } from '../variableUsages';
|
||||
import { useVariableImpactState } from './useVariableImpactState';
|
||||
import styles from './VariableImpactDialog.module.scss';
|
||||
|
||||
const KIND_LABEL: Record<VariableUsage['kind'], string> = {
|
||||
builder: 'Query builder',
|
||||
promql: 'PromQL',
|
||||
clickhouse: 'ClickHouse',
|
||||
variable: 'Variable',
|
||||
};
|
||||
|
||||
interface VariableImpactDialogProps {
|
||||
open: boolean;
|
||||
mode: VariableImpactMode;
|
||||
/** The variable being renamed/deleted (its current name). */
|
||||
variableName: string;
|
||||
/** The new name (rename mode only). */
|
||||
newName?: string;
|
||||
usages: VariableUsage[];
|
||||
isLoading: boolean;
|
||||
onConfirm: (resolvedUsages: VariableUsage[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks a rename/delete of a referenced variable behind a review step: lists
|
||||
* every usage across panel queries (builder / PromQL / ClickHouse) and other
|
||||
* variables, shows the current vs resulting query, and lets the user edit each
|
||||
* result or exclude it before applying.
|
||||
*/
|
||||
function VariableImpactDialog({
|
||||
open,
|
||||
mode,
|
||||
variableName,
|
||||
newName,
|
||||
usages,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: VariableImpactDialogProps): JSX.Element {
|
||||
const { rows, setResultingText, toggleIncluded, resolvedUsages } =
|
||||
useVariableImpactState(usages, open);
|
||||
|
||||
const isRename = mode === 'rename';
|
||||
const count = usages.length;
|
||||
const plural = count === 1 ? '' : 's';
|
||||
const intro = isRename
|
||||
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
|
||||
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="variable-impact-cancel"
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color={isRename ? 'primary' : 'destructive'}
|
||||
loading={isLoading}
|
||||
onClick={(): void => onConfirm(resolvedUsages)}
|
||||
testId="variable-impact-confirm"
|
||||
>
|
||||
<Check size={12} />
|
||||
{isRename ? 'Rename' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
|
||||
width="wide"
|
||||
showCloseButton={false}
|
||||
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
|
||||
style={{ zIndex: 1100 }}
|
||||
showOverlay={false}
|
||||
footer={footer}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
|
||||
<div className={styles.rows}>
|
||||
{rows.map((row) => {
|
||||
const stillReferences =
|
||||
row.included &&
|
||||
textContainsVariableReference(row.resultingText, variableName);
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={styles.row}
|
||||
data-testid={`variable-impact-row-${row.id}`}
|
||||
>
|
||||
<div className={styles.rowHeader}>
|
||||
<Checkbox
|
||||
checked={row.included}
|
||||
onChange={(): void => toggleIncluded(row.id)}
|
||||
data-testid={`variable-impact-include-${row.id}`}
|
||||
>
|
||||
<span className={styles.sourceLabel}>{row.sourceLabel}</span>
|
||||
</Checkbox>
|
||||
<span className={styles.kindTag}>{KIND_LABEL[row.kind]}</span>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>
|
||||
Current
|
||||
</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={styles.textArea}
|
||||
value={row.currentText}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>Result</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={cx(styles.textArea, !row.included && styles.disabled)}
|
||||
value={row.resultingText}
|
||||
disabled={!row.included}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
onChange={(e): void => setResultingText(row.id, e.target.value)}
|
||||
data-testid={`variable-impact-result-${row.id}`}
|
||||
/>
|
||||
{stillReferences ? (
|
||||
<Typography.Text className={styles.warning}>
|
||||
Still references ${variableName}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableImpactDialog;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { VariableUsage } from '../variableUsages';
|
||||
|
||||
/** A usage row plus whether its edit will be applied on confirm. */
|
||||
export interface EditableVariableUsage extends VariableUsage {
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
interface UseVariableImpactState {
|
||||
rows: EditableVariableUsage[];
|
||||
setResultingText: (id: string, text: string) => void;
|
||||
toggleIncluded: (id: string) => void;
|
||||
/** The included rows, as plain usages, to build the patch from. */
|
||||
resolvedUsages: VariableUsage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable state for the impact dialog: a per-usage copy the user can edit
|
||||
* (`resultingText`) and include/exclude before applying. Resets whenever the
|
||||
* dialog (re)opens for a fresh usage set.
|
||||
*/
|
||||
export function useVariableImpactState(
|
||||
usages: VariableUsage[],
|
||||
open: boolean,
|
||||
): UseVariableImpactState {
|
||||
const [rows, setRows] = useState<EditableVariableUsage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRows(usages.map((usage) => ({ ...usage, included: true })));
|
||||
}
|
||||
}, [open, usages]);
|
||||
|
||||
const setResultingText = useCallback((id: string, text: string): void => {
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, resultingText: text } : row)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleIncluded = useCallback((id: string): void => {
|
||||
setRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === id ? { ...row, included: !row.included } : row,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const resolvedUsages: VariableUsage[] = rows.filter((row) => row.included);
|
||||
|
||||
return { rows, setResultingText, toggleIncluded, resolvedUsages };
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import { findVariableUsages } from '../variableUsages';
|
||||
|
||||
// Identity adapter so `spec.variables` can be plain form models in the test.
|
||||
jest.mock('../variableAdapters', () => ({
|
||||
dtoToFormModel: (dto: unknown): unknown => dto,
|
||||
}));
|
||||
|
||||
function variable(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
function builderPanel(name: string, expression: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
display: { name },
|
||||
queries: [
|
||||
{
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function promqlPanel(name: string, query: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
display: { name },
|
||||
queries: [
|
||||
{ spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { query } } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dashboard(
|
||||
panels: Record<string, unknown>,
|
||||
variables: VariableFormModel[],
|
||||
): DashboardtypesGettableDashboardV2DTO {
|
||||
return {
|
||||
spec: { panels, variables },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
describe('findVariableUsages', () => {
|
||||
const dash = dashboard(
|
||||
{
|
||||
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
|
||||
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
|
||||
p3: builderPanel('Unrelated', "env = 'prod'"),
|
||||
},
|
||||
[
|
||||
variable({ name: 'svc', type: 'QUERY' }),
|
||||
variable({
|
||||
name: 'other',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT x WHERE s = $svc',
|
||||
}),
|
||||
variable({ name: 'plain', type: 'QUERY', queryValue: 'SELECT y' }),
|
||||
],
|
||||
);
|
||||
|
||||
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
|
||||
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
|
||||
const ids = usages.map((u) => u.id).sort();
|
||||
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
|
||||
});
|
||||
|
||||
it('rewrites references for a rename across all kinds', () => {
|
||||
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
|
||||
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
|
||||
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
|
||||
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
|
||||
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
|
||||
});
|
||||
|
||||
it('strips builder clauses on delete but leaves raw/variable queries for review', () => {
|
||||
const usages = findVariableUsages(dash, 'svc', 'delete');
|
||||
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
|
||||
// Builder: the clause referencing $svc is dropped.
|
||||
expect(byId['panel:p1:0']).toBe("env = 'prod'");
|
||||
// Raw PromQL + variable query: unchanged (user edits).
|
||||
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
|
||||
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
|
||||
});
|
||||
|
||||
it('returns nothing for an unreferenced variable', () => {
|
||||
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,17 +17,7 @@ import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from './variableFormModel';
|
||||
import {
|
||||
applyVariableQueryEdits,
|
||||
buildVariableImpactPatch,
|
||||
} from './variableImpactPatch';
|
||||
import {
|
||||
findVariableUsages,
|
||||
type VariableImpactMode,
|
||||
type VariableUsage,
|
||||
} from './variableUsages';
|
||||
import VariableForm from './VariableForm/VariableForm';
|
||||
import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
@@ -69,16 +59,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
null,
|
||||
);
|
||||
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
|
||||
// A pending rename/delete that touches other queries — resolved via the impact
|
||||
// dialog before it is applied. `nextVariables` is the array to persist (with the
|
||||
// rename/delete already applied), before any variable-query edits.
|
||||
const [impact, setImpact] = useState<{
|
||||
mode: VariableImpactMode;
|
||||
variableName: string;
|
||||
newName?: string;
|
||||
usages: VariableUsage[];
|
||||
nextVariables: VariableFormModel[];
|
||||
} | null>(null);
|
||||
|
||||
const editingFormModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!isEditing) {
|
||||
@@ -124,38 +104,12 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
formModel: VariableFormModel,
|
||||
selectedPanelIds: string[],
|
||||
): void => {
|
||||
const editingIndex = isEditing?.type === 'edit' ? isEditing.index : null;
|
||||
const oldName = editingIndex !== null ? variables[editingIndex].name : null;
|
||||
|
||||
const next = [...variables];
|
||||
if (isEditing?.type === 'new') {
|
||||
next.push(formModel);
|
||||
} else if (editingIndex !== null) {
|
||||
next[editingIndex] = formModel;
|
||||
} else if (isEditing?.type === 'edit') {
|
||||
next[isEditing.index] = formModel;
|
||||
}
|
||||
|
||||
// A rename that other queries/variables reference must be reviewed first, so
|
||||
// the references are rewritten alongside the rename (never left dangling).
|
||||
if (oldName && oldName !== formModel.name) {
|
||||
const usages = findVariableUsages(
|
||||
dashboard,
|
||||
oldName,
|
||||
'rename',
|
||||
formModel.name,
|
||||
);
|
||||
if (usages.length > 0) {
|
||||
setIsEditing(null);
|
||||
setImpact({
|
||||
mode: 'rename',
|
||||
variableName: oldName,
|
||||
newName: formModel.name,
|
||||
usages,
|
||||
nextVariables: next,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsEditing(null);
|
||||
setVariables(next);
|
||||
void (async (): Promise<void> => {
|
||||
@@ -195,57 +149,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
// Delete requested from the list: if the variable is referenced anywhere, block
|
||||
// and open the impact dialog; otherwise fall through to the simple confirm.
|
||||
const requestDelete = (index: number): void => {
|
||||
const usages = findVariableUsages(dashboard, variables[index].name, 'delete');
|
||||
if (usages.length > 0) {
|
||||
setImpact({
|
||||
mode: 'delete',
|
||||
variableName: variables[index].name,
|
||||
usages,
|
||||
nextVariables: variables.filter((_, i) => i !== index),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setConfirmDeleteIndex(index);
|
||||
};
|
||||
|
||||
// Applies a resolved rename/delete: the variables array (rename/delete + edited
|
||||
// variable queries) and each touched panel's queries, in one atomic patch.
|
||||
const handleImpactConfirm = async (
|
||||
resolvedUsages: VariableUsage[],
|
||||
): Promise<void> => {
|
||||
if (!impact) {
|
||||
return;
|
||||
}
|
||||
const nextVariables = applyVariableQueryEdits(
|
||||
impact.nextVariables,
|
||||
resolvedUsages,
|
||||
);
|
||||
const ops = buildVariableImpactPatch(
|
||||
dashboard,
|
||||
nextVariables,
|
||||
resolvedUsages,
|
||||
);
|
||||
setVariables(nextVariables);
|
||||
try {
|
||||
await patchAsync(ops);
|
||||
toast.success(
|
||||
impact.mode === 'rename'
|
||||
? `Renamed to $${impact.newName}`
|
||||
: `Deleted $${impact.variableName}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
impact.mode === 'rename'
|
||||
? 'Could not rename the variable'
|
||||
: 'Could not delete the variable',
|
||||
);
|
||||
}
|
||||
setImpact(null);
|
||||
};
|
||||
|
||||
const applyToAllVariable =
|
||||
applyToAllIndex === null ? null : variables[applyToAllIndex];
|
||||
|
||||
@@ -299,7 +202,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
|
||||
onRequestDelete={requestDelete}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
@@ -317,16 +220,6 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
onConfirm={(): void => void handleConfirmApplyToAll()}
|
||||
onClose={(): void => setApplyToAllIndex(null)}
|
||||
/>
|
||||
<VariableImpactDialog
|
||||
open={impact !== null}
|
||||
mode={impact?.mode ?? 'delete'}
|
||||
variableName={impact?.variableName ?? ''}
|
||||
newName={impact?.newName}
|
||||
usages={impact?.usages ?? []}
|
||||
isLoading={isPatching}
|
||||
onConfirm={(resolved): void => void handleImpactConfirm(resolved)}
|
||||
onClose={(): void => setImpact(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import { buildVariablesPatch } from './variablePatchOps';
|
||||
import type { VariableUsage, VariableUsageKind } from './variableUsages';
|
||||
|
||||
/** Minimal writable view of an envelope spec's reference-bearing fields. */
|
||||
interface WritableSpec {
|
||||
query?: string;
|
||||
filter?: { expression?: string };
|
||||
}
|
||||
|
||||
/** Writes the resolved text into the spec's builder filter or raw query field. */
|
||||
function writeSpecText(
|
||||
spec: WritableSpec,
|
||||
kind: VariableUsageKind,
|
||||
text: string,
|
||||
): void {
|
||||
if (kind === 'builder') {
|
||||
spec.filter = { ...(spec.filter ?? {}), expression: text };
|
||||
} else {
|
||||
spec.query = text;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies one panel usage's edited text into a (cloned) queries array in place. */
|
||||
function applyPanelUsage(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
usage: VariableUsage,
|
||||
): void {
|
||||
const plugin = queries[0]?.spec?.plugin;
|
||||
if (!plugin?.spec) {
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
const envelope = (composite.queries ?? [])[usage.envelopeIndex];
|
||||
if (envelope?.spec) {
|
||||
writeSpecText(
|
||||
envelope.spec as WritableSpec,
|
||||
usage.kind,
|
||||
usage.resultingText,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Bare BuilderQuery / PromQLQuery / ClickHouseSQL — the plugin spec is the
|
||||
// single envelope (index 0).
|
||||
writeSpecText(plugin.spec as WritableSpec, usage.kind, usage.resultingText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the variable-definition usages' edited text back into the matching
|
||||
* variable's `queryValue`, so a renamed/deleted variable's references inside
|
||||
* another query variable are updated alongside the panels.
|
||||
*/
|
||||
export function applyVariableQueryEdits(
|
||||
variables: VariableFormModel[],
|
||||
usages: VariableUsage[],
|
||||
): VariableFormModel[] {
|
||||
const edits = new Map(
|
||||
usages
|
||||
.filter((usage) => usage.sourceType === 'variable')
|
||||
.map((usage) => [usage.sourceId, usage.resultingText]),
|
||||
);
|
||||
if (edits.size === 0) {
|
||||
return variables;
|
||||
}
|
||||
return variables.map((variable) =>
|
||||
edits.has(variable.name)
|
||||
? { ...variable, queryValue: edits.get(variable.name) as string }
|
||||
: variable,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the atomic JSON-Patch for a variable rename/delete impact: replaces the
|
||||
* whole variables array (which the caller has already updated for the rename/
|
||||
* delete and any variable-query edits) and replaces each touched panel's queries
|
||||
* with the user's resolved text.
|
||||
*/
|
||||
export function buildVariableImpactPatch(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
nextVariables: VariableFormModel[],
|
||||
usages: VariableUsage[],
|
||||
): DashboardtypesJSONPatchOperationDTO[] {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
...buildVariablesPatch(nextVariables.map(formModelToDto)),
|
||||
];
|
||||
|
||||
const panels = dashboard.spec.panels ?? {};
|
||||
const byPanel = new Map<string, VariableUsage[]>();
|
||||
usages
|
||||
.filter((usage) => usage.sourceType === 'panel')
|
||||
.forEach((usage) => {
|
||||
const list = byPanel.get(usage.sourceId) ?? [];
|
||||
list.push(usage);
|
||||
byPanel.set(usage.sourceId, list);
|
||||
});
|
||||
|
||||
byPanel.forEach((list, panelId) => {
|
||||
const panel = panels[panelId];
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
const queries = cloneDeep(panel.spec.queries);
|
||||
list.forEach((usage) => applyPanelUsage(queries, usage));
|
||||
ops.push({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: `/spec/panels/${panelId}/spec/queries`,
|
||||
value: queries,
|
||||
});
|
||||
});
|
||||
|
||||
return ops;
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
removeVariableReferenceClause,
|
||||
rewriteVariableReferences,
|
||||
textContainsVariableReference,
|
||||
} from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
|
||||
/** The kind of query text a variable is referenced from. */
|
||||
export type VariableUsageKind =
|
||||
| 'builder'
|
||||
| 'promql'
|
||||
| 'clickhouse'
|
||||
| 'variable';
|
||||
|
||||
/** Whether the impact is a rename (rewrite refs) or a delete (remove refs). */
|
||||
export type VariableImpactMode = 'rename' | 'delete';
|
||||
|
||||
/**
|
||||
* One place a variable is referenced — a panel query's builder filter expression,
|
||||
* a PromQL/ClickHouse query string, or another variable's query definition. Each
|
||||
* usage is a single editable text field: `currentText` is what exists today,
|
||||
* `resultingText` is the proposed rewrite (rename) or removal (delete) the user
|
||||
* can review and edit before applying.
|
||||
*/
|
||||
export interface VariableUsage {
|
||||
/** Stable key: `${sourceType}:${sourceId}:${envelopeIndex}`. */
|
||||
id: string;
|
||||
sourceType: 'panel' | 'variable';
|
||||
/** Panel id or referencing variable's name. */
|
||||
sourceId: string;
|
||||
/** Human label: panel display name or `$variableName`. */
|
||||
sourceLabel: string;
|
||||
kind: VariableUsageKind;
|
||||
/** Index into the panel's query envelopes (0 for a variable definition). */
|
||||
envelopeIndex: number;
|
||||
currentText: string;
|
||||
resultingText: string;
|
||||
}
|
||||
|
||||
/** The reference-bearing text + kind for one query envelope, if any. */
|
||||
function envelopeReferenceText(
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): { kind: VariableUsageKind; text: string } | null {
|
||||
const spec = envelope.spec as
|
||||
| { query?: string; filter?: { expression?: string } }
|
||||
| undefined;
|
||||
if (envelope.type === 'builder_query') {
|
||||
const text = spec?.filter?.expression;
|
||||
return typeof text === 'string' ? { kind: 'builder', text } : null;
|
||||
}
|
||||
if (envelope.type === 'promql') {
|
||||
return typeof spec?.query === 'string'
|
||||
? { kind: 'promql', text: spec.query }
|
||||
: null;
|
||||
}
|
||||
if (envelope.type === 'clickhouse_sql') {
|
||||
return typeof spec?.query === 'string'
|
||||
? { kind: 'clickhouse', text: spec.query }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The proposed text after a rename (rewrite) or delete (best-effort removal). */
|
||||
function computeResultingText(
|
||||
kind: VariableUsageKind,
|
||||
text: string,
|
||||
variableName: string,
|
||||
mode: VariableImpactMode,
|
||||
newName: string,
|
||||
): string {
|
||||
if (mode === 'rename') {
|
||||
return rewriteVariableReferences(text, variableName, newName);
|
||||
}
|
||||
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
|
||||
// ClickHouse and variable queries are left for the user to edit.
|
||||
return kind === 'builder'
|
||||
? removeVariableReferenceClause(text, variableName)
|
||||
: text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every usage of `variableName` across the dashboard's panel queries
|
||||
* (builder / PromQL / ClickHouse) and other variables' query definitions, with a
|
||||
* proposed `resultingText` for the given mode. Consumed by the impact dialog that
|
||||
* blocks a rename/delete until the user resolves each usage.
|
||||
*/
|
||||
export function findVariableUsages(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
variableName: string,
|
||||
mode: VariableImpactMode,
|
||||
newName = '',
|
||||
): VariableUsage[] {
|
||||
if (!variableName) {
|
||||
return [];
|
||||
}
|
||||
const usages: VariableUsage[] = [];
|
||||
const spec = dashboard.spec;
|
||||
|
||||
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
|
||||
const queries = panel?.spec?.queries;
|
||||
if (!queries?.length) {
|
||||
return;
|
||||
}
|
||||
toQueryEnvelopes(queries).forEach((envelope, index) => {
|
||||
const ref = envelopeReferenceText(envelope);
|
||||
if (!ref || !textContainsVariableReference(ref.text, variableName)) {
|
||||
return;
|
||||
}
|
||||
usages.push({
|
||||
id: `panel:${panelId}:${index}`,
|
||||
sourceType: 'panel',
|
||||
sourceId: panelId,
|
||||
sourceLabel: panel.spec?.display?.name || panelId,
|
||||
kind: ref.kind,
|
||||
envelopeIndex: index,
|
||||
currentText: ref.text,
|
||||
resultingText: computeResultingText(
|
||||
ref.kind,
|
||||
ref.text,
|
||||
variableName,
|
||||
mode,
|
||||
newName,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
(spec.variables ?? []).map(dtoToFormModel).forEach((variable) => {
|
||||
if (
|
||||
variable.name === variableName ||
|
||||
variable.type !== 'QUERY' ||
|
||||
!variable.queryValue ||
|
||||
!textContainsVariableReference(variable.queryValue, variableName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
usages.push({
|
||||
id: `variable:${variable.name}:0`,
|
||||
sourceType: 'variable',
|
||||
sourceId: variable.name,
|
||||
sourceLabel: `$${variable.name}`,
|
||||
kind: 'variable',
|
||||
envelopeIndex: 0,
|
||||
currentText: variable.queryValue,
|
||||
resultingText: computeResultingText(
|
||||
'variable',
|
||||
variable.queryValue,
|
||||
variableName,
|
||||
mode,
|
||||
newName,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
return usages;
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
|
||||
import { Tooltip } from 'antd';
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import CustomSelector from './selectors/CustomSelector';
|
||||
import DynamicSelector from './selectors/DynamicSelector';
|
||||
import QuerySelector from './selectors/QuerySelector';
|
||||
import TextSelector from './selectors/TextSelector';
|
||||
import VariableValueControl from './selectors/VariableValueControl';
|
||||
import { useVariableFetchState } from './useVariableFetchState';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
import VariableTooltip from './VariableTooltip';
|
||||
|
||||
interface VariableSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
@@ -34,63 +32,50 @@ function VariableSelector({
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
// Dependency links shown in the hover tooltip: variables this one's query
|
||||
// references (dependsOn) and query variables that reference this one (usedBy).
|
||||
const { dependsOn, usedBy } = useMemo(() => {
|
||||
const references = (text: string | undefined, name: string): boolean =>
|
||||
!!text && !!name && textContainsVariableReference(text, name);
|
||||
return {
|
||||
dependsOn:
|
||||
variable.type === 'QUERY'
|
||||
? variables
|
||||
.filter(
|
||||
(v) =>
|
||||
v.name !== variable.name && references(variable.queryValue, v.name),
|
||||
)
|
||||
.map((v) => v.name)
|
||||
: [],
|
||||
usedBy: variables
|
||||
.filter(
|
||||
(v) =>
|
||||
v.type === 'QUERY' &&
|
||||
v.name !== variable.name &&
|
||||
references(v.queryValue, variable.name),
|
||||
)
|
||||
.map((v) => v.name),
|
||||
};
|
||||
}, [variable, variables]);
|
||||
|
||||
const hasTooltip =
|
||||
!!variable.description || dependsOn.length > 0 || usedBy.length > 0;
|
||||
|
||||
// Surface the fetch on the bar itself: a bar flush along the control's bottom
|
||||
// edge while a QUERY/DYNAMIC variable is loading (or waiting on a parent), so the
|
||||
// user sees options are being fetched without opening the dropdown.
|
||||
const { isVariableFetching, isVariableWaiting } = useVariableFetchState(
|
||||
variable.name,
|
||||
);
|
||||
const isFetchingOptions =
|
||||
(variable.type === 'QUERY' || variable.type === 'DYNAMIC') &&
|
||||
(isVariableFetching || isVariableWaiting);
|
||||
|
||||
const renderControl = (): JSX.Element =>
|
||||
variable.type === 'TEXT' ? (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
) : (
|
||||
<VariableValueControl
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
const renderControl = (): JSX.Element => {
|
||||
switch (variable.type) {
|
||||
case 'TEXT':
|
||||
return (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
case 'QUERY':
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
return (
|
||||
<DynamicSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
default:
|
||||
return (
|
||||
<CustomSelector
|
||||
variable={variable}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
onAutoSelect={onAutoSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,29 +84,14 @@ function VariableSelector({
|
||||
>
|
||||
<Typography.Text className={styles.variableName}>
|
||||
${variable.name}
|
||||
{hasTooltip ? (
|
||||
<Tooltip
|
||||
title={
|
||||
<VariableTooltip
|
||||
description={variable.description}
|
||||
dependsOn={dependsOn}
|
||||
usedBy={usedBy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{variable.description ? (
|
||||
<Tooltip title={variable.description}>
|
||||
<SolidInfoCircle className={styles.infoIcon} size={14} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.variableValue}>{renderControl()}</div>
|
||||
|
||||
{isFetchingOptions ? (
|
||||
<span
|
||||
className={styles.loadingBar}
|
||||
data-testid={`variable-loading-${variable.name}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariableTooltipProps {
|
||||
description?: string;
|
||||
/** Variables this one references (its query depends on their values). */
|
||||
dependsOn: string[];
|
||||
/** Variables whose queries reference this one. */
|
||||
usedBy: string[];
|
||||
}
|
||||
|
||||
/** Hover-tooltip body for a variable: its description plus its dependencies. */
|
||||
function VariableTooltip({
|
||||
description,
|
||||
dependsOn,
|
||||
usedBy,
|
||||
}: VariableTooltipProps): JSX.Element {
|
||||
const hasDependencies = dependsOn.length > 0 || usedBy.length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.tooltipContent}>
|
||||
{description ? (
|
||||
<div className={styles.tooltipDescription}>{description}</div>
|
||||
) : null}
|
||||
|
||||
{hasDependencies ? (
|
||||
<>
|
||||
{description ? <div className={styles.tooltipDivider} /> : null}
|
||||
{dependsOn.length > 0 ? (
|
||||
<div className={styles.tooltipSection}>
|
||||
<div className={cx(styles.tooltipLabel, styles.dependsColor)}>
|
||||
Depends on
|
||||
</div>
|
||||
<div className={styles.tooltipRefs}>
|
||||
{dependsOn.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className={cx(styles.tooltipRef, styles.dependsColor)}
|
||||
>
|
||||
${name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{usedBy.length > 0 ? (
|
||||
<div className={styles.tooltipSection}>
|
||||
<div className={cx(styles.tooltipLabel, styles.usedByColor)}>
|
||||
Used by
|
||||
</div>
|
||||
<div className={styles.tooltipRefs}>
|
||||
{usedBy.map((name) => (
|
||||
<span key={name} className={cx(styles.tooltipRef, styles.usedByColor)}>
|
||||
${name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTooltip;
|
||||
@@ -73,57 +73,10 @@
|
||||
}
|
||||
|
||||
.variableItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Loading indicator: an indeterminate bar flush along the control's bottom edge,
|
||||
// full width and overlaying the border so it reads as the input's own edge rather
|
||||
// than a separate element. Non-interactive so the name/description stays hoverable.
|
||||
.loadingBar {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 2px 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 20%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loadingBar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 40%;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
animation: variable-loading-slide 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes variable-loading-slide {
|
||||
0% {
|
||||
left: -40%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loadingBar::after {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.variableName {
|
||||
display: flex;
|
||||
min-width: 56px;
|
||||
@@ -134,7 +87,7 @@
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--bg-robin-400);
|
||||
color: var(--bg-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
@@ -144,63 +97,11 @@
|
||||
|
||||
.infoIcon {
|
||||
display: inline-flex;
|
||||
margin-left: 6px;
|
||||
margin-left: 2px;
|
||||
color: var(--l2-foreground);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.tooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// Divider and labels use the tooltip's own text color at reduced opacity so they
|
||||
// read on the tooltip surface in either theme without hard-coding a palette.
|
||||
.tooltipDivider {
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.tooltipSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tooltipLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tooltipRefs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tooltipRef {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Directional colors: parents (Depends on) in forest, children (Used by) in amber.
|
||||
.dependsColor {
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
|
||||
.usedByColor {
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.variableValue {
|
||||
display: flex;
|
||||
min-width: 120px;
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import {
|
||||
configuredDefaultValue,
|
||||
reconcileWithOptions,
|
||||
resolveDefaultSelection,
|
||||
} from '../resolveVariableSelection';
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
describe('resolveDefaultSelection', () => {
|
||||
it('TEXT: uses defaultValue, then textValue, else empty string', () => {
|
||||
expect(
|
||||
resolveDefaultSelection(model({ type: 'TEXT', defaultValue: 'd' })),
|
||||
).toStrictEqual({ value: 'd', allSelected: false });
|
||||
expect(
|
||||
resolveDefaultSelection(model({ type: 'TEXT', textValue: 't' })),
|
||||
).toStrictEqual({ value: 't', allSelected: false });
|
||||
expect(resolveDefaultSelection(model({ type: 'TEXT' }))).toStrictEqual({
|
||||
value: '',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('list: ALL when allowAll (multi + showAllOption) and no default', () => {
|
||||
expect(
|
||||
resolveDefaultSelection(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
),
|
||||
).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
|
||||
it('list: ALL sentinel default → ALL', () => {
|
||||
expect(
|
||||
resolveDefaultSelection(
|
||||
model({ type: 'CUSTOM', multiSelect: true, defaultValue: '__ALL__' }),
|
||||
),
|
||||
).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
|
||||
it('list: configured default wins over ALL default', () => {
|
||||
expect(
|
||||
resolveDefaultSelection(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
defaultValue: 'x',
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({ value: ['x'], allSelected: false });
|
||||
});
|
||||
|
||||
it('list: no default and no allowAll → empty placeholder (filled after fetch)', () => {
|
||||
expect(resolveDefaultSelection(model({ type: 'QUERY' }))).toStrictEqual({
|
||||
value: '',
|
||||
allSelected: false,
|
||||
});
|
||||
expect(
|
||||
resolveDefaultSelection(model({ type: 'QUERY', multiSelect: true })),
|
||||
).toStrictEqual({ value: [], allSelected: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileWithOptions', () => {
|
||||
it('leaves a valid single selection untouched (local-first)', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY' }),
|
||||
{ value: 'b', allSelected: false },
|
||||
['a', 'b'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('materializes query ALL to the full option array', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
{ value: null, allSelected: true },
|
||||
['a', 'b'],
|
||||
),
|
||||
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('does not materialize dynamic ALL (sends __all__)', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
|
||||
{ value: null, allSelected: true },
|
||||
['a', 'b'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the still-valid subset when options re-scope', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
{ value: ['a', 'b', 'c'], allSelected: false },
|
||||
['a', 'b', 'd'],
|
||||
),
|
||||
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('falls back to the configured default (else first) when invalid', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY', defaultValue: 'b' }),
|
||||
{ value: '', allSelected: false },
|
||||
['a', 'b', 'c'],
|
||||
),
|
||||
).toStrictEqual({ value: 'b', allSelected: false });
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY' }),
|
||||
{ value: '', allSelected: false },
|
||||
['a', 'b'],
|
||||
),
|
||||
).toStrictEqual({ value: 'a', allSelected: false });
|
||||
});
|
||||
|
||||
it('does nothing while options are empty', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY' }),
|
||||
{ value: '', allSelected: false },
|
||||
[],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuredDefaultValue', () => {
|
||||
it('TEXT: textValue fallback; list: defaultValue only (no ALL synthesis)', () => {
|
||||
expect(configuredDefaultValue(model({ type: 'TEXT', textValue: 't' }))).toBe(
|
||||
't',
|
||||
);
|
||||
expect(
|
||||
configuredDefaultValue(model({ type: 'QUERY', defaultValue: 'x' })),
|
||||
).toBe('x');
|
||||
// ALL-by-default list variable is not expanded here (options unknown).
|
||||
expect(
|
||||
configuredDefaultValue(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { withVariablesSearch } from '../variablesUrlState';
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
|
||||
}));
|
||||
|
||||
describe('withVariablesSearch', () => {
|
||||
const current = `?compositeQuery=abc&variables=${encodeURIComponent(
|
||||
'{"env":"prod"}',
|
||||
)}`;
|
||||
|
||||
it('returns the base unchanged when the current search has no variables', () => {
|
||||
expect(withVariablesSearch('', '?compositeQuery=abc')).toBe('');
|
||||
expect(withVariablesSearch('?panelKind=signoz/TablePanel', '')).toBe(
|
||||
'?panelKind=signoz/TablePanel',
|
||||
);
|
||||
});
|
||||
|
||||
it('carries only the variables param onto an empty base', () => {
|
||||
const result = withVariablesSearch('', current);
|
||||
expect(new URLSearchParams(result).get('variables')).toBe('{"env":"prod"}');
|
||||
expect(new URLSearchParams(result).get('compositeQuery')).toBeNull();
|
||||
});
|
||||
|
||||
it('appends the variables param to existing base params', () => {
|
||||
const result = withVariablesSearch('?panelKind=signoz/TablePanel', current);
|
||||
const params = new URLSearchParams(result);
|
||||
expect(params.get('panelKind')).toBe('signoz/TablePanel');
|
||||
expect(params.get('variables')).toBe('{"env":"prod"}');
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
} from './selectionTypes';
|
||||
import { ALL_SELECTED } from './variablesUrlState';
|
||||
|
||||
/**
|
||||
* Single source of truth for "what value does this variable hold?", shared by the
|
||||
* three surfaces that used to each own a divergent copy of the rule:
|
||||
* - {@link resolveDefaultSelection} — the seed-time default (no options yet).
|
||||
* - {@link reconcileWithOptions} — the post-fetch reconcile (options known).
|
||||
* - {@link configuredDefaultValue} — the payload fallback when nothing is picked.
|
||||
*
|
||||
* Keeping them here means the variable bar, the fetch gate and the panel-query
|
||||
* payload can never disagree about a variable's default (the previous split
|
||||
* produced "bar shows ALL while the query omits the variable").
|
||||
*/
|
||||
|
||||
/** An "every option selected" (ALL) selection. */
|
||||
const ALL_SELECTION: VariableSelection = { value: null, allSelected: true };
|
||||
|
||||
/** The `defaultValue` reduced to a single string, or undefined when unset. */
|
||||
function firstConfiguredDefault(model: VariableFormModel): string | undefined {
|
||||
const def = model.defaultValue;
|
||||
if (Array.isArray(def)) {
|
||||
return def.length > 0 ? String(def[0]) : undefined;
|
||||
}
|
||||
if (typeof def === 'string' && def !== '') {
|
||||
return def;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether the configured default marks the ALL sentinel. */
|
||||
function isAllDefault(def: VariableFormModel['defaultValue']): boolean {
|
||||
return (
|
||||
def === ALL_SELECTED ||
|
||||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidSingle(
|
||||
value: SelectedVariableValue,
|
||||
options: string[],
|
||||
): boolean {
|
||||
return (
|
||||
!Array.isArray(value) &&
|
||||
value !== '' &&
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
options.includes(String(value))
|
||||
);
|
||||
}
|
||||
|
||||
/** The configured default (or first option) as a fresh selection. */
|
||||
function fillDefault(
|
||||
model: VariableFormModel,
|
||||
options: string[],
|
||||
): VariableSelection {
|
||||
const fallback = firstConfiguredDefault(model);
|
||||
const initial = fallback && options.includes(fallback) ? fallback : options[0];
|
||||
return {
|
||||
value: model.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* For an ALL selection, the value to materialize (or null when unchanged).
|
||||
* Dynamic ALL travels as the `__all__` wire sentinel and renders ALL from the
|
||||
* flag, so it needs no materialized value. Query/custom ALL must carry the full
|
||||
* option array (the payload builder cannot expand it) — keep it in sync.
|
||||
*/
|
||||
function materializeAll(
|
||||
model: VariableFormModel,
|
||||
options: string[],
|
||||
current: SelectedVariableValue,
|
||||
): VariableSelection | null {
|
||||
if (!model.multiSelect || model.type === 'DYNAMIC') {
|
||||
return null;
|
||||
}
|
||||
const alreadyFull =
|
||||
Array.isArray(current) &&
|
||||
current.length === options.length &&
|
||||
current.every((c) => options.includes(String(c)));
|
||||
return alreadyFull ? null : { value: options, allSelected: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The seed-time default for a variable, before any options are fetched.
|
||||
* - TEXT: the configured default (`defaultValue` → `textValue`), else empty.
|
||||
* - CUSTOM/QUERY/DYNAMIC: the configured default; else ALL when allowAll is on;
|
||||
* else a placeholder that {@link reconcileWithOptions} fills with the first
|
||||
* option once the options resolve.
|
||||
*/
|
||||
export function resolveDefaultSelection(
|
||||
model: VariableFormModel,
|
||||
): VariableSelection {
|
||||
if (model.type === 'TEXT') {
|
||||
return {
|
||||
value: firstConfiguredDefault(model) ?? model.textValue ?? '',
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
const def = model.defaultValue;
|
||||
if (isAllDefault(def)) {
|
||||
return ALL_SELECTION;
|
||||
}
|
||||
if (Array.isArray(def) && def.length > 0) {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
if (typeof def === 'string' && def !== '') {
|
||||
return { value: model.multiSelect ? [def] : def, allSelected: false };
|
||||
}
|
||||
if (model.multiSelect && model.showAllOption) {
|
||||
return ALL_SELECTION;
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles a variable's current selection against its freshly-fetched options.
|
||||
* Returns the next selection, or null when nothing should change (a valid pick is
|
||||
* left untouched — local-first). Behaviour, in order:
|
||||
* - materialize ALL to the full option set (query/custom);
|
||||
* - keep a still-valid multi-select subset, dropping only invalid entries;
|
||||
* - otherwise auto-pick the default (or first option) so dependent variables and
|
||||
* panels always resolve against a usable value.
|
||||
*/
|
||||
export function reconcileWithOptions(
|
||||
model: VariableFormModel,
|
||||
current: VariableSelection,
|
||||
options: string[],
|
||||
): VariableSelection | null {
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current.allSelected) {
|
||||
return materializeAll(model, options, current.value);
|
||||
}
|
||||
|
||||
if (
|
||||
model.multiSelect &&
|
||||
Array.isArray(current.value) &&
|
||||
current.value.length > 0
|
||||
) {
|
||||
const valid = current.value.map(String).filter((c) => options.includes(c));
|
||||
if (valid.length === current.value.length) {
|
||||
return null;
|
||||
}
|
||||
return valid.length > 0
|
||||
? { value: valid, allSelected: false }
|
||||
: fillDefault(model, options);
|
||||
}
|
||||
|
||||
if (!model.multiSelect && isValidSingle(current.value, options)) {
|
||||
return null;
|
||||
}
|
||||
return fillDefault(model, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value to send for a variable when the user has made no selection yet
|
||||
* (the payload fallback). Mirrors the configured default only — an ALL-by-default
|
||||
* list variable resolves to `undefined` here (its concrete values are carried by
|
||||
* the materialized selection once options are known), so it is omitted until then
|
||||
* rather than sent wrong.
|
||||
*/
|
||||
export function configuredDefaultValue(
|
||||
model: VariableFormModel,
|
||||
): SelectedVariableValue | undefined {
|
||||
if (model.type === 'TEXT') {
|
||||
return firstConfiguredDefault(model) ?? model.textValue ?? undefined;
|
||||
}
|
||||
const def = model.defaultValue;
|
||||
if (Array.isArray(def)) {
|
||||
return def.length > 0 ? def : undefined;
|
||||
}
|
||||
return def || undefined;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { VariableType } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
@@ -20,32 +19,6 @@ export function isResolved(selection?: VariableSelection): boolean {
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a selection carries a value usable when scheduling a dependent
|
||||
* variable/panel fetch. Unlike {@link isResolved}, a QUERY/CUSTOM ALL counts only
|
||||
* once materialized into the concrete array (an unmaterialized ALL isn't usable),
|
||||
* while a DYNAMIC ALL is usable immediately via the `__all__` sentinel.
|
||||
*/
|
||||
export function hasUsableValue(
|
||||
selection: VariableSelection | undefined,
|
||||
type: VariableType | undefined,
|
||||
): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
if (type === 'DYNAMIC') {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(selection.value) && selection.value.length > 0;
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface CustomSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom-variable options come from the comma-separated `customValue` (no fetch),
|
||||
* but still auto-select a default/first option so the variable is never left blank.
|
||||
*/
|
||||
function CustomSelector({
|
||||
variable,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: CustomSelectorProps): JSX.Element {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String),
|
||||
[variable.customValue, variable.sort],
|
||||
);
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomSelector;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
signalForApi,
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables + current selections, to scope options by sibling dynamics. */
|
||||
variables: VariableFormModel[];
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
|
||||
* the runtime fetch engine: dynamics fetch together once the query variables have
|
||||
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
|
||||
const { data, isFetching, error, refetch } = useQuery(
|
||||
[
|
||||
'dashboard-variable-dynamic',
|
||||
variable.name,
|
||||
variable.dynamicSignal,
|
||||
variable.dynamicAttribute,
|
||||
existingQuery,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
getFieldValues(
|
||||
signalForApi(variable.dynamicSignal),
|
||||
variable.dynamicAttribute,
|
||||
undefined,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery || undefined,
|
||||
),
|
||||
{
|
||||
enabled:
|
||||
!!variable.dynamicAttribute &&
|
||||
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching || isVariableWaiting}
|
||||
errorMessage={error ? (error as Error).message || null : null}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
}}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSelector;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
|
||||
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
|
||||
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
|
||||
* per-variable `cycleId` keys the request — so a parent's value change refetches
|
||||
* only the dependent variables, in dependency order. The current selections feed
|
||||
* the request payload but are deliberately NOT in the key (V1 parity).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
|
||||
const { data, isFetching, error, refetch } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
minTime,
|
||||
maxTime,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{
|
||||
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
|
||||
refetchOnWindowFocus: false,
|
||||
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
|
||||
cacheTime: isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data || data.statusCode !== 200 || !data.payload) {
|
||||
return [] as string[];
|
||||
}
|
||||
return sortValuesByOrder(
|
||||
data.payload.variableValues ?? [],
|
||||
variable.sort,
|
||||
).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching || isVariableWaiting}
|
||||
errorMessage={error ? (error as Error).message || null : null}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
}}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySelector;
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
import { useVariableOptions } from './useVariableOptions';
|
||||
|
||||
interface VariableValueControlProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic scopes its options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** All current selections (fed to the Query request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
/** Batched auto-selection fill applied when options resolve. */
|
||||
onAutoSelect: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single value picker for QUERY / CUSTOM / DYNAMIC variables. Options + fetch
|
||||
* state come from {@link useVariableOptions}; this component only reconciles the
|
||||
* selection against the options and renders — the view is decoupled from how the
|
||||
* options are sourced (Container/Presentational).
|
||||
*/
|
||||
function VariableValueControl({
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: VariableValueControlProps): JSX.Element {
|
||||
const { options, loading, errorMessage, onRetry } = useVariableOptions(
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
);
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableValueControl;
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import {
|
||||
DASHBOARD_CACHE_TIME,
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
signalForApi,
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import { selectionToPayload } from '../selectionUtils';
|
||||
import { useVariableFetchState } from '../useVariableFetchState';
|
||||
|
||||
export interface VariableOptions {
|
||||
options: string[];
|
||||
loading: boolean;
|
||||
errorMessage: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The option list for a list variable (QUERY / CUSTOM / DYNAMIC), plus its loading
|
||||
* and error state — the single place the three list types get their options.
|
||||
* QUERY/DYNAMIC fetch via react-query (WHEN owned by the fetch engine: `enabled`
|
||||
* gated on the variable's fetch state, keyed by `cycleId`, never by the current
|
||||
* selections or time — those feed the fetchers (which read the current time at
|
||||
* call), so the debounced fetch cycle drives refetches). CUSTOM is parsed
|
||||
* synchronously from its comma list. TEXT never reaches here (it has no options).
|
||||
*/
|
||||
export function useVariableOptions(
|
||||
variable: VariableFormModel,
|
||||
variables: VariableFormModel[],
|
||||
selections: VariableSelectionMap,
|
||||
): VariableOptions {
|
||||
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
// Bound cache churn: 0 under auto-refresh so entries don't pile up (V1 parity).
|
||||
const cacheTime = isAutoRefreshDisabled
|
||||
? DASHBOARD_CACHE_TIME
|
||||
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED;
|
||||
const {
|
||||
variableFetchCycleId,
|
||||
isVariableFetching,
|
||||
isVariableSettled,
|
||||
isVariableWaiting,
|
||||
hasVariableFetchedOnce,
|
||||
} = useVariableFetchState(variable.name);
|
||||
const onVariableFetchComplete = useDashboardStore(
|
||||
(s) => s.onVariableFetchComplete,
|
||||
);
|
||||
const onVariableFetchFailure = useDashboardStore(
|
||||
(s) => s.onVariableFetchFailure,
|
||||
);
|
||||
const setVariableResolvedEmpty = useDashboardStore(
|
||||
(s) => s.setVariableResolvedEmpty,
|
||||
);
|
||||
|
||||
// Fetch while this variable is actively fetching, or once settled after a first
|
||||
// fetch (so a `cycleId` bump re-runs it). Combined with a per-type guard below.
|
||||
const canFetch =
|
||||
isVariableFetching || (isVariableSettled && hasVariableFetchedOnce);
|
||||
|
||||
// QUERY — options from the test-run endpoint; selections feed the payload, not the key.
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
const queryResult = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{
|
||||
enabled: variable.type === 'QUERY' && canFetch,
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
// DYNAMIC — telemetry field values scoped by sibling dynamics via `existingQuery`
|
||||
// (fed to the fetcher only, not the key — see DynamicSelector history).
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
const dynamicResult = useQuery(
|
||||
[
|
||||
'dashboard-variable-dynamic',
|
||||
variable.name,
|
||||
variable.dynamicSignal,
|
||||
variable.dynamicAttribute,
|
||||
variableFetchCycleId,
|
||||
],
|
||||
() =>
|
||||
getFieldValues(
|
||||
signalForApi(variable.dynamicSignal),
|
||||
variable.dynamicAttribute,
|
||||
undefined,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery || undefined,
|
||||
),
|
||||
{
|
||||
enabled:
|
||||
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime,
|
||||
onSettled: (_, error) =>
|
||||
error
|
||||
? onVariableFetchFailure(variable.name)
|
||||
: onVariableFetchComplete(variable.name),
|
||||
},
|
||||
);
|
||||
|
||||
const queryOptions = useMemo(() => {
|
||||
const data = queryResult.data;
|
||||
if (!data || data.statusCode !== 200 || !data.payload) {
|
||||
return [] as string[];
|
||||
}
|
||||
return sortValuesByOrder(
|
||||
data.payload.variableValues ?? [],
|
||||
variable.sort,
|
||||
).map(String);
|
||||
}, [queryResult.data, variable.sort]);
|
||||
|
||||
const dynamicOptions = useMemo(() => {
|
||||
const data = dynamicResult.data?.data;
|
||||
const values = data?.normalizedValues ?? data?.values?.StringValues ?? [];
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [dynamicResult.data, variable.sort]);
|
||||
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String)
|
||||
: ([] as string[]),
|
||||
[variable.type, variable.customValue, variable.sort],
|
||||
);
|
||||
|
||||
// Flag a variable that settled with zero options so dependent panels fall through
|
||||
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
|
||||
const effectiveOptions =
|
||||
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
|
||||
useEffect(() => {
|
||||
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
|
||||
return;
|
||||
}
|
||||
setVariableResolvedEmpty(
|
||||
variable.name,
|
||||
hasVariableFetchedOnce &&
|
||||
!isVariableFetching &&
|
||||
effectiveOptions.length === 0,
|
||||
);
|
||||
}, [
|
||||
variable.type,
|
||||
variable.name,
|
||||
hasVariableFetchedOnce,
|
||||
isVariableFetching,
|
||||
effectiveOptions.length,
|
||||
setVariableResolvedEmpty,
|
||||
]);
|
||||
|
||||
if (variable.type === 'CUSTOM') {
|
||||
return { options: customOptions, loading: false, errorMessage: null };
|
||||
}
|
||||
if (variable.type === 'DYNAMIC') {
|
||||
return {
|
||||
options: dynamicOptions,
|
||||
loading: dynamicResult.isFetching || isVariableWaiting,
|
||||
errorMessage: dynamicResult.error
|
||||
? (dynamicResult.error as Error).message || null
|
||||
: null,
|
||||
onRetry: (): void => {
|
||||
void dynamicResult.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
options: queryOptions,
|
||||
loading: queryResult.isFetching || isVariableWaiting,
|
||||
errorMessage: queryResult.error
|
||||
? (queryResult.error as Error).message || null
|
||||
: null,
|
||||
onRetry: (): void => {
|
||||
void queryResult.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,61 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import { reconcileWithOptions } from './resolveVariableSelection';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
} from './selectionTypes';
|
||||
|
||||
/** The variable's default (or first option) as a fresh selection. */
|
||||
function fillDefault(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
): VariableSelection {
|
||||
const dv = variable.defaultValue;
|
||||
const fallback = Array.isArray(dv) ? dv[0] : dv;
|
||||
const initial = fallback && options.includes(fallback) ? fallback : options[0];
|
||||
return {
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** For an all-selected variable, the value to materialize (or null if unchanged). */
|
||||
function reconcileAllSelected(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
current: SelectedVariableValue,
|
||||
): VariableSelection | null {
|
||||
// Dynamic ALL travels as the `__all__` wire sentinel and shows ALL from the
|
||||
// flag, so it needs no materialized value. Query/custom ALL must carry the full
|
||||
// option array (the payload builder can't expand it) — keep it in sync.
|
||||
if (!variable.multiSelect || variable.type === 'DYNAMIC') {
|
||||
return null;
|
||||
}
|
||||
const alreadyFull =
|
||||
Array.isArray(current) &&
|
||||
current.length === options.length &&
|
||||
current.every((c) => options.includes(String(c)));
|
||||
return alreadyFull ? null : { value: options, allSelected: true };
|
||||
}
|
||||
|
||||
function isValidSingle(
|
||||
current: SelectedVariableValue,
|
||||
options: string[],
|
||||
): boolean {
|
||||
return (
|
||||
!Array.isArray(current) &&
|
||||
current !== '' &&
|
||||
current !== null &&
|
||||
current !== undefined &&
|
||||
options.includes(String(current))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles a variable's selection with its freshly-fetched options and fires
|
||||
* `onAutoSelect` only when the value must change. The reconcile rule lives in
|
||||
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
|
||||
* and the panel query can never disagree about a variable's default.
|
||||
* Reconciles a variable's selection with its freshly-fetched options: materialize
|
||||
* ALL to the full set, keep a still-valid multi-select subset, else auto-pick the
|
||||
* default (or first option) so dependent children always have a usable value.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
@@ -17,10 +64,36 @@ export function useAutoSelect(
|
||||
onAutoSelect: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const next = reconcileWithOptions(variable, selection, options);
|
||||
if (next) {
|
||||
onAutoSelect(next);
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
const current = selection.value;
|
||||
|
||||
if (selection.allSelected) {
|
||||
const next = reconcileAllSelected(variable, options, current);
|
||||
if (next) {
|
||||
onAutoSelect(next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (variable.multiSelect && Array.isArray(current) && current.length > 0) {
|
||||
const valid = current.map(String).filter((c) => options.includes(c));
|
||||
if (valid.length === current.length) {
|
||||
return;
|
||||
}
|
||||
onAutoSelect(
|
||||
valid.length > 0
|
||||
? { value: valid, allSelected: false }
|
||||
: fillDefault(variable, options),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variable.multiSelect && isValidSingle(current, options)) {
|
||||
return;
|
||||
}
|
||||
onAutoSelect(fillDefault(variable, options));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters'
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { resolveDefaultSelection } from './resolveVariableSelection';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
@@ -18,6 +17,26 @@ import {
|
||||
} from './variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = model.defaultValue;
|
||||
if (
|
||||
def === ALL_SELECTED ||
|
||||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
|
||||
) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
if (Array.isArray(def) && def.length > 0) {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
if (typeof def === 'string' && def !== '') {
|
||||
return { value: model.multiSelect ? [def] : def, allSelected: false };
|
||||
}
|
||||
if (model.multiSelect && model.showAllOption) {
|
||||
return { value: null, allSelected: true };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
|
||||
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
|
||||
function fromUrlValue(
|
||||
@@ -69,20 +88,12 @@ export function useSeedVariableSelection(
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
const stored = selection[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
const fromUrl = fromUrlValue(urlValue, variable);
|
||||
// When the URL carries only the ALL sentinel but the store already holds
|
||||
// the materialized full-option array, reuse it — avoids the re-fetch +
|
||||
// re-materialize round-trip (and its dependent-refetch cascade) on load.
|
||||
seeded[variable.name] =
|
||||
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
|
||||
? stored
|
||||
: fromUrl;
|
||||
} else if (stored) {
|
||||
seeded[variable.name] = stored;
|
||||
seeded[variable.name] = fromUrlValue(urlValue, variable);
|
||||
} else if (selection[variable.name]) {
|
||||
seeded[variable.name] = selection[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = resolveDefaultSelection(variable);
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
@@ -105,10 +116,8 @@ export function useSeedVariableSelection(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per dashboard/variable set; the URL is read as of that moment
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
// Always init the context (even with no variables) so panels can tell "ready, none"
|
||||
// from "not ready yet"; also clears it when the last variable is removed.
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
const names = variables
|
||||
|
||||
@@ -11,14 +11,9 @@ import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import { useSeedVariableSelection } from './useSeedVariableSelection';
|
||||
import { doAllQueryVariablesHaveValues } from './variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
|
||||
|
||||
/**
|
||||
* Debounce for the fetch cycle, so the on-load time-range settle (default → saved)
|
||||
* and rapid time-picker changes collapse into one cycle instead of double-fetching.
|
||||
*/
|
||||
const FETCH_CYCLE_DEBOUNCE_MS = 250;
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
selection: VariableSelectionMap;
|
||||
@@ -53,10 +48,9 @@ export function useVariableSelection(
|
||||
(s) => s.enqueueDescendantsBatch,
|
||||
);
|
||||
|
||||
const { minTime, maxTime, selectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// Latest selection, read by the fetch-cycle effect without subscribing to it
|
||||
// (so a value change doesn't re-trigger a full fetch cycle).
|
||||
@@ -68,39 +62,20 @@ export function useVariableSelection(
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Start a full fetch cycle on load / dependency-order / time change, debounced so
|
||||
// the initial time-window settle (and rapid time changes) collapse into ONE cycle
|
||||
// instead of double-fetching every variable. Variables stay disabled until the
|
||||
// cycle runs, so the transient window is never fetched. A value change instead
|
||||
// goes through `enqueueDescendants` — immediate, not this effect.
|
||||
// Start a full fetch cycle on load / dependency-order / time change. A value
|
||||
// change instead goes through `enqueueDescendants`, not this effect.
|
||||
const orderKey = `${fetchContext.queryVariableOrder.join(
|
||||
',',
|
||||
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
|
||||
// Key on the time *selection*, not raw min/max: a relative range recomputes those
|
||||
// as `now` drifts, which shouldn't refetch. The fetchers still read current time.
|
||||
const timeKey =
|
||||
selectedTime === 'custom' ? `custom:${minTime}-${maxTime}` : selectedTime;
|
||||
// A re-mount re-runs this effect with the same key, which enqueueFetchAll skips.
|
||||
const fetchCycleKey = `${dashboardId}|${orderKey}|${timeKey}`;
|
||||
const fetchCycleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
if (fetchCycleTimer.current) {
|
||||
clearTimeout(fetchCycleTimer.current);
|
||||
}
|
||||
fetchCycleTimer.current = setTimeout(
|
||||
() => enqueueFetchAll(fetchCycleKey),
|
||||
FETCH_CYCLE_DEBOUNCE_MS,
|
||||
enqueueFetchAll(
|
||||
doAllQueryVariablesHaveValues(variables, selectionRef.current),
|
||||
);
|
||||
return (): void => {
|
||||
if (fetchCycleTimer.current) {
|
||||
clearTimeout(fetchCycleTimer.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, fetchCycleKey]);
|
||||
}, [dashboardId, orderKey, minTime, maxTime]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
VariableFormModel,
|
||||
VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
import { isResolved } from './selectionUtils';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
@@ -240,3 +242,17 @@ export function deriveFetchContext(
|
||||
dynamicVariableOrder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every QUERY variable already has a usable selection — decides at load
|
||||
* time whether dynamic variables may fetch immediately or must wait for the
|
||||
* query variables to settle first (V1 parity).
|
||||
*/
|
||||
export function doAllQueryVariablesHaveValues(
|
||||
variables: VariableFormModel[],
|
||||
selection: VariableSelectionMap,
|
||||
): boolean {
|
||||
return variables
|
||||
.filter((v) => v.type === 'QUERY')
|
||||
.every((v) => isResolved(selection[v.name]));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { parseAsJson } from 'nuqs';
|
||||
|
||||
import type { SelectedVariableValue } from './selectionTypes';
|
||||
@@ -13,3 +14,21 @@ export const variablesUrlParser = parseAsJson<
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Extends a search string with the current `?variables=` param (unchanged when
|
||||
* absent), so the dashboard ↔ editor handoff keeps the selection in the URL and
|
||||
* it survives a refresh (V1 parity).
|
||||
*/
|
||||
export function withVariablesSearch(
|
||||
base: string,
|
||||
currentSearch: string,
|
||||
): string {
|
||||
const value = new URLSearchParams(currentSearch).get(QueryParams.variables);
|
||||
if (!value) {
|
||||
return base;
|
||||
}
|
||||
const params = new URLSearchParams(base);
|
||||
params.set(QueryParams.variables, value);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
|
||||
import type { PanelKind } from '../Panels/types/panelKind';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
|
||||
|
||||
interface UseCreatePanelResult {
|
||||
isPickerOpen: boolean;
|
||||
@@ -25,6 +26,7 @@ interface UseCreatePanelResult {
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { search } = useLocation();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
@@ -48,10 +50,11 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
// Variable selection is read from the persisted store, not the URL.
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
safeNavigate(
|
||||
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
[safeNavigate, dashboardId, layoutIndex, search],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,51 +1,23 @@
|
||||
import { hasUsableValue } from '../VariablesBar/selectionUtils';
|
||||
import { VariableFetchState } from '../store/slices/variableFetchSlice';
|
||||
import { isResolved } from '../VariablesBar/selectionUtils';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* Whether a panel should stay loading because a QUERY/DYNAMIC variable it references
|
||||
* isn't ready to substitute. A concrete pick (not ALL) and a DYNAMIC ALL are ready
|
||||
* immediately; an unselected value or a QUERY/CUSTOM ALL waits while it's still
|
||||
* resolving, then until it settles with a value — so a panel on a chain holds until
|
||||
* the last variable it depends on resolves. A fetch error or a settled-empty variable
|
||||
* releases it (no value is coming — render rather than hang).
|
||||
* True while a panel should stay in its loading state because a variable it
|
||||
* references is still loading/waiting and has no usable value yet — i.e. the
|
||||
* first load. Once the variable has a value, a later change no longer blocks the
|
||||
* panel (it refetches over stale data instead). V1 parity with
|
||||
* `useIsPanelWaitingOnVariable`.
|
||||
*/
|
||||
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variableTypes = useDashboardStore(
|
||||
(s) => s.variableFetchContext?.variableTypes,
|
||||
);
|
||||
const fetchStates = useDashboardStore((s) => s.variableFetchStates);
|
||||
const resolvedEmpty = useDashboardStore((s) => s.variableResolvedEmpty);
|
||||
const states = useDashboardStore((s) => s.variableFetchStates);
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
|
||||
return names.some((name) => {
|
||||
const type = variableTypes?.[name];
|
||||
if (type !== 'QUERY' && type !== 'DYNAMIC') {
|
||||
return false;
|
||||
}
|
||||
const value = selection[name];
|
||||
// A concrete pick is authoritative; a DYNAMIC ALL is the stable `__all__`
|
||||
// sentinel — both ready without waiting.
|
||||
if (value && !value.allSelected && hasUsableValue(value, type)) {
|
||||
return false;
|
||||
}
|
||||
if (type === 'DYNAMIC' && value?.allSelected) {
|
||||
return false;
|
||||
}
|
||||
// Unselected, or a QUERY/CUSTOM ALL whose array the fetch produces: wait while
|
||||
// resolving, then until it settles with a usable value.
|
||||
const state = fetchStates[name];
|
||||
if (
|
||||
state === VariableFetchState.Waiting ||
|
||||
state === VariableFetchState.Loading
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (hasUsableValue(value, type)) {
|
||||
return false;
|
||||
}
|
||||
return state !== VariableFetchState.Error && !resolvedEmpty[name];
|
||||
const state = states[name];
|
||||
const inFlight =
|
||||
state === 'loading' || state === 'revalidating' || state === 'waiting';
|
||||
return isResolved(selection[name]) ? false : inFlight;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useCallback } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id. Variable selection is read from the
|
||||
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
|
||||
* caller can open the editor with just the panel id. The `?variables=` selection is carried
|
||||
* along (V1 parity) so it survives a refresh of the editor. The optional `handoffState` is
|
||||
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
|
||||
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
|
||||
* panel.
|
||||
@@ -20,15 +21,19 @@ export function useOpenPanelEditor(): (
|
||||
handoffState?: PanelEditorHandoffState,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { search } = useLocation();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
`${generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId,
|
||||
})}${withVariablesSearch('', search)}`,
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
[safeNavigate, dashboardId, search],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,7 @@ import {
|
||||
} from '../queryV5/buildQueryRangeRequest';
|
||||
import type { PanelPagination, PanelQueryData } from '../queryV5/types';
|
||||
import { getRawResults } from '../queryV5/v5ResponseData';
|
||||
import {
|
||||
getReferencedVariables,
|
||||
queryReferencesAnyVariable,
|
||||
} from '../queryV5/getReferencedVariables';
|
||||
import { getReferencedVariables } from '../queryV5/getReferencedVariables';
|
||||
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
|
||||
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
|
||||
@@ -60,9 +57,9 @@ export interface PanelQueryTimeOverride {
|
||||
export interface UsePanelQueryResult {
|
||||
/** Raw V5 fetch result — response + the request that produced it. */
|
||||
data: PanelQueryData;
|
||||
/** First fetch only (no cached data yet), OR waiting on an unresolved referenced variable — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
|
||||
/** First fetch only (no cached data yet) — drives the full-panel loader. A background refetch does NOT set this; use `isFetching`. */
|
||||
isLoading: boolean;
|
||||
/** Any request in flight (including a background refetch over stale data), OR waiting on an unresolved referenced variable — drives the loader / "refreshing" affordance, never a blank panel. */
|
||||
/** Any request in flight, including a background refetch over stale data — drives a "refreshing" affordance, never a blank panel. */
|
||||
isFetching: boolean;
|
||||
/** Showing a prior page's data (keepPreviousData) while the next page loads — list renderers swap in skeleton rows. */
|
||||
isPreviousData: boolean;
|
||||
@@ -134,13 +131,6 @@ export function usePanelQuery({
|
||||
return getReferencedVariables(queries, allNames);
|
||||
}, [queries, fetchContext]);
|
||||
|
||||
// Detected without the fetch context, so the gate below can hold even before it
|
||||
// initializes.
|
||||
const hasVariableReference = useMemo(
|
||||
() => queryReferencesAnyVariable(queries),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const scopedVariables = useMemo(() => {
|
||||
const scoped: typeof variables = {};
|
||||
referencedVariableNames.forEach((name) => {
|
||||
@@ -151,11 +141,11 @@ export function usePanelQuery({
|
||||
return scoped;
|
||||
}, [variables, referencedVariableNames]);
|
||||
|
||||
// Hold until referenced variables resolve; also hold before the context is ready
|
||||
// (we can't yet know which variables to substitute, so firing would drop `$var`s).
|
||||
const isWaitingOnVariable =
|
||||
useIsPanelWaitingOnVariable(referencedVariableNames) ||
|
||||
(hasVariableReference && !fetchContext);
|
||||
// First-load gate: hold the panel in its loading state until every referenced
|
||||
// variable has resolved a value.
|
||||
const isWaitingOnVariable = useIsPanelWaitingOnVariable(
|
||||
referencedVariableNames,
|
||||
);
|
||||
|
||||
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
|
||||
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
|
||||
@@ -319,10 +309,8 @@ export function usePanelQuery({
|
||||
|
||||
return {
|
||||
data,
|
||||
// A disabled (waiting-on-variable) query reports neither loading nor fetching, so
|
||||
// fold the wait in — else the panel body falls through to "No data" mid-load.
|
||||
isLoading: isWaitingOnVariable || response.isLoading,
|
||||
isFetching: isWaitingOnVariable || response.isFetching,
|
||||
isLoading: response.isLoading,
|
||||
isFetching: response.isFetching,
|
||||
isPreviousData: response.isPreviousData,
|
||||
error: response.error ?? null,
|
||||
refetch: response.refetch,
|
||||
|
||||
@@ -33,11 +33,6 @@ function DashboardContainer({
|
||||
document.title = name;
|
||||
}, [name]);
|
||||
|
||||
// Store is app-level and outlives the page: clear transient variable fetch state on
|
||||
// unmount so the next visit doesn't inherit stale states / climbing cycle ids.
|
||||
const resetVariableFetch = useDashboardStore((s) => s.resetVariableFetch);
|
||||
useEffect(() => resetVariableFetch, [resetVariableFetch]);
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
getReferencedVariables,
|
||||
queryReferencesAnyVariable,
|
||||
} from '../getReferencedVariables';
|
||||
|
||||
// Test fixtures are cast at the outer boundary; the perses-generated query
|
||||
// plugin unions are too verbose to construct field-typed inline.
|
||||
function clickhouseQuery(query: string): DashboardtypesQueryDTO[] {
|
||||
return [
|
||||
{
|
||||
kind: 'ScalarQuery',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [{ type: 'clickhouse_sql', spec: { name: 'A', query } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
describe('getReferencedVariables', () => {
|
||||
it('returns only the variables the query references', () => {
|
||||
const queries = clickhouseQuery(
|
||||
'SELECT count() FROM t WHERE service = $service.name',
|
||||
);
|
||||
expect(
|
||||
getReferencedVariables(queries, [
|
||||
'service.name',
|
||||
'deployment.environment',
|
||||
'dyn_service',
|
||||
]),
|
||||
).toStrictEqual(['service.name']);
|
||||
});
|
||||
|
||||
it('returns empty when no known name matches', () => {
|
||||
const queries = clickhouseQuery('SELECT 1');
|
||||
expect(getReferencedVariables(queries, ['service.name'])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryReferencesAnyVariable', () => {
|
||||
it('is true when the query references a variable, even with no known names', () => {
|
||||
const queries = clickhouseQuery(
|
||||
'SELECT count() FROM t WHERE service = $service.name',
|
||||
);
|
||||
expect(queryReferencesAnyVariable(queries)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a query with no variable reference', () => {
|
||||
expect(queryReferencesAnyVariable(clickhouseQuery('SELECT 1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat $__ macros as variable references', () => {
|
||||
expect(
|
||||
queryReferencesAnyVariable(
|
||||
clickhouseQuery('SELECT toStartOfInterval(ts, INTERVAL $__interval)'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for an empty query list', () => {
|
||||
expect(queryReferencesAnyVariable([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
VariableFormModel,
|
||||
VariableType,
|
||||
} from '../DashboardSettings/Variables/variableFormModel';
|
||||
import { configuredDefaultValue } from '../VariablesBar/resolveVariableSelection';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../VariablesBar/selectionTypes';
|
||||
@@ -33,6 +33,21 @@ const VARIABLE_TYPE_TO_DTO: Record<
|
||||
DYNAMIC: Querybuildertypesv5VariableTypeDTO.dynamic,
|
||||
};
|
||||
|
||||
/** The variable's configured default, used when nothing is selected yet. */
|
||||
function configuredDefault(
|
||||
definition: VariableFormModel,
|
||||
): SelectedVariableValue | undefined {
|
||||
if (definition.type === 'TEXT') {
|
||||
return definition.textValue || undefined;
|
||||
}
|
||||
// `defaultValue` is `string | string[]` on the wire — use it directly.
|
||||
const def = definition.defaultValue;
|
||||
if (Array.isArray(def)) {
|
||||
return def.length > 0 ? def : undefined;
|
||||
}
|
||||
return def || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the wire value for one variable: the dynamic "ALL" sentinel, else the
|
||||
* user's selection, else the configured default. Returns `undefined` when there
|
||||
@@ -59,7 +74,7 @@ function resolveValue(
|
||||
return selected as Querybuildertypesv5VariableItemDTOValue;
|
||||
}
|
||||
|
||||
const fallback = configuredDefaultValue(definition);
|
||||
const fallback = configuredDefault(definition);
|
||||
return fallback == null
|
||||
? undefined
|
||||
: (fallback as Querybuildertypesv5VariableItemDTOValue);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
containsAnyVariableReference,
|
||||
textContainsVariableReference,
|
||||
} from 'lib/dashboardVariables/variableReference';
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { toQueryEnvelopes } from './buildQueryRangeRequest';
|
||||
|
||||
@@ -48,18 +45,3 @@ export function getReferencedVariables(
|
||||
texts.some((text) => textContainsVariableReference(text, name)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a panel's queries reference *any* variable, independent of the known
|
||||
* variable set. Used to hold the panel until the variable fetch context is ready:
|
||||
* before then the variable names aren't known, so firing would substitute nothing
|
||||
* (dropping every `$var`) and the query would fail.
|
||||
*/
|
||||
export function queryReferencesAnyVariable(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): boolean {
|
||||
if (queries.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return extractQueryTexts(queries).some(containsAnyVariableReference);
|
||||
}
|
||||
|
||||
@@ -12,25 +12,14 @@ function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
const DASH = 'test-dash';
|
||||
|
||||
function store(): ReturnType<typeof useDashboardStore.getState> {
|
||||
return useDashboardStore.getState();
|
||||
}
|
||||
function states(): Record<string, string> {
|
||||
return store().variableFetchStates;
|
||||
}
|
||||
/** Commit a value for a variable (what a parent must have before a child fetches). */
|
||||
function resolve(name: string): void {
|
||||
store().setVariableValue(DASH, name, {
|
||||
value: `${name}-v`,
|
||||
allSelected: false,
|
||||
});
|
||||
}
|
||||
function reset(names: string[], context: VariableFetchContext): void {
|
||||
useDashboardStore.setState({
|
||||
dashboardId: DASH,
|
||||
variableValues: {},
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
@@ -58,37 +47,28 @@ describe('variableFetchSlice', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('loads query roots + dynamics immediately and waits query dependents', () => {
|
||||
store().enqueueFetchAll();
|
||||
// Dynamics fetch immediately (not gated on the query chain); the query
|
||||
// dependent q2 waits for its parent q1.
|
||||
it('enqueueFetchAll loads roots, waits dependents and (ungated) dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
expect(states()).toMatchObject({
|
||||
q1: 'loading',
|
||||
q2: 'waiting',
|
||||
d1: 'loading',
|
||||
d2: 'loading',
|
||||
d1: 'waiting',
|
||||
d2: 'waiting',
|
||||
});
|
||||
});
|
||||
|
||||
it('a completed parent alone does not unblock the child; its committed value does', () => {
|
||||
store().enqueueFetchAll();
|
||||
store().onVariableFetchComplete('q1');
|
||||
// q1 finished fetching but has not auto-selected a value yet, so q2 holds
|
||||
// rather than fetching with q1 unresolved. Dynamics load regardless.
|
||||
expect(states()).toMatchObject({ q1: 'idle', q2: 'waiting', d1: 'loading' });
|
||||
// q1's value commits → the value cascade unblocks q2.
|
||||
resolve('q1');
|
||||
store().enqueueDescendants('q1');
|
||||
expect(states().q2).not.toBe('waiting');
|
||||
it('enqueueFetchAll loads dynamics immediately when query values exist', () => {
|
||||
store().enqueueFetchAll(true);
|
||||
expect(states().d1).toBe('loading');
|
||||
});
|
||||
|
||||
it('unblocks a query child immediately when its parent already has a value', () => {
|
||||
// Persisted/pre-seeded selection: q1 has a value before it even fetches, so
|
||||
// completing its fetch unblocks q2 straight away (a single fetch, no cascade).
|
||||
resolve('q1');
|
||||
store().enqueueFetchAll();
|
||||
it('completing a parent unblocks its query child, then unlocks dynamics', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchComplete('q1');
|
||||
expect(states().q2).not.toBe('waiting');
|
||||
expect(states()).toMatchObject({ q1: 'idle', q2: 'loading', d1: 'waiting' });
|
||||
|
||||
store().onVariableFetchComplete('q2');
|
||||
expect(states()).toMatchObject({ q2: 'idle', d1: 'loading', d2: 'loading' });
|
||||
});
|
||||
|
||||
it('ignores a settle for a variable that is not actively fetching', () => {
|
||||
@@ -99,14 +79,8 @@ describe('variableFetchSlice', () => {
|
||||
});
|
||||
|
||||
it('changing a query variable revalidates query descendants but NOT dynamics', () => {
|
||||
// Drive the chain to a fully settled state: q1 fetched + valued, q2 fetched.
|
||||
store().enqueueFetchAll();
|
||||
store().onVariableFetchComplete('q1');
|
||||
resolve('q1');
|
||||
store().enqueueDescendants('q1');
|
||||
store().onVariableFetchComplete('q2');
|
||||
resolve('q2');
|
||||
['d1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
|
||||
store().enqueueFetchAll(true);
|
||||
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
|
||||
const before = { ...store().variableCycleIds };
|
||||
|
||||
store().enqueueDescendants('q1');
|
||||
@@ -117,7 +91,7 @@ describe('variableFetchSlice', () => {
|
||||
});
|
||||
|
||||
it('changing a dynamic refreshes the OTHER dynamics, never itself or query vars', () => {
|
||||
store().enqueueFetchAll();
|
||||
store().enqueueFetchAll(true);
|
||||
['q1', 'q2', 'd1', 'd2'].forEach((n) => store().onVariableFetchComplete(n));
|
||||
const before = { ...store().variableCycleIds };
|
||||
|
||||
@@ -128,29 +102,13 @@ describe('variableFetchSlice', () => {
|
||||
});
|
||||
|
||||
it('a failed parent idles its query descendants', () => {
|
||||
store().enqueueFetchAll();
|
||||
store().enqueueFetchAll(false);
|
||||
store().onVariableFetchFailure('q1');
|
||||
expect(states().q1).toBe('error');
|
||||
expect(states().q2).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variableFetchSlice — query depends on a dynamic', () => {
|
||||
// qd (query) references $dyn (a dynamic variable).
|
||||
const dyn = model({ name: 'dyn', type: 'DYNAMIC', dynamicAttribute: 'pod' });
|
||||
const qd = model({ name: 'qd', type: 'QUERY', queryValue: 'SELECT $dyn' });
|
||||
const context = deriveFetchContext([dyn, qd]);
|
||||
|
||||
beforeEach(() => reset(['dyn', 'qd'], context));
|
||||
|
||||
it('does not wait for a dynamic parent — both load immediately', () => {
|
||||
store().enqueueFetchAll();
|
||||
// A dynamic's selected value is already in the selection, so the dependent
|
||||
// query never waits on the dynamic's option fetch; both start together.
|
||||
expect(states()).toMatchObject({ dyn: 'loading', qd: 'loading' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('variableFetchSlice — diamond dependencies', () => {
|
||||
// qA, qB (roots) → qC (references both $qA and $qB).
|
||||
const qA = model({ name: 'qA', type: 'QUERY', queryValue: 'SELECT 1' });
|
||||
@@ -160,19 +118,15 @@ describe('variableFetchSlice — diamond dependencies', () => {
|
||||
|
||||
beforeEach(() => reset(['qA', 'qB', 'qC'], context));
|
||||
|
||||
it('unblocks the child only once BOTH parents have committed values', () => {
|
||||
store().enqueueFetchAll();
|
||||
it('unblocks the child only once BOTH parents are settled', () => {
|
||||
store().enqueueFetchAll(false);
|
||||
expect(states().qC).toBe('waiting');
|
||||
|
||||
store().onVariableFetchComplete('qA');
|
||||
resolve('qA');
|
||||
store().enqueueDescendants('qA');
|
||||
expect(states().qC).toBe('waiting'); // qB has no value yet
|
||||
expect(states().qC).toBe('waiting'); // qB still loading
|
||||
|
||||
store().onVariableFetchComplete('qB');
|
||||
resolve('qB');
|
||||
store().enqueueDescendants('qB');
|
||||
expect(states().qC).not.toBe('waiting'); // both valued → fetches
|
||||
expect(states().qC).not.toBe('waiting'); // both settled → fetches
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,7 +139,7 @@ describe('variableFetchSlice — dependency cycle', () => {
|
||||
beforeEach(() => reset(['qX', 'qY'], context));
|
||||
|
||||
it('enqueues cyclic query variables as best-effort roots (not silently idle)', () => {
|
||||
store().enqueueFetchAll();
|
||||
store().enqueueFetchAll(false);
|
||||
expect(states().qX).not.toBe('idle');
|
||||
expect(states().qY).not.toBe('idle');
|
||||
});
|
||||
|
||||
@@ -1,42 +1,23 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import { hasUsableValue } from '../../VariablesBar/selectionUtils';
|
||||
import type { VariableSelectionMap } from '../../VariablesBar/selectionTypes';
|
||||
import type { VariableFetchContext } from '../../VariablesBar/variableDependencies';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
import { selectVariableValues } from './variableSelectionSlice';
|
||||
import {
|
||||
areAllQueryVariablesSettled,
|
||||
type FetchMaps,
|
||||
isSettled,
|
||||
isVariableInActiveFetchState,
|
||||
resolveFetchState,
|
||||
unlockWaitingDynamicVariables,
|
||||
VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
/**
|
||||
* Whether every QUERY parent of `name` holds a committed value. Gating a child on its
|
||||
* parents' *values* (not their settled fetch state) makes it fetch once, after the
|
||||
* values commit — not prematurely on fetch-complete and again on value-commit.
|
||||
*/
|
||||
function queryParentsHaveValues(
|
||||
name: string,
|
||||
context: VariableFetchContext,
|
||||
selection: VariableSelectionMap,
|
||||
): boolean {
|
||||
const parents = context.dependencyData.parentGraph[name] || [];
|
||||
return parents.every(
|
||||
(p) =>
|
||||
context.variableTypes[p] !== 'QUERY' ||
|
||||
hasUsableValue(selection[p], context.variableTypes[p]),
|
||||
);
|
||||
}
|
||||
|
||||
export { VariableFetchState } from './variableFetchSlice.utils';
|
||||
|
||||
/**
|
||||
* Runtime fetch orchestration for dashboard variables — native port of V1's
|
||||
* `variableFetchStore`. Decides WHEN each variable's options fetch: query
|
||||
* variables in dependency order, dynamics immediately (they are scoped only by
|
||||
* sibling dynamic selections, never by query variables, so nothing gates them),
|
||||
* variables in dependency order, dynamics together once query values exist,
|
||||
* text/custom never. `cycleIds` is a per-variable request nonce keyed into each
|
||||
* selector's react-query key (bump = fresh fetch, auto-cancel stale). Transient.
|
||||
* `enqueueFetchAll` = load/time change; `enqueueDescendants` = one value changed.
|
||||
@@ -45,35 +26,13 @@ export interface VariableFetchSlice {
|
||||
variableFetchStates: Record<string, VariableFetchState>;
|
||||
variableLastUpdated: Record<string, number>;
|
||||
variableCycleIds: Record<string, number>;
|
||||
/**
|
||||
* Whether a QUERY/DYNAMIC variable settled its fetch with zero options (so it
|
||||
* will never get a value). Lets a dependent panel fall through to "no data"
|
||||
* instead of waiting forever on a value that isn't coming.
|
||||
*/
|
||||
variableResolvedEmpty: Record<string, boolean>;
|
||||
/** Static dependency context, set by `initVariableFetch` (null before init). */
|
||||
variableFetchContext: VariableFetchContext | null;
|
||||
/**
|
||||
* Signature (dashboard + time + variable order) of the last full fetch cycle.
|
||||
* A repeat `enqueueFetchAll` with the same signature is skipped, so a component
|
||||
* re-mount can't redo the cycle and double every variable's fetch.
|
||||
*/
|
||||
lastFetchAllKey: string | null;
|
||||
|
||||
/** Seed state entries for the current variable set and store the context. */
|
||||
initVariableFetch: (names: string[], context: VariableFetchContext) => void;
|
||||
/**
|
||||
* Clear all transient fetch state on dashboard-page unmount, so a later visit
|
||||
* starts clean instead of inheriting stale state from this app-level store.
|
||||
*/
|
||||
resetVariableFetch: () => void;
|
||||
/** Record whether a variable settled with no options (drives the panel gate). */
|
||||
setVariableResolvedEmpty: (name: string, isEmpty: boolean) => void;
|
||||
/**
|
||||
* Start a full fetch cycle for every fetchable variable (load / time change).
|
||||
* A repeat call with the same signature `key` is a no-op (idempotent re-mount).
|
||||
*/
|
||||
enqueueFetchAll: (key?: string) => void;
|
||||
/** Start a full fetch cycle for every fetchable variable (load / time change). */
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected: boolean) => void;
|
||||
/** Mark a variable's fetch as done; unblock its waiting children / dynamics. */
|
||||
onVariableFetchComplete: (name: string) => void;
|
||||
/** Mark a variable's fetch as failed; idle its query descendants. */
|
||||
@@ -106,32 +65,10 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
|
||||
resetVariableFetch: (): void => {
|
||||
set({
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
});
|
||||
},
|
||||
|
||||
setVariableResolvedEmpty: (name, isEmpty): void => {
|
||||
const current = get().variableResolvedEmpty;
|
||||
if ((current[name] ?? false) === isEmpty) {
|
||||
return;
|
||||
}
|
||||
set({ variableResolvedEmpty: { ...current, [name]: isEmpty } });
|
||||
},
|
||||
|
||||
initVariableFetch: (names, context): void => {
|
||||
const maps = cloneMaps(get());
|
||||
const resolvedEmpty = { ...get().variableResolvedEmpty };
|
||||
// Initialize new variables to idle, preserving existing states.
|
||||
names.forEach((name) => {
|
||||
if (!maps.states[name]) {
|
||||
@@ -145,23 +82,17 @@ export const createVariableFetchSlice: StateCreator<
|
||||
delete maps.states[name];
|
||||
delete maps.lastUpdated[name];
|
||||
delete maps.cycleIds[name];
|
||||
delete resolvedEmpty[name];
|
||||
}
|
||||
});
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableResolvedEmpty: resolvedEmpty,
|
||||
variableFetchContext: context,
|
||||
});
|
||||
},
|
||||
|
||||
enqueueFetchAll: (key): void => {
|
||||
// Skip a redundant re-run (re-mount with identical inputs) — else it doubles.
|
||||
if (key && key === get().lastFetchAllKey) {
|
||||
return;
|
||||
}
|
||||
enqueueFetchAll: (doAllQueryVariablesHaveValuesSelected): void => {
|
||||
const { variableFetchContext } = get();
|
||||
if (!variableFetchContext) {
|
||||
return;
|
||||
@@ -174,11 +105,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
} = variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
|
||||
// Query variables wait only for their QUERY parents. A DYNAMIC parent does not
|
||||
// gate: its option fetch feeds only its own dropdown, while its selected value
|
||||
// (ALL → `__all__`, or a concrete pick) is already in the selection, so a
|
||||
// dependent query substitutes it immediately and refetches via the cascade if
|
||||
// it later changes. Text/custom parents resolve synchronously, so nothing waits.
|
||||
// Query variables: roots start immediately, dependents wait for parents.
|
||||
queryVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
const parents = dependencyData.parentGraph[name] || [];
|
||||
@@ -189,8 +116,8 @@ export const createVariableFetchSlice: StateCreator<
|
||||
});
|
||||
|
||||
// Query variables dropped from the dependency order (part of a cycle) would
|
||||
// otherwise never fetch — start them as best-effort roots so they surface
|
||||
// data/an error instead of sitting empty.
|
||||
// otherwise never fetch and would stall waiting dynamics — start them as
|
||||
// best-effort roots so they surface data/an error instead of sitting empty.
|
||||
const orderedQuery = new Set(queryVariableOrder);
|
||||
Object.keys(variableTypes).forEach((name) => {
|
||||
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
|
||||
@@ -199,21 +126,19 @@ export const createVariableFetchSlice: StateCreator<
|
||||
}
|
||||
});
|
||||
|
||||
// Dynamic variables fetch immediately, in parallel with the query variables:
|
||||
// their options are scoped only by sibling dynamic selections (never by query
|
||||
// variables), so there is nothing to wait for. Starting early lets them
|
||||
// populate fast even when query variables are slow; a sibling selection change
|
||||
// later refetches them via `enqueueDescendantsBatch`.
|
||||
// Dynamic variables: start now if query variables already have values,
|
||||
// otherwise wait until the query variables settle.
|
||||
dynamicVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
maps.states[name] = resolveFetchState(maps, name);
|
||||
maps.states[name] = doAllQueryVariablesHaveValuesSelected
|
||||
? resolveFetchState(maps, name)
|
||||
: VariableFetchState.Waiting;
|
||||
});
|
||||
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
lastFetchAllKey: key ?? get().lastFetchAllKey,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -229,11 +154,10 @@ export const createVariableFetchSlice: StateCreator<
|
||||
maps.lastUpdated[name] = Date.now();
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes } = variableFetchContext;
|
||||
const selection = selectVariableValues(get().dashboardId)(get());
|
||||
// Release a waiting child only if its parents are already valued (e.g. a
|
||||
// persisted selection). For a just-fetched parent whose value hasn't committed
|
||||
// yet, the value cascade (enqueueDescendantsBatch) unblocks it instead.
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// Unblock a waiting query child only once ALL its parents are settled —
|
||||
// otherwise it would fetch against a not-yet-resolved parent.
|
||||
(dependencyData.graph[name] || []).forEach((child) => {
|
||||
if (
|
||||
variableTypes[child] !== 'QUERY' ||
|
||||
@@ -241,10 +165,18 @@ export const createVariableFetchSlice: StateCreator<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (queryParentsHaveValues(child, variableFetchContext, selection)) {
|
||||
const parents = dependencyData.parentGraph[child] || [];
|
||||
if (parents.every((p) => isSettled(maps.states[p]))) {
|
||||
maps.states[child] = resolveFetchState(maps, child);
|
||||
}
|
||||
});
|
||||
// Once all query variables settle, unlock any waiting dynamics.
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
@@ -263,14 +195,20 @@ export const createVariableFetchSlice: StateCreator<
|
||||
maps.states[name] = VariableFetchState.Error;
|
||||
|
||||
if (variableFetchContext) {
|
||||
const { dependencyData, variableTypes } = variableFetchContext;
|
||||
// Idle query descendants only when a QUERY parent fails (they need its
|
||||
// value); a DYNAMIC failure doesn't block them (they used its selection).
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
// Query descendants can't proceed without this parent — idle them.
|
||||
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
|
||||
if (variableTypes[name] === 'QUERY' && variableTypes[desc] === 'QUERY') {
|
||||
if (variableTypes[desc] === 'QUERY') {
|
||||
maps.states[desc] = VariableFetchState.Idle;
|
||||
}
|
||||
});
|
||||
if (
|
||||
variableTypes[name] === 'QUERY' &&
|
||||
areAllQueryVariablesSettled(maps.states, variableTypes)
|
||||
) {
|
||||
unlockWaitingDynamicVariables(maps, dynamicVariableOrder);
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
@@ -293,11 +231,9 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
const changed = new Set(names);
|
||||
// Callers commit values before this runs, so the gate sees the new parent values.
|
||||
const selection = selectVariableValues(get().dashboardId)(get());
|
||||
|
||||
// Query descendants of the changed vars (not the changed ones): fetch once all
|
||||
// their query parents have a value, else hold until the rest land.
|
||||
// Union of the changed variables' query descendants (never the changed ones
|
||||
// themselves), refreshed once each: refetch when all parents are settled.
|
||||
const queryDescendants = new Set<string>();
|
||||
names.forEach((name) => {
|
||||
(dependencyData.transitiveDescendants[name] || []).forEach((desc) => {
|
||||
@@ -308,24 +244,27 @@ export const createVariableFetchSlice: StateCreator<
|
||||
});
|
||||
queryDescendants.forEach((desc) => {
|
||||
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
|
||||
maps.states[desc] = queryParentsHaveValues(
|
||||
desc,
|
||||
variableFetchContext,
|
||||
selection,
|
||||
)
|
||||
const parents = dependencyData.parentGraph[desc] || [];
|
||||
const allParentsSettled = parents.every((p) => isSettled(maps.states[p]));
|
||||
maps.states[desc] = allParentsSettled
|
||||
? resolveFetchState(maps, desc)
|
||||
: VariableFetchState.Waiting;
|
||||
});
|
||||
|
||||
// A dynamic's options depend only on its sibling DYNAMIC selections, so only a
|
||||
// dynamic change affects them — refresh the *other* dynamics immediately
|
||||
// (never the one that changed, which would refetch its own identical options).
|
||||
// dynamic change affects them — refresh the *other* dynamics (never the one
|
||||
// that changed, which would refetch its own identical options).
|
||||
if (names.some((name) => variableTypes[name] === 'DYNAMIC')) {
|
||||
dynamicVariableOrder
|
||||
.filter((dynName) => !changed.has(dynName))
|
||||
.forEach((dynName) => {
|
||||
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
|
||||
maps.states[dynName] = resolveFetchState(maps, dynName);
|
||||
maps.states[dynName] = areAllQueryVariablesSettled(
|
||||
maps.states,
|
||||
variableTypes,
|
||||
)
|
||||
? resolveFetchState(maps, dynName)
|
||||
: VariableFetchState.Waiting;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { VariableType } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/** Per-variable fetch lifecycle (ported from V1's `variableFetchStore`). */
|
||||
export enum VariableFetchState {
|
||||
Idle = 'idle',
|
||||
@@ -14,6 +16,11 @@ export interface FetchMaps {
|
||||
cycleIds: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Settled = can make no further progress (idle or error). */
|
||||
export function isSettled(state: VariableFetchState | undefined): boolean {
|
||||
return state === VariableFetchState.Idle || state === VariableFetchState.Error;
|
||||
}
|
||||
|
||||
/** Active = a fetch is in flight; only then should a settle be applied. */
|
||||
export function isVariableInActiveFetchState(
|
||||
state: VariableFetchState | undefined,
|
||||
@@ -30,3 +37,25 @@ export function resolveFetchState(
|
||||
? VariableFetchState.Revalidating
|
||||
: VariableFetchState.Loading;
|
||||
}
|
||||
|
||||
/** True once every QUERY variable is settled. */
|
||||
export function areAllQueryVariablesSettled(
|
||||
states: Record<string, VariableFetchState>,
|
||||
variableTypes: Record<string, VariableType>,
|
||||
): boolean {
|
||||
return Object.entries(variableTypes)
|
||||
.filter(([, type]) => type === 'QUERY')
|
||||
.every(([name]) => isSettled(states[name]));
|
||||
}
|
||||
|
||||
/** Move any `waiting` dynamic variables into loading/revalidating. */
|
||||
export function unlockWaitingDynamicVariables(
|
||||
maps: FetchMaps,
|
||||
dynamicVariableOrder: string[],
|
||||
): void {
|
||||
dynamicVariableOrder.forEach((dynName) => {
|
||||
if (maps.states[dynName] === VariableFetchState.Waiting) {
|
||||
maps.states[dynName] = resolveFetchState(maps, dynName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useS
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/useSeedVariableSelection';
|
||||
import { withVariablesSearch } from '../DashboardContainer/VariablesBar/variablesUrlState';
|
||||
import styles from './PanelEditorPage.module.scss';
|
||||
|
||||
/**
|
||||
@@ -95,10 +96,15 @@ function PanelEditorPage(): JSX.Element {
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
const backToDashboard = useCallback((): void => {
|
||||
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
|
||||
// variable selection from the persisted store, and time lives in Redux.
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [safeNavigate, dashboardId]);
|
||||
// Carry only dashboard params; drop editor-only URL state (chiefly
|
||||
// `compositeQuery`) so it doesn't leak into the dashboard. Time lives in Redux.
|
||||
safeNavigate(
|
||||
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}${withVariablesSearch(
|
||||
'',
|
||||
search,
|
||||
)}`,
|
||||
);
|
||||
}, [safeNavigate, dashboardId, search]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
|
||||
6
go.mod
6
go.mod
@@ -9,7 +9,7 @@ require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.3
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1
|
||||
github.com/antonmedv/expr v1.15.3
|
||||
github.com/bytedance/sonic v1.14.1
|
||||
@@ -150,10 +150,6 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/collector/client v1.54.0 // indirect
|
||||
go.opentelemetry.io/collector/config/configoptional v1.50.0 // indirect
|
||||
go.opentelemetry.io/collector/config/configretry v1.50.0 // indirect
|
||||
go.opentelemetry.io/collector/exporter/exporterhelper v0.144.0 // indirect
|
||||
go.opentelemetry.io/collector/internal/componentalias v0.148.0 // indirect
|
||||
go.opentelemetry.io/collector/pdata/xpdata v0.148.0 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
|
||||
25
go.sum
25
go.sum
@@ -110,11 +110,13 @@ github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72g
|
||||
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.3 h1:/7PPIqIpPsaWtrgnfHam2XVYP41ZlgEKLHzQO8oVxcA=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.3/go.mod h1:9pLVpcIQvUT88ZiNnZN/MI+XLruvwC+Xm2UzPmGjNfA=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
|
||||
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
|
||||
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
|
||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
||||
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
||||
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -124,6 +126,10 @@ github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vS
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
|
||||
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
@@ -286,6 +292,8 @@ github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/elastic/go-grok v0.3.1 h1:WEhUxe2KrwycMnlvMimJXvzRa7DoByJB4PVUIE1ZD/U=
|
||||
github.com/elastic/go-grok v0.3.1/go.mod h1:n38ls8ZgOboZRgKcjMY8eFeZFMmcL9n2lP0iHhIDk64=
|
||||
github.com/elastic/lunes v0.2.0 h1:WI3bsdOTuaYXVe2DS1KbqA7u7FOHN4o8qJw80ZyZoQs=
|
||||
github.com/elastic/lunes v0.2.0/go.mod h1:u3W/BdONWTrh0JjNZ21C907dDc+cUZttZrGa625nf2k=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
@@ -461,6 +469,7 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
@@ -674,6 +683,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
|
||||
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
|
||||
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
|
||||
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -866,12 +877,18 @@ github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.144.0/go.mod h1:O2rZKRXk1WeYhzfJBVXES/g7+PlIds/TzPZW/4NfTNA=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0 h1:CiTjQE/Hh5xK2t56ogrDK4nl0+tJPNmASCs4zEYZ/xU=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0/go.mod h1:WUFkzTiOpt7EYyL67gv1GOf3RD8qKWGtin3lY9LYzW4=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.144.0 h1:Ywu5mU4K5TMJigiXdyZloCRs/cq3/2OnoK3WjxNHWJo=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.144.0/go.mod h1:iebqlu6UvpiV1hO37r1sXA9fXaCaA8sQXilG0///xss=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.144.0 h1:TMRTvQSAeeLtkKwSrqcbectxDRPiqB6yYM3IvjC75es=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.144.0/go.mod h1:1HU0qJ4hFrphDebuBs3I4DPQ6zyBFGinQ5/bXEUM7pw=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.148.0 h1:i12duJOl5VCb9mbb8FfZCaP2CjeXbNsbg82JjSe7sy8=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.148.0/go.mod h1:jyw+QvkmCrF/oYy31O2ndb5KZZK4l+iR89msnV3LN/k=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0 h1:1TLg6YrS3Au6F7xw3ws2Njbwj13IMqPplvGFi+18fWs=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0/go.mod h1:P8hZEDIQk4REgUWyLhSVRHwTxK6KkifKfg36BmmQ/DI=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.144.0 h1:EIAygME70IOdEwaSr6bA3Wcdp7hXEqRsGsVfrI5v8OA=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.144.0/go.mod h1:3Y6ctEEwRg19B0jqsrQH6Hiquqte+zC0ZxpXLLSa5sA=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.144.0 h1:gRk73SsIJv3q/HI0kxMRN5TiIiJj+MRxrz0GEYx3jZw=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.144.0/go.mod h1:7b8ZcPdN6JSm6+LUq3rienoqHHiX60kyoOHDPCMM+L8=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0 h1:xgD/kNGp/wWY+bwY599Pc01OamYN17phRiTP934bM5Y=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0/go.mod h1:ZK7wvaefla9lB3bAW0rNKt7IzRPcTRQoOFqr4sZy/XM=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/logstransformprocessor v0.144.0 h1:HbmpzTixpQG/xGhQuQoiJTXQPrixe+yivAsF6tl2o4g=
|
||||
@@ -1119,6 +1136,10 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GH
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
|
||||
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6 h1:SIKIoA4e/5Y9ZOl0DCe3eVMLPOQzJxgZpfdHHeauNTM=
|
||||
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6/go.mod h1:BUbeWZiieNxAuuADTBNb3/aeje6on3DhU3rpWsQSB1E=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/uptrace/bun v1.2.9 h1:OOt2DlIcRUMSZPr6iXDFg/LaQd59kOxbAjpIVHddKRs=
|
||||
|
||||
@@ -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{
|
||||
@@ -98,7 +118,7 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
|
||||
Description: "Returns all mappers belonging to a mapping group.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(spantypes.GettableSpanMapperGroups),
|
||||
Response: new(spantypes.GettableSpanMappers),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
|
||||
@@ -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, collectorLogs, 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, CollectorLogs: collectorLogs})
|
||||
}
|
||||
|
||||
// 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/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -105,6 +106,61 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error) {
|
||||
if len(spans) == 0 {
|
||||
return nil, nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
|
||||
}
|
||||
if len(spans) > maxTestSpans {
|
||||
return nil, nil, errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at most %d spans", maxTestSpans)
|
||||
}
|
||||
|
||||
resolved, err := module.backfillMappers(ctx, orgID, groups)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
out, collectorLogs, err := spantypes.SimulateSpanMappersProcessing(ctx, resolved, spans)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, collectorLogs, nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
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 || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, 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, []string, 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)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ type SpanMapper struct {
|
||||
types.UserAuditable
|
||||
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
GroupID valuer.UUID `json:"group_id" required:"true"`
|
||||
GroupID valuer.UUID `json:"groupId" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
@@ -63,7 +63,7 @@ type SpanMapper struct {
|
||||
|
||||
type PostableSpanMapper struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
183
pkg/types/spantypes/spanmappersimulator.go
Normal file
183
pkg/types/spantypes/spanmappersimulator.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/pkg/collectorsimulator"
|
||||
"github.com/SigNoz/signoz-otel-collector/processor/signozspanmapperprocessor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"go.opentelemetry.io/collector/otelcol"
|
||||
"go.opentelemetry.io/collector/pdata/ptrace"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeProcessorFactoryMapFailed = errors.MustNewCode("processor_factory_map_failed")
|
||||
ErrCodeSpanMapperSimulationFailed = errors.MustNewCode("span_mapper_simulation_failed")
|
||||
)
|
||||
|
||||
const spanInputOrderAttr = "__signoz_input_idx__"
|
||||
|
||||
func SimulateSpanMappersProcessing(ctx context.Context, groups []*SpanMapperGroupWithMappers, spans []SpanMapperTestSpan) ([]SpanMapperTestSpan, []string, error) {
|
||||
enabled := filterEnabledGroupsWithMappers(groups)
|
||||
if len(enabled) < 1 {
|
||||
return spans, nil, nil
|
||||
}
|
||||
|
||||
// Tag each span with its request position so the output can be returned in
|
||||
// input order; work on copies so the caller's attribute maps stay untouched.
|
||||
inputSpans := make([]SpanMapperTestSpan, len(spans))
|
||||
for i, s := range spans {
|
||||
attrs := make(map[string]any, len(s.Attributes)+1)
|
||||
maps.Copy(attrs, s.Attributes)
|
||||
attrs[spanInputOrderAttr] = int64(i)
|
||||
inputSpans[i] = SpanMapperTestSpan{Attributes: attrs, Resource: s.Resource}
|
||||
}
|
||||
simulatorInput := SpansToPTraces(inputSpans)
|
||||
|
||||
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) {
|
||||
withProcessor, err := GenerateCollectorConfigWithSpanMapperProcessor(baseConf, enabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wireSpanMapperIntoTracesPipeline(withProcessor)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// The simulator returns whatever has been exported when its timeout
|
||||
// elapses, without error; surface that instead of silently dropping spans.
|
||||
if len(outputSpans) != len(spans) {
|
||||
return nil, nil, errors.Newf(errors.TypeInternal, ErrCodeSpanMapperSimulationFailed, "simulation returned %d spans for %d input spans", len(outputSpans), len(spans))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return outputSpans, collectorErrs, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// wireSpanMapperIntoTracesPipeline appends "signozspanmapper" to
|
||||
// service.pipelines.traces.processors so the processor defined by
|
||||
// GenerateCollectorConfigWithSpanMapperProcessor actually runs against the
|
||||
// traces flowing through the simulator. Idempotent: skips appending if the
|
||||
// processor name is already present. Errors if the config has no traces
|
||||
// pipeline, since the simulation would otherwise silently pass spans through.
|
||||
func wireSpanMapperIntoTracesPipeline(confYaml []byte) ([]byte, error) {
|
||||
var conf map[string]any
|
||||
if err := yaml.Unmarshal(confYaml, &conf); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config for pipeline wiring")
|
||||
}
|
||||
// Failed assertions yield nil, and indexing a nil map is safe,
|
||||
// so any missing/mistyped level surfaces as traces == nil below.
|
||||
service, _ := conf["service"].(map[string]any)
|
||||
pipelines, _ := service["pipelines"].(map[string]any)
|
||||
traces, _ := pipelines["traces"].(map[string]any)
|
||||
if traces == nil {
|
||||
return nil, errors.Newf(errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "collector config has no service.pipelines.traces pipeline to wire %q into", ProcessorName)
|
||||
}
|
||||
|
||||
procs, _ := traces["processors"].([]any)
|
||||
for _, p := range procs {
|
||||
if name, ok := p.(string); ok && name == ProcessorName {
|
||||
return confYaml, nil
|
||||
}
|
||||
}
|
||||
traces["processors"] = append(procs, ProcessorName)
|
||||
|
||||
out, err := yaml.Marshal(conf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal collector config after pipeline wiring")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
43
pkg/types/spantypes/spanmappersimulator_test.go
Normal file
43
pkg/types/spantypes/spanmappersimulator_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSimulateSpanMappersProcessing_EndToEnd is an integration test: it spins
|
||||
// up an actual in-memory otel-collector pipeline with signozspanmapperprocessor
|
||||
// and verifies the produced span has the expected target attribute.
|
||||
func TestSimulateSpanMappersProcessing_EndToEnd(t *testing.T) {
|
||||
groups := []*SpanMapperGroupWithMappers{{
|
||||
Group: &SpanMapperGroup{
|
||||
Name: "llm",
|
||||
Condition: SpanMapperGroupCondition{Attributes: []string{"model"}},
|
||||
Enabled: true,
|
||||
},
|
||||
Mappers: []*SpanMapper{{
|
||||
Name: "gen_ai.request.model",
|
||||
FieldContext: FieldContextSpanAttribute,
|
||||
Config: SpanMapperConfig{Sources: []SpanMapperSource{
|
||||
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1},
|
||||
}},
|
||||
Enabled: true,
|
||||
}},
|
||||
}}
|
||||
|
||||
spans := []SpanMapperTestSpan{{Attributes: map[string]any{"llm.model": "gpt-4"}}}
|
||||
|
||||
out, _, err := SimulateSpanMappersProcessing(context.Background(), groups, spans)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
assert.Equal(t, "gpt-4", out[0].Attributes["gen_ai.request.model"], "target attribute must be populated by the spanmapper processor")
|
||||
// Source attribute is preserved (copy, not move).
|
||||
assert.Equal(t, "gpt-4", out[0].Attributes["llm.model"])
|
||||
// Order-tracking attribute is stripped from the output.
|
||||
_, hasOrderAttr := out[0].Attributes[spanInputOrderAttr]
|
||||
assert.False(t, hasOrderAttr, "internal ordering attribute must be removed from the response")
|
||||
}
|
||||
54
pkg/types/spantypes/spanmappertest.go
Normal file
54
pkg/types/spantypes/spanmappertest.go
Normal file
@@ -0,0 +1,54 @@
|
||||
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"`
|
||||
CollectorLogs []string `json:"collectorLogs"`
|
||||
}
|
||||
|
||||
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,8 +21,11 @@ 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
|
||||
ArraySepSuffix = "[]"
|
||||
// ArraySep must match the array separator written by the collector's JSON
|
||||
// type exporter; the shared constant (jsontypeexporter.ArraySeparator) was
|
||||
// removed from signoz-otel-collector, so the value is duplicated here.
|
||||
ArraySep = "[]."
|
||||
ArraySepSuffix = "[]"
|
||||
// TODO(Piyush): Remove once we've migrated to the new array syntax.
|
||||
ArrayAnyIndex = "[*]."
|
||||
ArrayAnyIndexSuffix = "[*]"
|
||||
|
||||
@@ -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 := ArraySep
|
||||
if n.Parent.isRoot {
|
||||
sep = "."
|
||||
}
|
||||
|
||||
174
tests/integration/tests/spanmapper/01_span_mapper.py
Normal file
174
tests/integration/tests/spanmapper/01_span_mapper.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
|
||||
GROUPS_PATH = "/api/v1/span_mapper_groups"
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"authorization": f"Bearer {token}",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def test_create_groups_and_simulate_with_backfill(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Create a mapping group and a mapper through the CRUD API, then hit the
|
||||
simulate endpoint with two groups: the saved one referenced by name only
|
||||
(server backfills its mappers from the store) and a second, unsaved group
|
||||
sent with inline mappers.
|
||||
|
||||
Tests:
|
||||
1. Create a group (POST /span_mapper_groups) and read back its id
|
||||
2. Create a mapper in that group (POST .../{groupId}/span_mappers)
|
||||
3. List groups and mappers and verify what we created is persisted
|
||||
4. Simulate spans against both groups: a matching span gets the saved
|
||||
group's mappers (backfilled, copy) and the inline group's mappers
|
||||
(move) applied, while a span matching no condition passes through
|
||||
5. Delete the group and verify it (and its mappers) are gone
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
create_group = requests.post(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
json={
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert create_group.status_code == HTTPStatus.CREATED
|
||||
group = create_group.json()["data"]
|
||||
groupId = group["id"]
|
||||
assert group["name"] == "llm-backfill"
|
||||
assert group["enabled"] is True
|
||||
|
||||
create_mapper = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}/span_mappers"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
json={
|
||||
"name": "gen_ai.request.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert create_mapper.status_code == HTTPStatus.CREATED
|
||||
mapper = create_mapper.json()["data"]
|
||||
assert mapper["name"] == "gen_ai.request.model"
|
||||
assert mapper["groupId"] == groupId
|
||||
|
||||
list_groups = requests.get(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert list_groups.status_code == HTTPStatus.OK
|
||||
assert groupId in [g["id"] for g in list_groups.json()["data"]["items"]]
|
||||
|
||||
list_mappers = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}/span_mappers"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert list_mappers.status_code == HTTPStatus.OK
|
||||
assert "gen_ai.request.model" in [m["name"] for m in list_mappers.json()["data"]["items"]]
|
||||
|
||||
simulate = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/test"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
json={
|
||||
"spans": [
|
||||
{
|
||||
"attributes": {"llm.model": "gpt-4", "db.system": "postgres"},
|
||||
"resource": {},
|
||||
},
|
||||
# Matches no group condition, so it must pass through untouched.
|
||||
{
|
||||
"attributes": {"http.method": "GET"},
|
||||
"resource": {},
|
||||
},
|
||||
],
|
||||
"groups": [
|
||||
# No "mappers" key: the server backfills them from the saved group.
|
||||
{
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
# Unsaved group; mappers provided inline.
|
||||
{
|
||||
"name": "db-inline",
|
||||
"condition": {"attributes": ["db"], "resource": []},
|
||||
"enabled": True,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "db.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "db.system",
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabled": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert simulate.status_code == HTTPStatus.OK
|
||||
out_spans = simulate.json()["data"]["spans"]
|
||||
assert len(out_spans) == 2
|
||||
attrs = out_spans[0]["attributes"]
|
||||
assert attrs["gen_ai.request.model"] == "gpt-4" # backfilled mapper applied
|
||||
assert attrs["llm.model"] == "gpt-4" # copy preserves source
|
||||
assert attrs["db.name"] == "postgres" # inline mapper applied
|
||||
assert "db.system" not in attrs # move removes source
|
||||
assert out_spans[1]["attributes"] == {"http.method": "GET"} # unchanged
|
||||
|
||||
delete_group = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert delete_group.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
list_after_delete = requests.get(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert list_after_delete.status_code == HTTPStatus.OK
|
||||
assert groupId not in [g["id"] for g in list_after_delete.json()["data"]["items"]]
|
||||
0
tests/integration/tests/spanmapper/__init__.py
Normal file
0
tests/integration/tests/spanmapper/__init__.py
Normal file
Reference in New Issue
Block a user