mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-25 19:30:33 +01:00
Compare commits
5 Commits
e2e/table-
...
fix/ext-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d2ee01d4 | ||
|
|
3ffb5bd43b | ||
|
|
67324edb7e | ||
|
|
9ba57d323d | ||
|
|
09f4ba33c9 |
@@ -60,6 +60,14 @@ web:
|
||||
index: index.html
|
||||
# The directory containing the static build files.
|
||||
directory: /etc/signoz/web
|
||||
# Settings exposed to the web.
|
||||
settings:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: true
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: true
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -5641,6 +5641,19 @@ components:
|
||||
type: object
|
||||
Sigv4SigV4Config:
|
||||
type: object
|
||||
SpantypesEvent:
|
||||
properties:
|
||||
attributeMap:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
isError:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
timeUnixNano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesFieldContext:
|
||||
enum:
|
||||
- attribute
|
||||
@@ -5655,6 +5668,50 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
|
||||
nullable: true
|
||||
type: array
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
hasMore:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
serviceNameToTotalDurationMap:
|
||||
additionalProperties:
|
||||
minimum: 0
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesWaterfallSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -5682,6 +5739,50 @@ components:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableWaterfall:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
minimum: 0
|
||||
type: integer
|
||||
selectedSpanId:
|
||||
type: string
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesSpanAggregation:
|
||||
properties:
|
||||
aggregation:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregationType'
|
||||
field:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: object
|
||||
SpantypesSpanAggregationResult:
|
||||
properties:
|
||||
aggregation:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregationType'
|
||||
field:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
value:
|
||||
additionalProperties:
|
||||
minimum: 0
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesSpanAggregationType:
|
||||
enum:
|
||||
- span_count
|
||||
- execution_time_percentage
|
||||
- duration
|
||||
type: string
|
||||
SpantypesSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -5812,6 +5913,78 @@ components:
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
SpantypesWaterfallSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
db_name:
|
||||
type: string
|
||||
db_operation:
|
||||
type: string
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
nullable: true
|
||||
type: array
|
||||
external_http_method:
|
||||
type: string
|
||||
external_http_url:
|
||||
type: string
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
type: boolean
|
||||
http_host:
|
||||
type: string
|
||||
http_method:
|
||||
type: string
|
||||
http_url:
|
||||
type: string
|
||||
is_remote:
|
||||
type: string
|
||||
kind_string:
|
||||
type: string
|
||||
level:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
response_status_code:
|
||||
type: string
|
||||
span_id:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
sub_tree_node_count:
|
||||
minimum: 0
|
||||
type: integer
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -5904,179 +6077,6 @@ components:
|
||||
TimeDuration:
|
||||
format: int64
|
||||
type: integer
|
||||
TracedetailtypesEvent:
|
||||
properties:
|
||||
attributeMap:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
isError:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
timeUnixNano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
TracedetailtypesGettableWaterfallTrace:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesSpanAggregationResult'
|
||||
nullable: true
|
||||
type: array
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
hasMore:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
serviceNameToTotalDurationMap:
|
||||
additionalProperties:
|
||||
minimum: 0
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
TracedetailtypesPostableWaterfall:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesSpanAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
minimum: 0
|
||||
type: integer
|
||||
selectedSpanId:
|
||||
type: string
|
||||
uncollapsedSpans:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
TracedetailtypesSpanAggregation:
|
||||
properties:
|
||||
aggregation:
|
||||
$ref: '#/components/schemas/TracedetailtypesSpanAggregationType'
|
||||
field:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: object
|
||||
TracedetailtypesSpanAggregationResult:
|
||||
properties:
|
||||
aggregation:
|
||||
$ref: '#/components/schemas/TracedetailtypesSpanAggregationType'
|
||||
field:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
value:
|
||||
additionalProperties:
|
||||
minimum: 0
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
TracedetailtypesSpanAggregationType:
|
||||
enum:
|
||||
- span_count
|
||||
- execution_time_percentage
|
||||
- duration
|
||||
type: string
|
||||
TracedetailtypesWaterfallSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
db_name:
|
||||
type: string
|
||||
db_operation:
|
||||
type: string
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesEvent'
|
||||
nullable: true
|
||||
type: array
|
||||
external_http_method:
|
||||
type: string
|
||||
external_http_url:
|
||||
type: string
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
type: boolean
|
||||
http_host:
|
||||
type: string
|
||||
http_method:
|
||||
type: string
|
||||
http_url:
|
||||
type: string
|
||||
is_remote:
|
||||
type: string
|
||||
kind_string:
|
||||
type: string
|
||||
level:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
response_status_code:
|
||||
type: string
|
||||
span_id:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
sub_tree_node_count:
|
||||
minimum: 0
|
||||
type: integer
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
type: object
|
||||
TypesAlertStatus:
|
||||
properties:
|
||||
inhibitedBy:
|
||||
@@ -18896,7 +18896,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TracedetailtypesPostableWaterfall'
|
||||
$ref: '#/components/schemas/SpantypesPostableWaterfall'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -18904,7 +18904,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TracedetailtypesGettableWaterfallTrace'
|
||||
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -6655,6 +6655,28 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type SpantypesEventDTOAttributeMap = { [key: string]: unknown };
|
||||
|
||||
export interface SpantypesEventDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributeMap?: SpantypesEventDTOAttributeMap;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timeUnixNano?: number;
|
||||
}
|
||||
|
||||
export enum SpantypesFieldContextDTO {
|
||||
attribute = 'attribute',
|
||||
resource = 'resource',
|
||||
@@ -6721,6 +6743,232 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf =
|
||||
{ [key: string]: number };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap =
|
||||
SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf | null;
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
duration = 'duration',
|
||||
}
|
||||
export type SpantypesSpanAggregationResultDTOValueAnyOf = {
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanAggregationResultDTOValue =
|
||||
SpantypesSpanAggregationResultDTOValueAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanAggregationResultDTO {
|
||||
aggregation?: SpantypesSpanAggregationTypeDTO;
|
||||
field?: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
value?: SpantypesSpanAggregationResultDTOValue;
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesWaterfallSpanDTOAttributes =
|
||||
SpantypesWaterfallSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesWaterfallSpanDTOResourceAnyOf = { [key: string]: string };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesWaterfallSpanDTOResource =
|
||||
SpantypesWaterfallSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesWaterfallSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesWaterfallSpanDTOAttributes;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_operation?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
events?: SpantypesEventDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_url?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_children?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_host?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
is_remote?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
level?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesWaterfallSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
response_status_code?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
status_code?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
sub_tree_node_count?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_state?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableWaterfallTraceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
serviceNameToTotalDurationMap?: SpantypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans?: SpantypesWaterfallSpanDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
@@ -6770,6 +7018,31 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation?: SpantypesSpanAggregationTypeDTO;
|
||||
field?: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableWaterfallDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: SpantypesSpanAggregationDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedSpanId?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
@@ -6878,281 +7151,6 @@ export interface TelemetrytypesGettableFieldValuesDTO {
|
||||
values: TelemetrytypesTelemetryFieldValuesDTO;
|
||||
}
|
||||
|
||||
export type TracedetailtypesEventDTOAttributeMap = { [key: string]: unknown };
|
||||
|
||||
export interface TracedetailtypesEventDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributeMap?: TracedetailtypesEventDTOAttributeMap;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timeUnixNano?: number;
|
||||
}
|
||||
|
||||
export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf =
|
||||
{ [key: string]: number };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap =
|
||||
TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf | null;
|
||||
|
||||
export enum TracedetailtypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
duration = 'duration',
|
||||
}
|
||||
export type TracedetailtypesSpanAggregationResultDTOValueAnyOf = {
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesSpanAggregationResultDTOValue =
|
||||
TracedetailtypesSpanAggregationResultDTOValueAnyOf | null;
|
||||
|
||||
export interface TracedetailtypesSpanAggregationResultDTO {
|
||||
aggregation?: TracedetailtypesSpanAggregationTypeDTO;
|
||||
field?: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
value?: TracedetailtypesSpanAggregationResultDTOValue;
|
||||
}
|
||||
|
||||
export type TracedetailtypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesWaterfallSpanDTOAttributes =
|
||||
TracedetailtypesWaterfallSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type TracedetailtypesWaterfallSpanDTOResourceAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesWaterfallSpanDTOResource =
|
||||
TracedetailtypesWaterfallSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface TracedetailtypesWaterfallSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: TracedetailtypesWaterfallSpanDTOAttributes;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
db_operation?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
events?: TracedetailtypesEventDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_http_url?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_children?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_host?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_method?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
http_url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
is_remote?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
level?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: TracedetailtypesWaterfallSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
response_status_code?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
status_code?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
sub_tree_node_count?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_state?: string;
|
||||
}
|
||||
|
||||
export interface TracedetailtypesGettableWaterfallTraceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: TracedetailtypesSpanAggregationResultDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
serviceNameToTotalDurationMap?: TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans?: TracedetailtypesWaterfallSpanDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface TracedetailtypesSpanAggregationDTO {
|
||||
aggregation?: TracedetailtypesSpanAggregationTypeDTO;
|
||||
field?: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
}
|
||||
|
||||
export interface TracedetailtypesPostableWaterfallDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: TracedetailtypesSpanAggregationDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedSpanId?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface TypesChangePasswordRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9232,7 +9230,7 @@ export type GetWaterfallPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfall200 = {
|
||||
data: TracedetailtypesGettableWaterfallTraceDTO;
|
||||
data: SpantypesGettableWaterfallTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
TracedetailtypesPostableWaterfallDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -27,14 +27,14 @@ import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
*/
|
||||
export const getWaterfall = (
|
||||
{ traceID }: GetWaterfallPathParameters,
|
||||
tracedetailtypesPostableWaterfallDTO?: BodyType<TracedetailtypesPostableWaterfallDTO>,
|
||||
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfall200>({
|
||||
url: `/api/v3/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: tracedetailtypesPostableWaterfallDTO,
|
||||
data: spantypesPostableWaterfallDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -48,7 +48,7 @@ export const getGetWaterfallMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -57,7 +57,7 @@ export const getGetWaterfallMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -74,7 +74,7 @@ export const getGetWaterfallMutationOptions = <
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -89,7 +89,7 @@ export type GetWaterfallMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfall>>
|
||||
>;
|
||||
export type GetWaterfallMutationBody =
|
||||
| BodyType<TracedetailtypesPostableWaterfallDTO>
|
||||
| BodyType<SpantypesPostableWaterfallDTO>
|
||||
| undefined;
|
||||
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
@@ -105,7 +105,7 @@ export const useGetWaterfall = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -114,7 +114,7 @@ export const useGetWaterfall = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
@@ -5,7 +5,10 @@ import cx from 'classnames';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -196,7 +199,7 @@ function ListLogView({
|
||||
{updatedSelecedFields.some((field) => field.name === 'body') && (
|
||||
<LogGeneralField
|
||||
fieldKey="Log"
|
||||
fieldValue={flattenLogData.body}
|
||||
fieldValue={getBodyDisplayString(logData.body)}
|
||||
linesPerRow={linesPerRow}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
|
||||
@@ -93,7 +93,6 @@ function ValueGraph({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="value-graph-container"
|
||||
data-testid="value-graph-container"
|
||||
style={{
|
||||
backgroundColor:
|
||||
threshold.thresholdFormat === 'Background'
|
||||
|
||||
@@ -159,8 +159,6 @@ function GridTableComponent({
|
||||
if (threshold && idx !== -1) {
|
||||
return (
|
||||
<div
|
||||
data-testid="threshold-styled-cell"
|
||||
data-threshold-format={threshold.thresholdFormat}
|
||||
style={
|
||||
threshold.thresholdFormat === 'Background'
|
||||
? { backgroundColor: threshold.thresholdColor }
|
||||
|
||||
@@ -263,7 +263,7 @@ function External(): JSX.Element {
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address || '',
|
||||
isError: true,
|
||||
stepInterval: 300,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
@@ -306,7 +306,7 @@ function External(): JSX.Element {
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
@@ -352,7 +352,7 @@ function External(): JSX.Element {
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
@@ -395,7 +395,7 @@ function External(): JSX.Element {
|
||||
timestamp: selectedTimeStamp,
|
||||
domainName: selectedData?.address,
|
||||
isError: false,
|
||||
stepInterval: 300,
|
||||
stepInterval,
|
||||
safeNavigate,
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -151,7 +151,7 @@ export function onViewAPIMonitoringPopupClick({
|
||||
safeNavigate,
|
||||
}: OnViewAPIMonitoringPopupClickProps): (e?: React.MouseEvent) => void {
|
||||
return (e?: React.MouseEvent): void => {
|
||||
const endTime = timestamp + (stepInterval || 60);
|
||||
const endTime = timestamp;
|
||||
const startTime = timestamp - (stepInterval || 60);
|
||||
const filters = {
|
||||
items: [
|
||||
|
||||
@@ -231,14 +231,12 @@ function Threshold({
|
||||
type="text"
|
||||
icon={<Pencil size={14} />}
|
||||
className="edit-btn"
|
||||
data-testid="threshold-edit-btn"
|
||||
onClick={editHandler}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Trash2 size={14} />}
|
||||
className="delete-btn"
|
||||
data-testid="threshold-delete-btn"
|
||||
onClick={deleteHandler}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -311,7 +311,7 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
const endTimeText = useMemo((): string => {
|
||||
const endTime = formData.endTime;
|
||||
@@ -322,7 +322,7 @@ export function PlannedDowntimeForm(
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, recurrenceType]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -17,9 +17,9 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get waterfall view for a trace",
|
||||
Description: "Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination",
|
||||
Request: new(tracedetailtypes.PostableWaterfall),
|
||||
Request: new(spantypes.PostableWaterfall),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(tracedetailtypes.GettableWaterfallTrace),
|
||||
Response: new(spantypes.GettableWaterfallTrace),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
|
||||
@@ -2,8 +2,11 @@ package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -34,11 +37,22 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
return err
|
||||
}
|
||||
group.Update(name, condition, enabled, updatedBy)
|
||||
return module.store.UpdateGroup(ctx, group)
|
||||
|
||||
err = module.store.UpdateGroup(ctx, group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.store.DeleteGroup(ctx, orgID, id)
|
||||
err := module.store.DeleteGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]*spantypes.SpanMapper, error) {
|
||||
@@ -54,7 +68,12 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
|
||||
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.CreateMapper(ctx, mapper)
|
||||
err := module.store.CreateMapper(ctx, mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
|
||||
@@ -66,9 +85,72 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
|
||||
return err
|
||||
}
|
||||
mapper.Update(fieldContext, config, enabled, updatedBy)
|
||||
return module.store.UpdateMapper(ctx, mapper)
|
||||
err = module.store.UpdateMapper(ctx, mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
|
||||
return module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return spantypes.SpanAttrMappingFeatureType
|
||||
}
|
||||
|
||||
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
enabled, err := module.listEnabledGroupsWithMappers(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, err := spantypes.GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml, enabled)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(enabled)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// listEnabledGroupsWithMappers returns groups with their mappers.
|
||||
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
enabled := true
|
||||
groups, err := module.store.ListGroups(ctx, orgID, &spantypes.ListSpanMapperGroupsQuery{Enabled: &enabled})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]*spantypes.SpanMapperGroupWithMappers, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
mappers, err := module.store.ListMappers(ctx, orgID, g.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabledMappers := make([]*spantypes.SpanMapper, 0, len(mappers))
|
||||
for _, m := range mappers {
|
||||
if m.Enabled {
|
||||
enabledMappers = append(enabledMappers, m)
|
||||
}
|
||||
}
|
||||
if len(enabledMappers) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, &spantypes.SpanMapperGroupWithMappers{Group: g, Mappers: enabledMappers})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Module defines the business logic for span attribute mapping groups and mappers.
|
||||
type Module interface {
|
||||
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
|
||||
agentConf.AgentFeature
|
||||
|
||||
// Group operations
|
||||
ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error)
|
||||
GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes.SpanMapperGroup, error)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(tracedetailtypes.PostableWaterfall)
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -5,16 +5,16 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store tracedetailtypes.TraceStore
|
||||
store spantypes.TraceStore
|
||||
settings factory.ScopedProviderSettings
|
||||
config tracedetail.Config
|
||||
}
|
||||
|
||||
func NewModule(traceStore tracedetailtypes.TraceStore, providerSettings factory.ProviderSettings, cfg tracedetail.Config) *module {
|
||||
func NewModule(traceStore spantypes.TraceStore, providerSettings factory.ProviderSettings, cfg tracedetail.Config) *module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail")
|
||||
return &module{
|
||||
config: cfg,
|
||||
@@ -23,7 +23,7 @@ func NewModule(traceStore tracedetailtypes.TraceStore, providerSettings factory.
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *tracedetailtypes.PostableWaterfall) (*tracedetailtypes.GettableWaterfallTrace, error) {
|
||||
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
|
||||
waterfallTrace, err := m.getTraceData(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -37,16 +37,16 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *tracedet
|
||||
m.config.Waterfall.MaxDepthToAutoExpand,
|
||||
)
|
||||
|
||||
aggregationResults := make([]tracedetailtypes.SpanAggregationResult, 0, len(req.Aggregations))
|
||||
aggregationResults := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
|
||||
for _, a := range req.Aggregations {
|
||||
aggregationResults = append(aggregationResults, waterfallTrace.GetSpanAggregation(a.Aggregation, a.Field))
|
||||
}
|
||||
|
||||
return tracedetailtypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
|
||||
}
|
||||
|
||||
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*tracedetailtypes.WaterfallTrace, error) {
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -58,9 +58,9 @@ func (m *module) getTraceData(ctx context.Context, traceID string) (*tracedetail
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, tracedetailtypes.ErrTraceNotFound
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
traceData := tracedetailtypes.NewWaterfallTraceFromSpans(spanItems)
|
||||
traceData := spantypes.NewWaterfallTraceFromSpans(spanItems)
|
||||
return traceData, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
type traceStore struct {
|
||||
@@ -20,28 +20,28 @@ func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
|
||||
return &traceStore{telemetryStore: ts}
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*tracedetailtypes.TraceSummary, error) {
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS start", "max(end) AS end", "sum(num_spans) AS num_spans")
|
||||
sb.From(fmt.Sprintf("%s.%s", tracedetailtypes.TraceDB, tracedetailtypes.TraceSummaryTable))
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceSummaryTable))
|
||||
sb.Where(sb.E("trace_id", traceID))
|
||||
sb.GroupBy("trace_id")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var summary tracedetailtypes.TraceSummary
|
||||
var summary spantypes.TraceSummary
|
||||
err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&summary.TraceID, &summary.Start, &summary.End, &summary.NumSpans,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, tracedetailtypes.ErrTraceNotFound
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace summary")
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *tracedetailtypes.TraceSummary) ([]tracedetailtypes.StorableSpan, error) {
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT ON (span_id)
|
||||
@@ -55,9 +55,9 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
FROM %s.%s
|
||||
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
|
||||
ORDER BY timestamp ASC, name ASC`,
|
||||
tracedetailtypes.TraceDB, tracedetailtypes.TraceTable,
|
||||
spantypes.TraceDB, spantypes.TraceTable,
|
||||
)
|
||||
var spanItems []tracedetailtypes.StorableSpan
|
||||
var spanItems []spantypes.StorableSpan
|
||||
err := s.telemetryStore.ClickhouseDB().Select(
|
||||
ctx, &spanItems, query,
|
||||
traceID,
|
||||
|
||||
@@ -37,7 +37,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -45,8 +45,8 @@ import (
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func mkSpan(id, service string, children ...*tracedetailtypes.WaterfallSpan) *tracedetailtypes.WaterfallSpan {
|
||||
return &tracedetailtypes.WaterfallSpan{
|
||||
func mkSpan(id, service string, children ...*spantypes.WaterfallSpan) *spantypes.WaterfallSpan {
|
||||
return &spantypes.WaterfallSpan{
|
||||
SpanID: id,
|
||||
ServiceName: service,
|
||||
Name: id + "-op",
|
||||
@@ -54,7 +54,7 @@ func mkSpan(id, service string, children ...*tracedetailtypes.WaterfallSpan) *tr
|
||||
}
|
||||
}
|
||||
|
||||
func spanIDs(spans []*tracedetailtypes.WaterfallSpan) []string {
|
||||
func spanIDs(spans []*spantypes.WaterfallSpan) []string {
|
||||
ids := make([]string, len(spans))
|
||||
for i, s := range spans {
|
||||
ids[i] = s.SpanID
|
||||
@@ -62,10 +62,10 @@ func spanIDs(spans []*tracedetailtypes.WaterfallSpan) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func buildSpanMap(roots ...*tracedetailtypes.WaterfallSpan) map[string]*tracedetailtypes.WaterfallSpan {
|
||||
m := map[string]*tracedetailtypes.WaterfallSpan{}
|
||||
var walk func(*tracedetailtypes.WaterfallSpan)
|
||||
walk = func(s *tracedetailtypes.WaterfallSpan) {
|
||||
func buildSpanMap(roots ...*spantypes.WaterfallSpan) map[string]*spantypes.WaterfallSpan {
|
||||
m := map[string]*spantypes.WaterfallSpan{}
|
||||
var walk func(*spantypes.WaterfallSpan)
|
||||
walk = func(s *spantypes.WaterfallSpan) {
|
||||
m[s.SpanID] = s
|
||||
for _, c := range s.Children {
|
||||
walk(c)
|
||||
@@ -80,8 +80,8 @@ func buildSpanMap(roots ...*tracedetailtypes.WaterfallSpan) map[string]*tracedet
|
||||
}
|
||||
|
||||
// makeChain builds a linear trace: span0 → span1 → … → span(n-1).
|
||||
func makeChain(n int) (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan, []string) {
|
||||
spans := make([]*tracedetailtypes.WaterfallSpan, n)
|
||||
func makeChain(n int) (*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan, []string) {
|
||||
spans := make([]*spantypes.WaterfallSpan, n)
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if i == n-1 {
|
||||
spans[i] = mkSpan(fmt.Sprintf("span%d", i), "svc")
|
||||
@@ -96,8 +96,8 @@ func makeChain(n int) (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailt
|
||||
return spans[0], buildSpanMap(spans[0]), uncollapsed
|
||||
}
|
||||
|
||||
func getWaterfallTrace(roots []*tracedetailtypes.WaterfallSpan, spanMap map[string]*tracedetailtypes.WaterfallSpan) *tracedetailtypes.WaterfallTrace {
|
||||
return tracedetailtypes.NewWaterfallTrace(0, 0, uint64(len(spanMap)), 0, spanMap, nil, roots, false)
|
||||
func getWaterfallTrace(roots []*spantypes.WaterfallSpan, spanMap map[string]*spantypes.WaterfallSpan) *spantypes.WaterfallTrace {
|
||||
return spantypes.NewWaterfallTrace(0, 0, uint64(len(spanMap)), 0, spanMap, nil, roots, false)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -107,7 +107,7 @@ func getWaterfallTrace(roots []*tracedetailtypes.WaterfallSpan, spanMap map[stri
|
||||
func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buildRoots func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan)
|
||||
buildRoots func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan)
|
||||
uncollapsedSpans []string
|
||||
selectedSpanID string
|
||||
wantSpanIDs []string
|
||||
@@ -115,12 +115,12 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
{
|
||||
// Pre-order traversal is preserved: parent before children, siblings left-to-right.
|
||||
name: "pre_order_traversal",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoots: func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
|
||||
mkSpan("child2", "svc"),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
return []*spantypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"root", "child1"},
|
||||
selectedSpanID: "root",
|
||||
@@ -133,12 +133,12 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
// ├─ childA (uncollapsed) → grandchildA ✓
|
||||
// └─ childB (uncollapsed) → grandchildB ✓
|
||||
name: "multiple_uncollapsed",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoots: func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("childA", "svc", mkSpan("grandchildA", "svc")),
|
||||
mkSpan("childB", "svc", mkSpan("grandchildB", "svc")),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
return []*spantypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"root", "childA", "childB"},
|
||||
selectedSpanID: "root",
|
||||
@@ -154,7 +154,7 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
// │ └─ grandchild2 ✓
|
||||
// └─ childB ← selected (not expanded)
|
||||
name: "manual_uncollapse",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoots: func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("childA", "svc",
|
||||
mkSpan("grandchild1", "svc", mkSpan("greatGrandchild", "svc")),
|
||||
@@ -162,7 +162,7 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
),
|
||||
mkSpan("childB", "svc"),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
return []*spantypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{"childA"},
|
||||
selectedSpanID: "childB",
|
||||
@@ -171,12 +171,12 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
{
|
||||
// A collapsed span hides all children.
|
||||
name: "collapsed_span_hides_children",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoots: func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("child1", "svc"),
|
||||
mkSpan("child2", "svc"),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
return []*spantypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "root",
|
||||
@@ -187,13 +187,13 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
//
|
||||
// root → parent → selected
|
||||
name: "path_to_selected_is_uncollapsed",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoots: func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("parent", "svc",
|
||||
mkSpan("selected", "svc"),
|
||||
),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
return []*spantypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "selected",
|
||||
@@ -206,14 +206,14 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
// ├─ unrelated → unrelated-child (✗)
|
||||
// └─ parent → selected
|
||||
name: "siblings_not_expanded",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoots: func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("unrelated", "svc", mkSpan("unrelated-child", "svc")),
|
||||
mkSpan("parent", "svc",
|
||||
mkSpan("selected", "svc"),
|
||||
),
|
||||
)
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
return []*spantypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "selected",
|
||||
@@ -223,9 +223,9 @@ func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
|
||||
{
|
||||
// An unknown selectedSpanID must not panic; returns a window from index 0.
|
||||
name: "unknown_selected_span",
|
||||
buildRoots: func() ([]*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoots: func() ([]*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc", mkSpan("child", "svc"))
|
||||
return []*tracedetailtypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
return []*spantypes.WaterfallSpan{root}, buildSpanMap(root)
|
||||
},
|
||||
uncollapsedSpans: []string{},
|
||||
selectedSpanID: "nonexistent",
|
||||
@@ -257,10 +257,10 @@ func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
|
||||
root2 := mkSpan("root2", "svc-b", mkSpan("child2", "svc-b"))
|
||||
spanMap := buildSpanMap(root1, root2)
|
||||
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root1, root2}, spanMap)
|
||||
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root1, root2}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans([]string{"root1", "root2"}, "root1", 500, 5)
|
||||
|
||||
traceRespnose := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, false, nil)
|
||||
traceRespnose := spantypes.NewGettableWaterfallTrace(trace, spans, nil, false, nil)
|
||||
|
||||
assert.Equal(t, []string{"root1", "child1", "root2", "child2"}, spanIDs(spans), "root1 subtree must precede root2 subtree")
|
||||
assert.Equal(t, "svc-a", traceRespnose.RootServiceName, "metadata comes from first root")
|
||||
@@ -274,7 +274,7 @@ func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
|
||||
func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buildRoot func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan)
|
||||
buildRoot func() (*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan)
|
||||
uncollapsedSpans []string
|
||||
selectedSpanID string
|
||||
wantSpanIDs []string
|
||||
@@ -283,7 +283,7 @@ func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
|
||||
{
|
||||
// The path-to-selected spans are returned in updatedUncollapsedSpans.
|
||||
name: "path_returned_in_uncollapsed",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoot: func() (*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("parent", "svc",
|
||||
mkSpan("selected", "svc"),
|
||||
@@ -301,7 +301,7 @@ func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
|
||||
{
|
||||
// Siblings of ancestors are not tracked as uncollapsed.
|
||||
name: "siblings_not_in_uncollapsed",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoot: func() (*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("unrelated", "svc", mkSpan("unrelated-child", "svc")),
|
||||
mkSpan("parent", "svc",
|
||||
@@ -330,7 +330,7 @@ func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
|
||||
// └─ grandchildB (internal ✓)
|
||||
// └─ leafB (leaf ✗)
|
||||
name: "auto_expanded_spans_returned",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoot: func() (*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("childA", "svc",
|
||||
mkSpan("grandchildA", "svc",
|
||||
@@ -361,7 +361,7 @@ func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
|
||||
// If the selected span is already in uncollapsedSpans,
|
||||
// it should appear exactly once in the result.
|
||||
name: "duplicate_in_uncollapsed",
|
||||
buildRoot: func() (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailtypes.WaterfallSpan) {
|
||||
buildRoot: func() (*spantypes.WaterfallSpan, map[string]*spantypes.WaterfallSpan) {
|
||||
root := mkSpan("root", "svc",
|
||||
mkSpan("selected", "svc", mkSpan("child", "svc")),
|
||||
)
|
||||
@@ -384,7 +384,7 @@ func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root, spanMap := tc.buildRoot()
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root}, spanMap)
|
||||
spans, uncollapsed := trace.GetSelectedSpans(tc.uncollapsedSpans, tc.selectedSpanID, 500, 5)
|
||||
if tc.wantSpanIDs != nil {
|
||||
assert.Equal(t, tc.wantSpanIDs, spanIDs(spans))
|
||||
@@ -412,10 +412,10 @@ func TestGetSelectedSpans_SpanMetadata(t *testing.T) {
|
||||
mkSpan("child2", "svc"),
|
||||
)
|
||||
spanMap := buildSpanMap(root)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans([]string{"root", "child1"}, "root", 500, 5)
|
||||
|
||||
byID := map[string]*tracedetailtypes.WaterfallSpan{}
|
||||
byID := map[string]*spantypes.WaterfallSpan{}
|
||||
for _, s := range spans {
|
||||
byID[s.SpanID] = s
|
||||
}
|
||||
@@ -478,7 +478,7 @@ func TestGetSelectedSpans_Window(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root, spanMap, uncollapsed := makeChain(600)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans(uncollapsed, tc.selectedSpanID, 500, 5)
|
||||
|
||||
assert.Equal(t, tc.wantLen, len(spans), "window size")
|
||||
@@ -536,7 +536,7 @@ func TestGetSelectedSpans_DepthCountedFromSelectedSpan(t *testing.T) {
|
||||
root := mkSpan("root", "svc", mkSpan("A", "svc", selected))
|
||||
|
||||
spanMap := buildSpanMap(root)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, spanMap)
|
||||
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root}, spanMap)
|
||||
spans, _ := trace.GetSelectedSpans([]string{"selected"}, "selected", 500, 5)
|
||||
ids := spanIDs(spans)
|
||||
|
||||
@@ -565,9 +565,9 @@ func TestGetAllSpans(t *testing.T) {
|
||||
),
|
||||
),
|
||||
)
|
||||
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, nil)
|
||||
trace := getWaterfallTrace([]*spantypes.WaterfallSpan{root}, nil)
|
||||
spans := trace.GetAllSpans()
|
||||
traceResponse := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, true, nil)
|
||||
traceResponse := spantypes.NewGettableWaterfallTrace(trace, spans, nil, true, nil)
|
||||
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
|
||||
assert.Equal(t, "svc", traceResponse.RootServiceName)
|
||||
assert.Equal(t, "root-op", traceResponse.RootServiceEntryPoint)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
@@ -14,5 +14,5 @@ type Handler interface {
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetWaterfall(ctx context.Context, traceID string, req *tracedetailtypes.PostableWaterfall) (*tracedetailtypes.GettableWaterfallTrace, error)
|
||||
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package tracedetailtypes
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
@@ -1,4 +1,4 @@
|
||||
package tracedetailtypes
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -13,7 +13,9 @@ var (
|
||||
ErrCodeMappingGroupAlreadyExists = errors.MustNewCode("span_attribute_mapping_group_already_exists")
|
||||
)
|
||||
|
||||
// A group runs when any of the listed attribute/resource key patterns match.
|
||||
// SpanMapperGroupCondition gates whether a group's rules run for a given span.
|
||||
// A group runs when any attribute or resource key on the span CONTAINS one of
|
||||
// the listed substrings (plain substring match — no glob syntax).
|
||||
type SpanMapperGroupCondition struct {
|
||||
Attributes []string `json:"attributes" required:"true" nullable:"true"`
|
||||
Resource []string `json:"resource" required:"true" nullable:"true"`
|
||||
|
||||
140
pkg/types/spantypes/spanmapperprocessor.go
Normal file
140
pkg/types/spantypes/spanmapperprocessor.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
SpanAttrMappingFeatureType agentConf.AgentFeatureType = "span_attr_mapping"
|
||||
|
||||
ProcessorName = "signozspanmapper"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeInvalidCollectorConfig = errors.MustNewCode("invalid_collector_config")
|
||||
ErrCodeBuildMappingProcessorConfig = errors.MustNewCode("build_mapping_processor_config")
|
||||
)
|
||||
|
||||
type SpanMapperGroupWithMappers struct {
|
||||
Group *SpanMapperGroup `json:"group"`
|
||||
Mappers []*SpanMapper `json:"mappers"`
|
||||
}
|
||||
|
||||
// spanMapperProcessorConfig is the collector config for signozspanmapper.
|
||||
type spanMapperProcessorConfig struct {
|
||||
Groups []spanMapperProcessorGroup `yaml:"groups" json:"groups"`
|
||||
}
|
||||
|
||||
type spanMapperProcessorGroup struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
ExistsAny spanMapperProcessorExistsAny `yaml:"exists_any" json:"exists_any"`
|
||||
Attributes []spanMapperProcessorAttribute `yaml:"attributes" json:"attributes"`
|
||||
}
|
||||
|
||||
type spanMapperProcessorExistsAny struct {
|
||||
Attributes []string `yaml:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
Resource []string `yaml:"resource,omitempty" json:"resource,omitempty"`
|
||||
}
|
||||
|
||||
type spanMapperProcessorAttribute struct {
|
||||
Target string `yaml:"target" json:"target"`
|
||||
Context string `yaml:"context,omitempty" json:"context,omitempty"`
|
||||
Sources []spanMapperProcessorSource `yaml:"sources" json:"sources"`
|
||||
}
|
||||
|
||||
type spanMapperProcessorSource struct {
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Action string `yaml:"action,omitempty" json:"action,omitempty"`
|
||||
}
|
||||
|
||||
func GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml []byte, groups []*SpanMapperGroupWithMappers) ([]byte, error) {
|
||||
var collectorConf map[string]any
|
||||
if err := yaml.Unmarshal(currentConfYaml, &collectorConf); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config")
|
||||
}
|
||||
// rare but don't do anything in this case, also means it's just comments.
|
||||
if collectorConf == nil {
|
||||
collectorConf = map[string]any{}
|
||||
}
|
||||
|
||||
processors := map[string]any{}
|
||||
if existing, ok := collectorConf["processors"]; ok && existing != nil {
|
||||
p, ok := existing.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "collector config 'processors' must be a mapping, got %T", existing)
|
||||
}
|
||||
processors = p
|
||||
}
|
||||
|
||||
procConfig := buildProcessorConfig(groups)
|
||||
|
||||
processors[ProcessorName] = procConfig
|
||||
collectorConf["processors"] = processors
|
||||
|
||||
out, err := yaml.Marshal(collectorConf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal collector config")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProcessorConfig {
|
||||
out := make([]spanMapperProcessorGroup, 0, len(groups))
|
||||
|
||||
for _, gm := range groups {
|
||||
rules := make([]spanMapperProcessorAttribute, 0, len(gm.Mappers))
|
||||
for _, m := range gm.Mappers {
|
||||
rules = append(rules, buildAttributeRule(m))
|
||||
}
|
||||
|
||||
out = append(out, spanMapperProcessorGroup{
|
||||
ID: gm.Group.Name,
|
||||
ExistsAny: spanMapperProcessorExistsAny{
|
||||
Attributes: gm.Group.Condition.Attributes,
|
||||
Resource: gm.Group.Condition.Resource,
|
||||
},
|
||||
Attributes: rules,
|
||||
})
|
||||
}
|
||||
|
||||
return &spanMapperProcessorConfig{Groups: out}
|
||||
}
|
||||
|
||||
// buildAttributeRule maps a single SpanMapper to a collector attribute rule.
|
||||
// Sources are sorted by Priority DESC (highest-priority first); read-from-
|
||||
// resource sources are encoded via the "resource." prefix on the key. Each
|
||||
// source carries its own action — "copy" is omitted to keep the emitted YAML
|
||||
// compact, and only "move" is set explicitly.
|
||||
func buildAttributeRule(m *SpanMapper) spanMapperProcessorAttribute {
|
||||
sources := make([]SpanMapperSource, len(m.Config.Sources))
|
||||
copy(sources, m.Config.Sources)
|
||||
sort.SliceStable(sources, func(i, j int) bool { return sources[i].Priority > sources[j].Priority })
|
||||
|
||||
out := make([]spanMapperProcessorSource, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
key := s.Key
|
||||
if s.Context == FieldContextResource {
|
||||
key = FieldContextResource.StringValue() + "." + s.Key
|
||||
}
|
||||
var action string
|
||||
if s.Operation == SpanMapperOperationMove {
|
||||
action = SpanMapperOperationMove.StringValue()
|
||||
}
|
||||
out = append(out, spanMapperProcessorSource{Key: key, Action: action})
|
||||
}
|
||||
|
||||
ctx := FieldContextSpanAttribute
|
||||
if m.FieldContext == FieldContextResource {
|
||||
ctx = FieldContextResource
|
||||
}
|
||||
|
||||
return spanMapperProcessorAttribute{
|
||||
Target: m.Name,
|
||||
Context: ctx.StringValue(),
|
||||
Sources: out,
|
||||
}
|
||||
}
|
||||
198
pkg/types/spantypes/spanmapperprocessor_test.go
Normal file
198
pkg/types/spantypes/spanmapperprocessor_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestGenerateCollectorConfigWithSpanMapperProcessor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseline := loadFixture(t, "collector_baseline.yaml")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
groups []*SpanMapperGroupWithMappers
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no_groups",
|
||||
want: "collector_no_groups.yaml",
|
||||
},
|
||||
{
|
||||
name: "with_groups",
|
||||
groups: []*SpanMapperGroupWithMappers{
|
||||
{
|
||||
Group: newGroup("llm", []string{"model"}, []string{"service.name"}),
|
||||
Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.request.model", FieldContextResource,
|
||||
attrSrc("gen_ai.llm.model", SpanMapperOperationCopy, 3),
|
||||
attrSrc("llm.model", SpanMapperOperationCopy, 2),
|
||||
resSrc("service.name", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
newMapper("gen_ai.request.tokens", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.request_tokens", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.tokens", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
newMapper("gen_ai.request.input", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.input", SpanMapperOperationMove, 2),
|
||||
attrSrc("llm.input", SpanMapperOperationMove, 1),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
Group: newGroup("agent", []string{"agent."}, nil),
|
||||
Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.agent.name", FieldContextSpanAttribute,
|
||||
attrSrc("agent.name", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.agent.name", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
newMapper("gen_ai.agent.id", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.agent.id", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.agent.id", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
Group: newGroup("tool", []string{"agent."}, nil),
|
||||
Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.tool.name", FieldContextSpanAttribute,
|
||||
attrSrc("ai.tool.name", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.tool.name", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "collector_with_groups.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := GenerateCollectorConfigWithSpanMapperProcessor(baseline, tc.groups)
|
||||
require.NoError(t, err)
|
||||
assertYAMLEqual(t, loadFixture(t, tc.want), got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfigWithSpanMapperProcessor_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []byte
|
||||
}{
|
||||
{"processors_not_a_map", []byte("processors: not-a-map\n")},
|
||||
{"malformed_yaml", []byte(": :")},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := GenerateCollectorConfigWithSpanMapperProcessor(tc.in, nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "want TypeInvalidInput, got %v", err)
|
||||
assert.True(t, errors.Asc(err, ErrCodeInvalidCollectorConfig), "want ErrCodeInvalidCollectorConfig, got %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAttributeRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mapper *SpanMapper
|
||||
want spanMapperProcessorAttribute
|
||||
}{
|
||||
{
|
||||
name: "priority_sort_and_resource_prefix",
|
||||
mapper: newMapper("gen_ai.request.model", FieldContextResource,
|
||||
attrSrc("llm.model", SpanMapperOperationCopy, 20),
|
||||
resSrc("service.name", SpanMapperOperationCopy, 10),
|
||||
attrSrc("gen_ai.llm.model", SpanMapperOperationCopy, 30),
|
||||
),
|
||||
want: spanMapperProcessorAttribute{
|
||||
Target: "gen_ai.request.model",
|
||||
Context: FieldContextResource.StringValue(),
|
||||
Sources: []spanMapperProcessorSource{
|
||||
{Key: "gen_ai.llm.model"},
|
||||
{Key: "llm.model"},
|
||||
{Key: "resource.service.name"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "per_source_actions",
|
||||
mapper: newMapper("gen_ai.request.input", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.input", SpanMapperOperationMove, 20),
|
||||
attrSrc("llm.input", SpanMapperOperationCopy, 10),
|
||||
),
|
||||
want: spanMapperProcessorAttribute{
|
||||
Target: "gen_ai.request.input",
|
||||
Context: FieldContextSpanAttribute.StringValue(),
|
||||
Sources: []spanMapperProcessorSource{
|
||||
{Key: "gen_ai.input", Action: SpanMapperOperationMove.StringValue()},
|
||||
{Key: "llm.input"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, buildAttributeRule(tc.mapper))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
// assertYAMLEqual compares two YAML documents structurally so key order and
|
||||
// slice formatting do not matter.
|
||||
func assertYAMLEqual(t *testing.T, want, got []byte) {
|
||||
t.Helper()
|
||||
var w, g any
|
||||
require.NoError(t, yaml.Unmarshal(want, &w))
|
||||
require.NoError(t, yaml.Unmarshal(got, &g))
|
||||
assert.Equal(t, w, g)
|
||||
}
|
||||
|
||||
func newGroup(name string, attrs, res []string) *SpanMapperGroup {
|
||||
return &SpanMapperGroup{
|
||||
Name: name,
|
||||
Condition: SpanMapperGroupCondition{Attributes: attrs, Resource: res},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *SpanMapper {
|
||||
return &SpanMapper{
|
||||
Name: name,
|
||||
FieldContext: target,
|
||||
Config: SpanMapperConfig{Sources: sources},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func attrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
|
||||
}
|
||||
|
||||
func resSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
|
||||
}
|
||||
@@ -21,3 +21,9 @@ type SpanMapperStore interface {
|
||||
UpdateMapper(ctx context.Context, mapper *SpanMapper) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
// TraceStore defines the data access interface for trace detail queries.
|
||||
type TraceStore interface {
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
}
|
||||
|
||||
17
pkg/types/spantypes/testdata/collector_baseline.yaml
vendored
Normal file
17
pkg/types/spantypes/testdata/collector_baseline.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozspanmapper:
|
||||
groups: []
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmapper, batch]
|
||||
exporters: [otlp]
|
||||
17
pkg/types/spantypes/testdata/collector_no_groups.yaml
vendored
Normal file
17
pkg/types/spantypes/testdata/collector_no_groups.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozspanmapper:
|
||||
groups: []
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmapper, batch]
|
||||
exporters: [otlp]
|
||||
67
pkg/types/spantypes/testdata/collector_with_groups.yaml
vendored
Normal file
67
pkg/types/spantypes/testdata/collector_with_groups.yaml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozspanmapper:
|
||||
groups:
|
||||
- id: llm
|
||||
exists_any:
|
||||
attributes:
|
||||
- model
|
||||
resource:
|
||||
- service.name
|
||||
attributes:
|
||||
- target: gen_ai.request.model
|
||||
context: resource
|
||||
sources:
|
||||
- key: gen_ai.llm.model
|
||||
- key: llm.model
|
||||
- key: resource.service.name
|
||||
- target: gen_ai.request.tokens
|
||||
context: attribute
|
||||
sources:
|
||||
- key: gen_ai.request_tokens
|
||||
- key: llm.tokens
|
||||
- target: gen_ai.request.input
|
||||
context: attribute
|
||||
sources:
|
||||
- key: gen_ai.input
|
||||
action: move
|
||||
- key: llm.input
|
||||
action: move
|
||||
- id: agent
|
||||
exists_any:
|
||||
attributes:
|
||||
- agent.
|
||||
attributes:
|
||||
- target: gen_ai.agent.name
|
||||
context: attribute
|
||||
sources:
|
||||
- key: agent.name
|
||||
- key: llm.agent.name
|
||||
- target: gen_ai.agent.id
|
||||
context: attribute
|
||||
sources:
|
||||
- key: gen_ai.agent.id
|
||||
- key: llm.agent.id
|
||||
- id: tool
|
||||
exists_any:
|
||||
attributes:
|
||||
- agent.
|
||||
attributes:
|
||||
- target: gen_ai.tool.name
|
||||
context: attribute
|
||||
sources:
|
||||
- key: ai.tool.name
|
||||
- key: llm.tool.name
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmapper, batch]
|
||||
exporters: [otlp]
|
||||
@@ -1,4 +1,4 @@
|
||||
package tracedetailtypes
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package tracedetailtypes
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,9 +0,0 @@
|
||||
package tracedetailtypes
|
||||
|
||||
import "context"
|
||||
|
||||
// TraceStore defines the data access interface for trace detail queries.
|
||||
type TraceStore interface {
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
}
|
||||
@@ -14,6 +14,24 @@ type Config struct {
|
||||
|
||||
// The directory from which to serve the web files.
|
||||
Directory string `mapstructure:"directory"`
|
||||
|
||||
// Settings that are exposed to the web.
|
||||
Settings Settings `mapstructure:"settings"`
|
||||
}
|
||||
|
||||
// Settings that are exposed to the web.
|
||||
type Settings struct {
|
||||
Posthog Posthog `mapstructure:"posthog"`
|
||||
|
||||
Appcues Appcues `mapstructure:"appcues"`
|
||||
}
|
||||
|
||||
type Posthog struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type Appcues struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
@@ -25,6 +43,14 @@ func newConfig() factory.Config {
|
||||
Enabled: true,
|
||||
Index: "index.html",
|
||||
Directory: "/etc/signoz/web",
|
||||
Settings: Settings{
|
||||
Posthog: Posthog{
|
||||
Enabled: true,
|
||||
},
|
||||
Appcues: Appcues{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ func TestNewWithEnvProvider(t *testing.T) {
|
||||
Enabled: false,
|
||||
Index: def.Index,
|
||||
Directory: def.Directory,
|
||||
Settings: def.Settings,
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, actual)
|
||||
|
||||
@@ -2,6 +2,8 @@ package routerweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,8 +44,16 @@ func New(ctx context.Context, settings factory.ProviderSettings, config web.Conf
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot read %q in web directory", config.Index)
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(config.Settings)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "cannot marshal web settings to JSON")
|
||||
}
|
||||
|
||||
logger := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/web/routerweb").Logger()
|
||||
indexContents := web.NewIndex(ctx, logger, config.Index, raw, web.TemplateData{BaseHref: globalConfig.ExternalPathTrailing()})
|
||||
indexContents := web.NewIndex(ctx, logger, config.Index, raw, web.TemplateData{
|
||||
BaseHref: globalConfig.ExternalPathTrailing(),
|
||||
Settings: template.JS(settingsJSON),
|
||||
})
|
||||
|
||||
return &provider{
|
||||
config: config,
|
||||
|
||||
@@ -2,6 +2,7 @@ package routerweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -19,6 +20,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func expectedHTML(baseHref string, settings web.Settings) string {
|
||||
settingsJSON, _ := json.Marshal(settings)
|
||||
return `<html><head><base href="` + baseHref + `" /></head><body><script>window.signozBootData={settings:` + string(settingsJSON) + `}</script>Welcome to test data!!!</body></html>`
|
||||
}
|
||||
|
||||
func startServer(t *testing.T, config web.Config, globalConfig global.Config) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -54,53 +60,79 @@ func httpGet(t *testing.T, url string) string {
|
||||
func TestServeTemplatedIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
emptySettings := web.Settings{}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
globalConfig global.Config
|
||||
webConfig web.Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "RootBaseHrefAtRoot",
|
||||
path: "/",
|
||||
globalConfig: global.Config{},
|
||||
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
webConfig: web.Config{Index: "valid_template.html", Directory: "testdata"},
|
||||
expected: expectedHTML("/", emptySettings),
|
||||
},
|
||||
{
|
||||
name: "RootBaseHrefAtNonExistentPath",
|
||||
path: "/does-not-exist",
|
||||
globalConfig: global.Config{},
|
||||
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
webConfig: web.Config{Index: "valid_template.html", Directory: "testdata"},
|
||||
expected: expectedHTML("/", emptySettings),
|
||||
},
|
||||
{
|
||||
name: "RootBaseHrefAtDirectory",
|
||||
path: "/assets",
|
||||
globalConfig: global.Config{},
|
||||
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
webConfig: web.Config{Index: "valid_template.html", Directory: "testdata"},
|
||||
expected: expectedHTML("/", emptySettings),
|
||||
},
|
||||
{
|
||||
name: "SubPathBaseHrefAtRoot",
|
||||
path: "/",
|
||||
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
|
||||
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
webConfig: web.Config{Index: "valid_template.html", Directory: "testdata"},
|
||||
expected: expectedHTML("/signoz/", emptySettings),
|
||||
},
|
||||
{
|
||||
name: "SubPathBaseHrefAtNonExistentPath",
|
||||
path: "/does-not-exist",
|
||||
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
|
||||
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
webConfig: web.Config{Index: "valid_template.html", Directory: "testdata"},
|
||||
expected: expectedHTML("/signoz/", emptySettings),
|
||||
},
|
||||
{
|
||||
name: "SubPathBaseHrefAtDirectory",
|
||||
path: "/assets",
|
||||
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
|
||||
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
|
||||
webConfig: web.Config{Index: "valid_template.html", Directory: "testdata"},
|
||||
expected: expectedHTML("/signoz/", emptySettings),
|
||||
},
|
||||
{
|
||||
name: "WithPopulatedSettings",
|
||||
path: "/",
|
||||
globalConfig: global.Config{},
|
||||
webConfig: web.Config{
|
||||
Index: "valid_template.html",
|
||||
Directory: "testdata",
|
||||
Settings: web.Settings{
|
||||
Posthog: web.Posthog{Enabled: true},
|
||||
Appcues: web.Appcues{Enabled: true},
|
||||
},
|
||||
},
|
||||
expected: expectedHTML("/", web.Settings{
|
||||
Posthog: web.Posthog{Enabled: true},
|
||||
Appcues: web.Appcues{Enabled: true},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
base := startServer(t, web.Config{Index: "valid_template.html", Directory: "testdata"}, testCase.globalConfig)
|
||||
base := startServer(t, testCase.webConfig, testCase.globalConfig)
|
||||
|
||||
assert.Equal(t, testCase.expected, strings.TrimSuffix(httpGet(t, base+testCase.path), "\n"))
|
||||
})
|
||||
|
||||
@@ -1 +1 @@
|
||||
<html><head><base href="[[.BaseHref]]" /></head><body>Welcome to test data!!!</body></html>
|
||||
<html><head><base href="[[.BaseHref]]" /></head><body><script>window.signozBootData={settings:[[.Settings]]}</script>Welcome to test data!!!</body></html>
|
||||
|
||||
@@ -11,8 +11,14 @@ import (
|
||||
|
||||
// Field names map to the HTML attributes they populate in the template:
|
||||
// - BaseHref → <base href="[[.BaseHref]]" />
|
||||
// - Settings → window.signozBootData = { settings: [[.Settings]] }
|
||||
type TemplateData struct {
|
||||
BaseHref string
|
||||
|
||||
// Settings is the pre-serialized JSON of web.Settings for injection into a
|
||||
// <script> block. The template.JS type prevents html/template from
|
||||
// HTML-escaping the value.
|
||||
Settings template.JS
|
||||
}
|
||||
|
||||
// If the template cannot be parsed or executed, the raw bytes are
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
} from '@playwright/test';
|
||||
|
||||
import apmMetricsTemplate from '../testdata/apm-metrics.json';
|
||||
import queriesData from '../testdata/queries.json';
|
||||
|
||||
export type SignalType = 'metrics' | 'logs' | 'traces';
|
||||
export type QueriesData = typeof queriesData;
|
||||
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
|
||||
import variablesTemplate from '../testdata/variables-dashboard.json';
|
||||
|
||||
@@ -370,56 +366,6 @@ export async function findDashboardIdByTitle(
|
||||
return body.data.find((d) => d.data.title === title)?.id;
|
||||
}
|
||||
|
||||
/** Shape of a single persisted widget — only the fields these specs assert on. */
|
||||
export interface PersistedWidget {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
panelTypes?: string;
|
||||
timePreferance?: string;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: number;
|
||||
thresholds?: Array<{
|
||||
thresholdFormat?: string;
|
||||
thresholdOperator?: string;
|
||||
thresholdValue?: number;
|
||||
thresholdColor?: string;
|
||||
thresholdTableOptions?: string;
|
||||
}>;
|
||||
columnUnits?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Shape of the persisted dashboard payload returned by GET /api/v1/dashboards/<id>. */
|
||||
export interface DashboardData {
|
||||
title: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
widgets?: PersistedWidget[];
|
||||
variables?: Record<string, Record<string, unknown>>;
|
||||
layout?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the persisted dashboard payload via API. Use this for "did the save
|
||||
* actually land on the server?" assertions — UI-only checks can pass on
|
||||
* optimistic-update bugs.
|
||||
*/
|
||||
export async function fetchDashboardData(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<DashboardData> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(`/api/v1/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`GET /dashboards/${id} ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const body = (await res.json()) as { data: { data: DashboardData } };
|
||||
return body.data.data;
|
||||
}
|
||||
|
||||
// ─── List page UI helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -441,235 +387,3 @@ export async function openDashboardActionMenu(
|
||||
await icon.click();
|
||||
return page.getByRole('tooltip');
|
||||
}
|
||||
|
||||
// ─── Dashboard detail page helpers ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
|
||||
* detail page and wait for the settings drawer (`.settings-container-root`) to
|
||||
* be visible. Works from both the empty-state view and the populated toolbar —
|
||||
* both render the same testid.
|
||||
*
|
||||
* Returns the drawer locator so callers can scope further assertions to it.
|
||||
*/
|
||||
export async function openDashboardSettingsDrawer(page: Page): Promise<Locator> {
|
||||
await page.getByTestId('show-drawer').first().click();
|
||||
const drawer = page.locator('.settings-container-root');
|
||||
await drawer.waitFor({ state: 'visible' });
|
||||
return drawer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `data-testid="save-dashboard-config"` and wait for the resulting
|
||||
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
|
||||
* when there is at least one unsaved change — callers must ensure the drawer
|
||||
* has been dirtied before calling this.
|
||||
*/
|
||||
export async function saveDashboardSettings(page: Page): Promise<void> {
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('save-dashboard-config').click();
|
||||
await patchResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a dashboard via the toolbar options popover:
|
||||
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
|
||||
* input, clicks "Rename Dashboard", and waits for the PUT response.
|
||||
*
|
||||
* Pre-condition: the caller must be on the dashboard detail page.
|
||||
*/
|
||||
export async function renameDashboardViaToolbar(
|
||||
page: Page,
|
||||
newTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('button', { name: 'Rename' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await modal.waitFor({ state: 'visible' });
|
||||
|
||||
const input = modal.getByTestId('dashboard-name');
|
||||
await input.clear();
|
||||
await input.fill(newTitle);
|
||||
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
await patchResponse;
|
||||
|
||||
await modal.waitFor({ state: 'hidden' });
|
||||
}
|
||||
|
||||
// ─── Add panel flow ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* From the dashboard detail page (must already be loaded), drive the full
|
||||
* "Add Panel" flow for the given signal type:
|
||||
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
|
||||
* 2. Pick the Time Series panel type.
|
||||
* 3. Fill the panel name in the right pane (drives the post-save assertion).
|
||||
* 4. For metrics: type the metric name from `queries.json` into the metric
|
||||
* AutoComplete and select it from the dropdown. For logs/traces: switch
|
||||
* the data-source selector to LOGS / TRACES; default Query Builder state
|
||||
* is sufficient (queries.json query strings are empty by design).
|
||||
* 5. Click Save Changes and wait for the PUT /api/v1/dashboards/<id> response.
|
||||
*
|
||||
* Throws if the PUT response is not 2xx. After return, the page is back on
|
||||
* the dashboard detail page; the caller asserts the panel rendered.
|
||||
*/
|
||||
export async function configureAndSavePanel(
|
||||
page: Page,
|
||||
signal: SignalType,
|
||||
panelTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('add-panel').click();
|
||||
|
||||
const newPanelModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'New Panel' });
|
||||
await newPanelModal.waitFor({ state: 'visible' });
|
||||
await newPanelModal.getByTestId('panel-type-graph').click();
|
||||
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('panel-name-input').fill(panelTitle);
|
||||
|
||||
if (signal === 'metrics') {
|
||||
const metricName = queriesData.metrics.metricName;
|
||||
// The testid is on the Ant Select wrapper <div>; the editable input
|
||||
// lives inside it. Target the descendant input for fill().
|
||||
const metricInput = page.getByTestId('metric-name-selector-0').locator('input');
|
||||
await metricInput.click();
|
||||
await metricInput.fill(metricName);
|
||||
// AutoComplete debounces and fetches; wait for the option then click.
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: metricName })
|
||||
.first()
|
||||
.click();
|
||||
} else {
|
||||
// logs / traces — switch the data source. Default query is sufficient.
|
||||
await page.getByTestId('query-data-source-selector-0').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', {
|
||||
hasText: signal.toUpperCase(),
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
|
||||
const res = await putResponse;
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Save navigates back to /dashboard/<id> (no /new suffix).
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
}
|
||||
|
||||
// ─── Widget editor (re-open existing panel) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Display labels surfaced in the `panel-change-select` Ant Select inside the
|
||||
* widget editor. The mapping to URL `graphType` values comes from the
|
||||
* `PANEL_TYPES` enum: TIME_SERIES='graph', VALUE='value', and so on.
|
||||
*/
|
||||
export type PanelDisplayLabel =
|
||||
| 'Time Series'
|
||||
| 'Number'
|
||||
| 'Table'
|
||||
| 'List'
|
||||
| 'Bar'
|
||||
| 'Pie'
|
||||
| 'Histogram';
|
||||
|
||||
const PANEL_DISPLAY_TO_GRAPH_TYPE: Record<PanelDisplayLabel, string> = {
|
||||
'Time Series': 'graph',
|
||||
Number: 'value',
|
||||
Table: 'table',
|
||||
List: 'list',
|
||||
Bar: 'bar',
|
||||
Pie: 'pie',
|
||||
Histogram: 'histogram',
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the widget editor for an existing panel by driving the panel header
|
||||
* options menu (the three-dot Ant `Dropdown` next to the title).
|
||||
*
|
||||
* The widget-header-options button is `visibility: hidden` until the panel is
|
||||
* hovered (see `GridCardLayout.styles.scss`) — except on TABLE panels, where
|
||||
* `globalSearchAvailable` keeps it permanently visible. Hovering the title
|
||||
* testid first works for both states.
|
||||
*/
|
||||
export async function openWidgetEditor(
|
||||
page: Page,
|
||||
panelTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(panelTitle).first().hover();
|
||||
await page.getByTestId('widget-header-options').first().click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /^edit$/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(/widgetId=/);
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Save Changes" in the widget editor, await the dashboard PUT response,
|
||||
* and wait for navigation back to `/dashboard/<id>`. Throws if the PUT
|
||||
* response is not 2xx. NewWidget's save handler calls the mutation and
|
||||
* navigates on success — there is no confirmation modal in this flow.
|
||||
*/
|
||||
export async function saveWidgetEdit(page: Page): Promise<void> {
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
const res = await putResponse;
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the editor's panel display type via the Ant `Select` exposed as
|
||||
* `data-testid="panel-change-select"`. The select options carry the display
|
||||
* label as visible text (matches `PanelDisplay` enum values). After the
|
||||
* change, this helper waits for the URL `graphType` param to reflect the new
|
||||
* panel type and for the Save Changes button to re-render — the editor
|
||||
* re-routes mid-flow via `redirectWithQueryBuilderData`.
|
||||
*
|
||||
* Note: the "List" option is filtered out of the dropdown when the current
|
||||
* query contains a metrics data source (see VisualizationSettingsSection).
|
||||
*/
|
||||
export async function changePanelType(
|
||||
page: Page,
|
||||
displayLabel: PanelDisplayLabel,
|
||||
): Promise<void> {
|
||||
const expectedGraphType = PANEL_DISPLAY_TO_GRAPH_TYPE[displayLabel];
|
||||
await page.getByTestId('panel-change-select').click();
|
||||
// Each option renders a .select-option containing the display text — match
|
||||
// against the typography element to avoid matching the trigger itself.
|
||||
await page
|
||||
.locator('.ant-select-item-option .display', { hasText: displayLabel })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(new RegExp(`graphType=${expectedGraphType}`));
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
12
tests/e2e/testdata/queries.json
vendored
12
tests/e2e/testdata/queries.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"logs": {
|
||||
"query": ""
|
||||
},
|
||||
"metrics": {
|
||||
"metricName": "signoz_calls_total",
|
||||
"query": ""
|
||||
},
|
||||
"traces": {
|
||||
"query": ""
|
||||
}
|
||||
}
|
||||
@@ -1,779 +0,0 @@
|
||||
import path from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
gotoDashboardsList,
|
||||
openDashboardSettingsDrawer,
|
||||
renameDashboardViaToolbar,
|
||||
SEARCH_PLACEHOLDER,
|
||||
} from '../../helpers/dashboards';
|
||||
|
||||
// All tests mutate dashboard state (create / rename / delete). Run serially to
|
||||
// prevent cross-test interference on the list and detail pages.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// ─── Suite-level seed registry ────────────────────────────────────────────────
|
||||
//
|
||||
// Every dashboard created by any test is registered here; one afterAll tears
|
||||
// them all down. Tests that don't create anything (TC-10, TC-11, TC-13) need
|
||||
// no cleanup entry.
|
||||
const seedIds = new Set<string>();
|
||||
const BASE_FIXTURE_TITLE = 'create-flow-base-fixture';
|
||||
|
||||
const APM_METRICS_TESTDATA_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../testdata/apm-metrics.json',
|
||||
);
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Seed one base dashboard so the list is non-empty and the
|
||||
// `new-dashboard-cta` header button is rendered for all tests that
|
||||
// drive the "New dashboard" dropdown from the list page.
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Create Flow', () => {
|
||||
// ─── 1. Create Dashboard (blank) ─────────────────────────────────────────
|
||||
|
||||
test('TC-01 blank create lands on onboarding state with correct default title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
const postResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('create-dashboard-menu-cta').click();
|
||||
const res = await postResponse;
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
expect(res.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status()).toBeLessThan(300);
|
||||
|
||||
// Request contract: UI must POST the default title + uploadedGrafana=false.
|
||||
// Catches regressions where the menu CTA silently changes the create payload.
|
||||
const reqBody = res.request().postDataJSON() as {
|
||||
title?: string;
|
||||
uploadedGrafana?: boolean;
|
||||
};
|
||||
expect(reqBody.title).toBe('Sample Title');
|
||||
expect(reqBody.uploadedGrafana).toBe(false);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { data: { title: string }; id: string };
|
||||
};
|
||||
expect(body.data.data.title).toBe('Sample Title');
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
// DashboardDescription always renders dashboard-title even on blank dashboards.
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText('Sample Title');
|
||||
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
|
||||
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
|
||||
await expect(page.getByTestId('show-drawer').first()).toBeVisible();
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
// Register the UI-created dashboard for cleanup.
|
||||
const id = body.data.id;
|
||||
expect(id, 'POST response must include a dashboard id').toBeTruthy();
|
||||
seedIds.add(id);
|
||||
});
|
||||
|
||||
test('TC-02 configure drawer opens with Overview tab and pre-fills existing title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc02');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
|
||||
// Overview tab is the default active tab.
|
||||
await expect(drawer.getByRole('button', { name: 'Overview' })).toBeVisible();
|
||||
|
||||
const nameInput = drawer.getByTestId('dashboard-name');
|
||||
await expect(nameInput).toHaveValue('create-flow-tc02');
|
||||
|
||||
const descInput = drawer.getByTestId('dashboard-desc');
|
||||
await expect(descInput).toBeVisible();
|
||||
await expect(descInput).toHaveValue('');
|
||||
|
||||
await expect(
|
||||
drawer.getByPlaceholder('Start typing your tag name'),
|
||||
).toBeVisible();
|
||||
|
||||
// Ant Drawer does not close on Escape — use the X close button in the header.
|
||||
await drawer.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(drawer).not.toHaveClass(/ant-drawer-open/);
|
||||
});
|
||||
|
||||
test('TC-03 rename title, add description and tags, save persists to list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc03-original');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
|
||||
const nameInput = drawer.getByTestId('dashboard-name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('create-flow-tc03-renamed');
|
||||
await expect(drawer.getByText(/1 unsaved change/)).toBeVisible();
|
||||
|
||||
await drawer.getByTestId('dashboard-desc').fill('A test description');
|
||||
await expect(drawer.getByText(/2 unsaved changes/)).toBeVisible();
|
||||
|
||||
const tagInput = drawer.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.click();
|
||||
await tagInput.fill('e2e-tag');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(drawer.getByText(/3 unsaved changes/)).toBeVisible();
|
||||
|
||||
// Click save, capture the PUT, and verify it carried all three fields.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' &&
|
||||
new RegExp(`/api/v1/dashboards/${id}$`).test(r.url()),
|
||||
);
|
||||
await page.getByTestId('save-dashboard-config').click();
|
||||
const putRes = await putResponse;
|
||||
expect(putRes.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(putRes.status()).toBeLessThan(300);
|
||||
|
||||
// Server-side state must match what the user typed. UI-only checks pass
|
||||
// on optimistic-update bugs; this catches them.
|
||||
const persisted = await fetchDashboardData(page, id);
|
||||
expect(persisted.title).toBe('create-flow-tc03-renamed');
|
||||
expect(persisted.description).toBe('A test description');
|
||||
expect(persisted.tags ?? []).toContain('e2e-tag');
|
||||
|
||||
// Footer clears only after the PUT success callback re-syncs local state.
|
||||
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
|
||||
|
||||
await drawer.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Renamed dashboard appears in the list.
|
||||
await gotoDashboardsList(page);
|
||||
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
await searchInput.fill('create-flow-tc03-renamed');
|
||||
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
|
||||
|
||||
// Tag search also surfaces the renamed dashboard.
|
||||
await searchInput.fill('e2e-tag');
|
||||
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 discard reverts unsaved changes without API call', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc04');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
|
||||
const nameInput = drawer.getByTestId('dashboard-name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('create-flow-tc04-discarded');
|
||||
await drawer.getByTestId('dashboard-desc').fill('discarded desc');
|
||||
await expect(drawer.getByText(/unsaved change/)).toBeVisible();
|
||||
|
||||
// Intercept any PUT to detect an unwanted save.
|
||||
let patchFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
patchFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await drawer.getByRole('button', { name: 'Discard' }).click();
|
||||
|
||||
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
|
||||
await expect(nameInput).toHaveValue('create-flow-tc04');
|
||||
await expect(drawer.getByTestId('dashboard-desc')).toHaveValue('');
|
||||
|
||||
// Settle before asserting "no PUT fired" — a delayed save request that
|
||||
// races past the UI revert would otherwise sneak past the check.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(patchFired).toBe(false);
|
||||
});
|
||||
|
||||
test('TC-05 rename via toolbar options popover persists to the toolbar title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc05');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// DashboardDescription toolbar always renders — even on blank dashboards.
|
||||
await expect(page.getByTestId('options')).toBeVisible();
|
||||
|
||||
await renameDashboardViaToolbar(page, 'create-flow-tc05-renamed');
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText(
|
||||
'create-flow-tc05-renamed',
|
||||
);
|
||||
|
||||
// Server-side persistence — toolbar rename uses a separate PUT path from
|
||||
// the settings drawer; this catches an optimistic-update regression.
|
||||
const persisted = await fetchDashboardData(page, id);
|
||||
expect(persisted.title).toBe('create-flow-tc05-renamed');
|
||||
|
||||
// List view reflects the rename after navigating back.
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('create-flow-tc05-renamed');
|
||||
await expect(page.getByText('create-flow-tc05-renamed').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 2. Variables ─────────────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 add a Custom variable, verify it appears in the variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc06');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
await drawer.getByRole('button', { name: 'Variables' }).click();
|
||||
|
||||
await drawer.getByTestId('add-new-variable').click();
|
||||
await expect(drawer.getByRole('button', { name: 'All variables' })).toBeVisible();
|
||||
|
||||
await drawer
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill('env');
|
||||
|
||||
await drawer.getByRole('button', { name: 'Custom' }).click();
|
||||
|
||||
// After selecting "Custom" type, the Options collapse panel contains a
|
||||
// textarea with placeholder "Enter options separated by commas."
|
||||
const customInput = drawer.getByPlaceholder(
|
||||
'Enter options separated by commas.',
|
||||
);
|
||||
await customInput.fill('prod,staging,dev');
|
||||
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await drawer.getByRole('button', { name: 'Save Variable' }).click();
|
||||
const res = await patchResponse;
|
||||
expect(res.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status()).toBeLessThan(300);
|
||||
|
||||
// After saving, the variable form disappears and the table row is visible.
|
||||
await expect(drawer.getByRole('button', { name: 'All variables' })).not.toBeVisible();
|
||||
await expect(drawer.getByText('env')).toBeVisible();
|
||||
|
||||
// Server-side persistence — the variable record must land in the dashboard JSON.
|
||||
const persisted = await fetchDashboardData(page, id);
|
||||
const persistedVars = Object.values(persisted.variables ?? {}) as Array<{
|
||||
name?: string;
|
||||
customValue?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
const envVar = persistedVars.find((v) => v.name === 'env');
|
||||
expect(envVar, 'env variable must be persisted on the dashboard').toBeTruthy();
|
||||
expect(envVar?.customValue).toBe('prod,staging,dev');
|
||||
|
||||
// Close the drawer via its X button and check the variables bar renders the
|
||||
// variable label. `.dashboard-variables` always exists once any variable
|
||||
// is defined; assert it contains `$env` (the rendered prefix from
|
||||
// VariableItem) so an empty-bar regression is caught.
|
||||
await drawer.getByRole('button', { name: 'Close' }).click();
|
||||
const varsBar = page.locator('.dashboard-variables');
|
||||
await expect(varsBar).toBeVisible();
|
||||
await expect(varsBar).toContainText('$env');
|
||||
});
|
||||
|
||||
test('TC-07 duplicate variable name is rejected inline', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Seed a dashboard that already has a variable named 'env'.
|
||||
const id = await seed(page, 'create-flow-tc07');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// Use the UI to add the first variable so the state is real.
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
await drawer.getByRole('button', { name: 'Variables' }).click();
|
||||
await drawer.getByTestId('add-new-variable').click();
|
||||
await drawer.getByPlaceholder('Unique name of the variable').fill('env');
|
||||
await drawer.getByRole('button', { name: 'Custom' }).click();
|
||||
await drawer
|
||||
.getByPlaceholder('Enter options separated by commas.')
|
||||
.fill('prod');
|
||||
const firstSave = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await drawer.getByRole('button', { name: 'Save Variable' }).click();
|
||||
await firstSave;
|
||||
|
||||
// Now try to add a second variable with the same name.
|
||||
await drawer.getByTestId('add-new-variable').click();
|
||||
const nameInput = drawer.getByPlaceholder('Unique name of the variable');
|
||||
await nameInput.fill('env');
|
||||
|
||||
await expect(
|
||||
drawer.getByText('Variable name already exists'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
drawer.getByRole('button', { name: 'Save Variable' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
// ─── 3. Import JSON ───────────────────────────────────────────────────────
|
||||
//
|
||||
// TC-08 and TC-12 are merged: TC-08 covers the POST contract and navigation;
|
||||
// the merged test also navigates back to the list and verifies metadata
|
||||
// surfacing (the TC-12 concern). This avoids two identical import flows.
|
||||
|
||||
test('TC-08 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const postResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
|
||||
);
|
||||
await dialog.locator('input[type="file"]').setInputFiles(APM_METRICS_TESTDATA_PATH);
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
const res = await postResponse;
|
||||
|
||||
expect(res.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status()).toBeLessThan(300);
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
// Register for cleanup.
|
||||
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
|
||||
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
|
||||
seedIds.add(urlMatch![1]);
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText(APM_METRICS_TITLE);
|
||||
|
||||
// Server-side check: every widget + tag from the fixture must be persisted.
|
||||
// A partial import (e.g. silently dropped widgets) would pass the UI title
|
||||
// check but fail here. The apm-metrics fixture has 16 widgets and 4 tags.
|
||||
const persisted = await fetchDashboardData(page, urlMatch![1]);
|
||||
expect(persisted.widgets?.length).toBe(16);
|
||||
expect(persisted.tags).toEqual(
|
||||
expect.arrayContaining(['apm', 'latency', 'error rate', 'throughput']),
|
||||
);
|
||||
|
||||
// Navigate back and confirm the imported dashboard surfaces in the list
|
||||
// with at least one tag chip (TC-12 coverage).
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
|
||||
await expect(page.getByText('apm').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// TC-09 (Monaco paste path) is intentionally dropped — the file-upload
|
||||
// path (TC-08) exercises the same populate-editor-then-import code path.
|
||||
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
|
||||
|
||||
test('TC-10 invalid JSON via file upload shows "Invalid JSON" error', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// No dashboard is created by this test — no cleanup entry needed.
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Track POST attempts: invalid JSON must never reach the create endpoint.
|
||||
let postFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
postFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'bad.json',
|
||||
mimeType: 'application/json',
|
||||
buffer: Buffer.from('not valid json {'),
|
||||
});
|
||||
|
||||
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Clicking "Import and Next" with invalid content should surface an error
|
||||
// and keep the dialog open.
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(postFired, 'invalid JSON must not trigger POST').toBe(false);
|
||||
});
|
||||
|
||||
test('TC-11 import with empty editor clicking Import and Next shows error', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// No dashboard is created — no cleanup entry needed.
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
let postFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards(\?|$)/, (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
postFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
|
||||
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(postFired, 'empty editor must not trigger POST').toBe(false);
|
||||
});
|
||||
|
||||
// ─── 4. View Templates ────────────────────────────────────────────────────
|
||||
|
||||
test('TC-13 New Dashboard dropdown has the three expected entries, View templates is an external link', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// No dashboard is created — no cleanup entry needed.
|
||||
// The assertion guards against silent additions or reorderings to the
|
||||
// dropdown (adds, removals, label rename) AND the link being changed to
|
||||
// an in-app modal or a different URL (the DashboardTemplatesModal exists
|
||||
// in source but is never triggered from this menu item).
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
|
||||
// All three CTAs must render, with the expected labels.
|
||||
await expect(page.getByTestId('create-dashboard-menu-cta')).toHaveText(
|
||||
/Create dashboard/i,
|
||||
);
|
||||
await expect(page.getByTestId('import-json-menu-cta')).toHaveText(/Import JSON/i);
|
||||
|
||||
const link = page.getByTestId('view-templates-menu-cta');
|
||||
await expect(link).toHaveText(/View templates/i);
|
||||
|
||||
await expect(link).toHaveAttribute(
|
||||
'href',
|
||||
/signoz\.io\/docs\/dashboards\/dashboard-templates/,
|
||||
);
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||
});
|
||||
|
||||
// ─── 5. Post-Create Dashboard Detail — Panel Addition ────────────────────
|
||||
|
||||
test('TC-14 New Panel modal opens and selecting Time Series navigates to widget editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc14');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
|
||||
|
||||
await page.getByTestId('add-panel').click();
|
||||
// PANEL_TYPES enum: TIME_SERIES='graph', VALUE='value', TABLE='table'
|
||||
// — the testid is panel-type-<enum-value>, not panel-type-<enum-name>.
|
||||
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
|
||||
await expect(modal.getByTestId('panel-type-value')).toBeVisible();
|
||||
await expect(modal.getByTestId('panel-type-table')).toBeVisible();
|
||||
|
||||
await modal.getByTestId('panel-type-graph').click();
|
||||
await expect(page).toHaveURL(/graphType=graph/);
|
||||
|
||||
// Confirm the widget editor actually loaded — URL-only checks pass even
|
||||
// if the route resolves to a blank/broken page.
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-name-input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-15 New Panel button from toolbar header opens the same panel type modal', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc15');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// The toolbar "New Panel" button (add-panel-header) is present even on
|
||||
// a blank dashboard, alongside the empty-state "add-panel" button.
|
||||
await expect(page.getByTestId('add-panel-header')).toBeVisible();
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
|
||||
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
|
||||
|
||||
// Click the modal X button to close (Escape also works but may conflict
|
||||
// with the Enterprise modal in the background; explicit click is more reliable).
|
||||
await modal.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 6. Cancellation and Navigation Away ─────────────────────────────────
|
||||
|
||||
test('TC-16 browser Back from dashboard detail returns to list with URL preserved', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc16');
|
||||
|
||||
await page.goto(`/dashboard?search=create-flow-tc16`);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByAltText('dashboard-image').first().click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/search=create-flow-tc16/);
|
||||
await expect(
|
||||
page.getByPlaceholder(SEARCH_PLACEHOLDER),
|
||||
).toHaveValue('create-flow-tc16');
|
||||
});
|
||||
|
||||
test('TC-17 navigating away with the settings drawer open does not crash', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc17');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openDashboardSettingsDrawer(page);
|
||||
|
||||
// Navigate away without closing the drawer.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
// No error overlay should be present.
|
||||
await expect(
|
||||
page.getByRole('alert').filter({ hasText: /error/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── 7. Add Panel — end-to-end per signal ────────────────────────────────
|
||||
//
|
||||
// TC-14/TC-15 verify the New Panel modal opens and routes to the widget
|
||||
// editor. The TCs below go further: configure a query for each signal
|
||||
// using values from testdata/queries.json, save the panel, return to the
|
||||
// dashboard, and verify the panel card renders.
|
||||
|
||||
test('TC-18 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'add-panel-metrics');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
|
||||
|
||||
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
|
||||
|
||||
// Reload — proves the panel persists, not just optimistic UI from the save.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-19 add logs Time Series panel with default query from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'add-panel-logs');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
|
||||
|
||||
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-20 add traces Time Series panel with default query from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'add-panel-traces');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
|
||||
|
||||
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 8. Destructive CRUD ─────────────────────────────────────────────────
|
||||
|
||||
test('TC-21 delete dashboard via list action menu removes it from the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Seed with a unique title so the list filter resolves to exactly one row.
|
||||
const targetTitle = 'create-flow-tc21-to-delete';
|
||||
const id = await createDashboardViaApi(page, targetTitle);
|
||||
// Intentionally not registered in seedIds — this test deletes it via UI.
|
||||
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(targetTitle);
|
||||
await expect(page.getByText(targetTitle).first()).toBeVisible();
|
||||
|
||||
// Open the row action menu (tooltip with action buttons).
|
||||
const icon = page.getByTestId('dashboard-action-icon').first();
|
||||
await icon.scrollIntoViewIfNeeded();
|
||||
await icon.click();
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await tooltip.getByRole('button', { name: /Delete Dashboard/i }).click();
|
||||
|
||||
// Confirm modal: title contains the dashboard name + a danger "Delete" button.
|
||||
const confirmModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Are you sure you want to delete' });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await expect(confirmModal).toContainText(targetTitle);
|
||||
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'DELETE' &&
|
||||
new RegExp(`/api/v1/dashboards/${id}`).test(r.url()),
|
||||
);
|
||||
await confirmModal.getByRole('button', { name: /^Delete$/ }).click();
|
||||
const delRes = await deleteResponse;
|
||||
expect(delRes.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(delRes.status()).toBeLessThan(300);
|
||||
|
||||
// Row should disappear from the list. The search is still active, so the
|
||||
// list shows its empty-search state with a "No dashboards found for X"
|
||||
// message — assert that explicitly rather than the row's absence (the
|
||||
// title also lives in the search input value and the empty-state message).
|
||||
await expect(
|
||||
page.getByText(`No dashboards found for ${targetTitle}`),
|
||||
).toBeVisible();
|
||||
|
||||
// API confirms the row is gone — guards against an optimistic-update bug
|
||||
// where the UI hides the row without the backend actually deleting it.
|
||||
const token = await authToken(page);
|
||||
const verifyRes = await page.request.get(`/api/v1/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(verifyRes.status()).toBe(404);
|
||||
});
|
||||
|
||||
// ─── 9. Full Round-Trip ──────────────────────────────────────────────────
|
||||
//
|
||||
// Catches cross-feature regressions: a settings save that nukes variables,
|
||||
// a variable add that strips widgets, a panel save that overwrites tags, etc.
|
||||
// Stress-tests the dashboard PUT contract by writing every editable surface.
|
||||
|
||||
test('TC-22 settings + variable + panel survive a hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc22');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// 1. Settings drawer: rename + description + tag.
|
||||
let drawer = await openDashboardSettingsDrawer(page);
|
||||
await drawer.getByTestId('dashboard-name').clear();
|
||||
await drawer.getByTestId('dashboard-name').fill('create-flow-tc22-roundtrip');
|
||||
await drawer.getByTestId('dashboard-desc').fill('round trip description');
|
||||
const tagInput = drawer.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.click();
|
||||
await tagInput.fill('roundtrip-tag');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByTestId('save-dashboard-config').click();
|
||||
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
|
||||
|
||||
// 2. Variable tab — add a Custom variable.
|
||||
await drawer.getByRole('button', { name: 'Variables' }).click();
|
||||
await drawer.getByTestId('add-new-variable').click();
|
||||
await drawer.getByPlaceholder('Unique name of the variable').fill('region');
|
||||
await drawer.getByRole('button', { name: 'Custom' }).click();
|
||||
await drawer
|
||||
.getByPlaceholder('Enter options separated by commas.')
|
||||
.fill('us,eu,ap');
|
||||
const varSave = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await drawer.getByRole('button', { name: 'Save Variable' }).click();
|
||||
await varSave;
|
||||
await drawer.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// 3. Add a metrics panel.
|
||||
await configureAndSavePanel(page, 'metrics', 'tc22-metrics');
|
||||
await expect(page.getByTestId('tc22-metrics')).toBeVisible();
|
||||
|
||||
// 4. Hard reload — assert everything persisted across a fresh fetch.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText(
|
||||
'create-flow-tc22-roundtrip',
|
||||
);
|
||||
await expect(page.locator('.dashboard-variables')).toContainText('$region');
|
||||
await expect(page.getByTestId('tc22-metrics')).toBeVisible();
|
||||
|
||||
// 5. Server confirmation — every change is in the persisted JSON.
|
||||
const persisted = await fetchDashboardData(page, id);
|
||||
expect(persisted.title).toBe('create-flow-tc22-roundtrip');
|
||||
expect(persisted.description).toBe('round trip description');
|
||||
expect(persisted.tags ?? []).toContain('roundtrip-tag');
|
||||
expect(persisted.widgets?.length).toBe(1);
|
||||
const persistedVars = Object.values(persisted.variables ?? {}) as Array<{
|
||||
name?: string;
|
||||
}>;
|
||||
expect(persistedVars.map((v) => v.name)).toContain('region');
|
||||
});
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
openWidgetEditor,
|
||||
saveWidgetEdit,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const FIXTURE_DASHBOARD_TITLE = 'list-controls-fixture';
|
||||
const FIXTURE_PANEL_TITLE = 'list-controls-panel';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
|
||||
// LIST panels require a logs (or traces) data source — metrics queries
|
||||
// hide the LIST option from panel-change-select.
|
||||
await configureAndSavePanel(page, 'logs', FIXTURE_PANEL_TITLE);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await changePanelType(page, 'List');
|
||||
await saveWidgetEdit(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoFixtureDashboard(page: Page): Promise<void> {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/** Fetch the persisted fixture dashboard's first widget. */
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
test.describe('List Panel Controls', () => {
|
||||
test('TC-01 panel name persists and is reflected in the widget header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('list-controls-renamed');
|
||||
await saveWidgetEdit(page);
|
||||
await expect(page.getByTestId('list-controls-renamed').first()).toBeVisible();
|
||||
|
||||
// Server-side check.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('list-controls-renamed');
|
||||
|
||||
await openWidgetEditor(page, 'list-controls-renamed');
|
||||
await expect(page.getByTestId('panel-name-input')).toHaveValue(
|
||||
'list-controls-renamed',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-02 description persists and shows info icon on header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('E2E list description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe('E2E list description');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page.getByTestId('panel-description-input')).toHaveValue(
|
||||
'E2E list description',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-03 panel type switch from List to Table persists and re-renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await changePanelType(page, 'Table');
|
||||
// Table re-renders Decimal Precision + Column Units in the right pane.
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Panel card should now render an Ant table head.
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="' + FIXTURE_PANEL_TITLE + '"]')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.ant-table-thead').first()).toBeVisible();
|
||||
|
||||
// Server-side: panelTypes is 'table'.
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('table');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page).toHaveURL(/graphType=table/);
|
||||
|
||||
// Reset back to List.
|
||||
await changePanelType(page, 'List');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-04 sections hidden for LIST are not rendered in the right pane', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await expect(page.locator('section.panel-time-preference')).toHaveCount(0);
|
||||
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
|
||||
await expect(page.locator('section.stack-chart')).toHaveCount(0);
|
||||
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
|
||||
await expect(page.locator('section.log-scale')).toHaveCount(0);
|
||||
await expect(page.locator('section.legend-position')).toHaveCount(0);
|
||||
await expect(page.locator('.decimal-precision-selector')).toHaveCount(0);
|
||||
await expect(page.locator('.column-unit-selector')).toHaveCount(0);
|
||||
await expect(page.locator('.y-axis-unit-selector-v2')).toHaveCount(0);
|
||||
await expect(page.getByTestId('add-threshold-cta')).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId('panel-name-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-description-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-change-select')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 discarding right-pane changes does not persist', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('discard-list-test');
|
||||
|
||||
let putFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
putFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.getByTestId('discard-button').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.last()
|
||||
.getByRole('button', { name: /^OK$/i })
|
||||
.click({ timeout: 1000 })
|
||||
.catch(() => {
|
||||
// no modal — direct navigation
|
||||
});
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
|
||||
|
||||
// Settle before asserting no PUT.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Save description + a non-default panel type, then hard-reload and
|
||||
// re-verify the panel card rehydrates with the right state.
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Description info icon must render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Server-side check post-reload — confirms the load path read the same JSON.
|
||||
const persisted = await fetchFixtureWidget(page);
|
||||
expect(persisted.description).toBe('reload persistence description');
|
||||
expect(persisted.panelTypes).toBe('list');
|
||||
|
||||
// Reset.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
});
|
||||
@@ -1,570 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
openWidgetEditor,
|
||||
saveWidgetEdit,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const FIXTURE_DASHBOARD_TITLE = 'table-controls-fixture';
|
||||
const FIXTURE_PANEL_TITLE = 'table-controls-panel';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
|
||||
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await changePanelType(page, 'Table');
|
||||
await saveWidgetEdit(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoFixtureDashboard(page: Page): Promise<void> {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the persisted fixture dashboard JSON and return the first widget.
|
||||
* Use this after a save to confirm the PUT actually landed the expected
|
||||
* shape on the backend — UI-only round-trips pass on optimistic-update bugs.
|
||||
*/
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last <td> in the first data row of the panel's Ant Design table.
|
||||
* Ant Design applies .ant-table-row to actual data rows only (not header rows),
|
||||
* so this correctly skips the fixed/sticky header tbody rows.
|
||||
*
|
||||
* For the metrics panel the row has: td[0] = label column, td[last] = value
|
||||
* column (the aggregation query "A"). The last td is thus the value cell.
|
||||
* However, depending on the panel query there may only be ONE td per row. Use
|
||||
* the cell that contains a non-empty value: any td that is not purely the
|
||||
* label placeholder.
|
||||
*
|
||||
* NOTE: the value cell wraps its text in a <button> element (from the
|
||||
* QueryTable open-traces render path) so textContent picks it up correctly.
|
||||
*/
|
||||
async function getFirstDataCell(page: Page) {
|
||||
// .ant-table-row targets Ant Design data rows only (not header/fixed rows).
|
||||
const firstRow = page.locator('tr.ant-table-row').first();
|
||||
await firstRow.waitFor({ state: 'visible' });
|
||||
// Return the last <td> — for a metrics table with columns [label, A] this
|
||||
// is the value column. For a single-column table it is the only column.
|
||||
return firstRow.locator('td').last();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a SettingsSection accordion in the widget editor right pane is
|
||||
* expanded. If it is already open (content div has the `open` class), this is
|
||||
* a no-op. Otherwise it clicks the header button and waits for the content to
|
||||
* become visible.
|
||||
*/
|
||||
async function expandSection(page: Page, title: string): Promise<void> {
|
||||
const section = page
|
||||
.locator('.settings-section')
|
||||
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
|
||||
const contentDiv = section.locator('.settings-section-content');
|
||||
const isOpen = await contentDiv.evaluate((el) => el.classList.contains('open'));
|
||||
if (!isOpen) {
|
||||
await section.locator('button.settings-section-header').click();
|
||||
await contentDiv.waitFor({ state: 'visible' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a unit from the column-unit selector dropdown by typing a search
|
||||
* term, then clicking the filtered option. Scoped to .column-unit-selector to
|
||||
* avoid matching the Y-axis unit selectors on other panel types.
|
||||
*
|
||||
* The selector has `showSearch` enabled and renders a long virtualised option
|
||||
* list — typing first avoids instability from the list re-rendering when the
|
||||
* target option is off-screen.
|
||||
*/
|
||||
async function selectColumnUnit(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionText: string,
|
||||
): Promise<void> {
|
||||
const unitSelect = page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select')
|
||||
.first();
|
||||
await unitSelect.click();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select input')
|
||||
.first()
|
||||
.fill(searchTerm);
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: optionText })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Table Panel Controls', () => {
|
||||
test('TC-01 panel name persists and is reflected in the widget header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('table-controls-renamed');
|
||||
await saveWidgetEdit(page);
|
||||
await expect(page.getByTestId('table-controls-renamed').first()).toBeVisible();
|
||||
|
||||
// Server-side check — the PUT must carry the new title.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('table-controls-renamed');
|
||||
|
||||
await openWidgetEditor(page, 'table-controls-renamed');
|
||||
await expect(page.getByTestId('panel-name-input')).toHaveValue(
|
||||
'table-controls-renamed',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-02 description persists and shows info icon on header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('E2E table description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe(
|
||||
'E2E table description',
|
||||
);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page.getByTestId('panel-description-input')).toHaveValue(
|
||||
'E2E table description',
|
||||
);
|
||||
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-03 panel time preference switches to Last 15 min and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button', { name: /global time/i })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Server-side: persisted timePreferance enum, not just visible label.
|
||||
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(
|
||||
page.locator('section.panel-time-preference').getByRole('button'),
|
||||
).toContainText(/Last 15 min/i);
|
||||
|
||||
await page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: /Global Time/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-04 column unit formats the matching column cells and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Use selectColumnUnit to avoid virtualised-list detached-DOM failures.
|
||||
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Cell text in the data column should now contain the `ms` suffix.
|
||||
// Strict check: text must be a number with the unit, not just an empty
|
||||
// cell that happens to substring-match "ms".
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toHaveText(/^\s*[-+]?\d[\d,.eE+-]*\s*ms\s*$/);
|
||||
|
||||
// Server-side: columnUnits must record the unit code, not just the
|
||||
// label. UI display can use a fancy label while the persisted enum drifts.
|
||||
const persistedAfterUnit = await fetchFixtureWidget(page);
|
||||
const columnUnitValues = Object.values(persistedAfterUnit.columnUnits ?? {});
|
||||
expect(columnUnitValues, 'columnUnits must include the chosen unit').toContain(
|
||||
'ms',
|
||||
);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// Section starts collapsed again on re-open — expand before asserting.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(
|
||||
page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-selection-item')
|
||||
.first(),
|
||||
).toContainText(/Milliseconds/);
|
||||
|
||||
// Reset — clear the unit via the Ant Select allowClear X button.
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2')
|
||||
.first()
|
||||
.hover();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
|
||||
.first()
|
||||
.click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-05 decimal precision changes the number of decimals when a column unit is set', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Set a column unit so decimal precision has a visible effect.
|
||||
await selectColumnUnit(page, 'Seconds', 'Seconds (s)');
|
||||
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Strict: text must be an integer followed by " s", not empty / partial.
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toHaveText(/^\s*[-+]?\d+\s*s\s*$/);
|
||||
|
||||
// Server-side: decimalPrecision must be 0 in the persisted widget.
|
||||
expect((await fetchFixtureWidget(page)).decimalPrecision).toBe(0);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// Section starts collapsed again on re-open — expand before asserting.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
/0 decimals/,
|
||||
);
|
||||
|
||||
// Reset: decimal precision back to 2, clear column unit.
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2')
|
||||
.first()
|
||||
.hover();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
|
||||
.first()
|
||||
.click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-06 column-targeted Background threshold paints only the targeted column', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
const card = page.locator('.threshold-container').first();
|
||||
|
||||
// For TABLE thresholds the column selector (table-operator-input-selector)
|
||||
// defaults to the first aggregation query column (typically `A`). Operator
|
||||
// defaults to '>'; switch to '>=' so it reliably matches non-negative values.
|
||||
await card.getByTestId('operator-input-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await card.getByTestId('threshold-color-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: 'Background' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Save the threshold row (commits it to the thresholds state array).
|
||||
await card.getByRole('button', { name: /save changes/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Inspect the threshold-styled cell directly. The testid host carries
|
||||
// `data-threshold-format="Background"` so we can confirm the format too.
|
||||
const row = page.locator('tr.ant-table-row').first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const styledCell = row.getByTestId('threshold-styled-cell').first();
|
||||
await expect(styledCell).toBeVisible();
|
||||
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Background');
|
||||
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
|
||||
expect(dataStyle).toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] must be persisted with format=Background.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
|
||||
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
|
||||
|
||||
// Reset — delete the threshold via its testid.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// ThresholdsSection defaultOpen is based on threshold count at mount; may
|
||||
// start collapsed due to async state loading — always expand before interacting.
|
||||
await expandSection(page, 'Thresholds');
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-07 column-targeted Text threshold colors only the targeted column text', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
const card = page.locator('.threshold-container').first();
|
||||
|
||||
await card.getByTestId('operator-input-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
// Format defaults to 'Text' — no change needed.
|
||||
await card.getByRole('button', { name: /save changes/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
const row = page.locator('tr.ant-table-row').first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const styledCell = row.getByTestId('threshold-styled-cell').first();
|
||||
await expect(styledCell).toBeVisible();
|
||||
await expect(styledCell).toHaveAttribute('data-threshold-format', 'Text');
|
||||
const dataStyle = (await styledCell.getAttribute('style')) ?? '';
|
||||
expect(dataStyle).toMatch(/color:/);
|
||||
expect(dataStyle).not.toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] must be persisted with format=Text.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
|
||||
|
||||
// Reset
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Thresholds');
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-08 sections hidden for TABLE are not rendered', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
|
||||
await expect(page.locator('section.stack-chart')).toHaveCount(0);
|
||||
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
|
||||
await expect(page.locator('section.log-scale')).toHaveCount(0);
|
||||
await expect(page.locator('section.legend-position')).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId('panel-name-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-change-select')).toBeVisible();
|
||||
|
||||
// decimal-precision-selector and column-unit-selector are inside the
|
||||
// "Formatting & Units" section which starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
|
||||
await expect(page.locator('.column-unit-selector').first()).toBeVisible();
|
||||
|
||||
// add-threshold-cta is inside "Thresholds" which is also collapsed.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-09 panel type switch from Table to Number persists and re-renders as a number', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await changePanelType(page, 'Number');
|
||||
// Number panel exposes the Y-axis unit selector in the Formatting & Units section.
|
||||
await expect(page.locator('.y-axis-unit-selector-v2').first()).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(page.getByTestId('value-graph-text').first()).toBeVisible();
|
||||
|
||||
// Server-side: persisted panelTypes is the PANEL_TYPES enum value 'value'.
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('value');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page).toHaveURL(/graphType=value/);
|
||||
|
||||
// Reset: switch back to Table.
|
||||
await changePanelType(page, 'Table');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-10 discarding right-pane changes does not persist', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('discard-table-test');
|
||||
|
||||
let putFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
putFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.getByTestId('discard-button').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.last()
|
||||
.getByRole('button', { name: /^OK$/i })
|
||||
.click({ timeout: 1000 })
|
||||
.catch(() => {
|
||||
// no modal — direct navigation
|
||||
});
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
|
||||
|
||||
// Settle before asserting — a delayed PUT could otherwise sneak past.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-11 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Apply a combination of edits, save, then hard-reload the page and
|
||||
// re-verify everything renders from the persisted JSON. Catches backend
|
||||
// → frontend rehydration regressions that round-trips via close+reopen
|
||||
// editor miss (re-opening the editor reuses the in-memory query state).
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await selectColumnUnit(page, 'Milliseconds', 'Milliseconds (ms)');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Hard reload — purges in-memory state, forces a fresh fetch.
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Cell value must still carry the unit after reload (proves the
|
||||
// columnUnits + decimalPrecision + panelType rehydrated correctly).
|
||||
const cell = await getFirstDataCell(page);
|
||||
await expect(cell).toHaveText(/^\s*[-+]?\d[\d,.eE+-]*\s*ms\s*$/);
|
||||
|
||||
// Description info icon (the only header surface for description) must
|
||||
// still render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset: clear unit + description.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2')
|
||||
.first()
|
||||
.hover();
|
||||
await page
|
||||
.locator('.column-unit-selector .y-axis-unit-selector-v2 .ant-select-clear')
|
||||
.first()
|
||||
.click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
});
|
||||
@@ -1,579 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
changePanelType,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
fetchDashboardData,
|
||||
findDashboardIdByTitle,
|
||||
openWidgetEditor,
|
||||
saveWidgetEdit,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// All TCs operate on the same fixture panel and toggle its state — they MUST
|
||||
// run serially within the worker. Project-level fullyParallel still runs this
|
||||
// file in parallel with other files.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const FIXTURE_DASHBOARD_TITLE = 'value-controls-fixture';
|
||||
const FIXTURE_PANEL_TITLE = 'value-controls-panel';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const id = await createDashboardViaApi(page, FIXTURE_DASHBOARD_TITLE);
|
||||
seedIds.add(id);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId('add-panel').waitFor({ state: 'visible' });
|
||||
await configureAndSavePanel(page, 'metrics', FIXTURE_PANEL_TITLE);
|
||||
// configureAndSavePanel creates a Time Series panel. Switch it to the
|
||||
// Number (VALUE) type before the per-TC bodies run.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await changePanelType(page, 'Number');
|
||||
await saveWidgetEdit(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoFixtureDashboard(page: Page): Promise<void> {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/** Fetch the persisted fixture dashboard's first widget. */
|
||||
async function fetchFixtureWidget(page: Page) {
|
||||
const id = await findDashboardIdByTitle(page, FIXTURE_DASHBOARD_TITLE);
|
||||
expect(id, `${FIXTURE_DASHBOARD_TITLE} not found`).toBeTruthy();
|
||||
const dashboard = await fetchDashboardData(page, id!);
|
||||
const widget = dashboard.widgets?.[0];
|
||||
expect(widget, 'fixture dashboard must have at least one widget').toBeTruthy();
|
||||
return widget!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a SettingsSection accordion in the widget editor right pane is
|
||||
* expanded. If it is already open (content div has the `open` class), this is
|
||||
* a no-op. Otherwise it clicks the header button and waits for the CSS
|
||||
* transition to complete. This handles both the common case (collapsed on
|
||||
* mount) and the defensive case (already open).
|
||||
*/
|
||||
async function expandSection(page: Page, title: string): Promise<void> {
|
||||
// Find the settings-section that contains this title in its header.
|
||||
const section = page
|
||||
.locator('.settings-section')
|
||||
.filter({ has: page.locator('button.settings-section-header', { hasText: title }) });
|
||||
|
||||
// Check if the content div already has the `open` class.
|
||||
const contentDiv = section.locator('.settings-section-content');
|
||||
const isOpen = await contentDiv.evaluate((el) =>
|
||||
el.classList.contains('open'),
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
// Click the header button to open the section.
|
||||
await section.locator('button.settings-section-header').click();
|
||||
// Wait for the CSS transition to complete (opacity 0→1, max-height 0→1000px).
|
||||
await contentDiv.waitFor({ state: 'visible' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a unit from the Y-axis unit selector dropdown by typing a search
|
||||
* term, then clicking the filtered option. The selector has `showSearch`
|
||||
* enabled and renders a long virtualised option list — typing first avoids
|
||||
* instability from the virtualised list re-rendering when the target option
|
||||
* is off-screen.
|
||||
*/
|
||||
async function selectYAxisUnit(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionText: string,
|
||||
): Promise<void> {
|
||||
// Click the outer wrapper to open the dropdown.
|
||||
const unitSelect = page.locator('.y-axis-unit-selector-v2 .ant-select').first();
|
||||
await unitSelect.click();
|
||||
// The Ant Select input is now focused — type to filter the virtual list.
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select input').first().fill(searchTerm);
|
||||
// Wait for the dropdown to show the filtered option, then click it.
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: optionText })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Value Panel Controls', () => {
|
||||
test('TC-01 panel name persists and is reflected in the widget header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('value-controls-renamed');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(page.getByTestId('value-controls-renamed').first()).toBeVisible();
|
||||
|
||||
// Server-side check.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe('value-controls-renamed');
|
||||
|
||||
await openWidgetEditor(page, 'value-controls-renamed');
|
||||
await expect(page.getByTestId('panel-name-input')).toHaveValue(
|
||||
'value-controls-renamed',
|
||||
);
|
||||
|
||||
// Reset back to fixture title so subsequent TCs locate the panel.
|
||||
await page.getByTestId('panel-name-input').fill(FIXTURE_PANEL_TITLE);
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-02 panel description persists and renders the info icon on the header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('E2E test description');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect((await fetchFixtureWidget(page)).description).toBe(
|
||||
'E2E test description',
|
||||
);
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page.getByTestId('panel-description-input')).toHaveValue(
|
||||
'E2E test description',
|
||||
);
|
||||
|
||||
// Reset
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-03 panel time preference switches from Global Time to Last 15 min and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
const timeButton = page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button', { name: /global time/i });
|
||||
await timeButton.click();
|
||||
await page.getByRole('menuitem', { name: /Last 15 min/i }).click();
|
||||
await expect(
|
||||
page.locator('section.panel-time-preference').getByRole('button'),
|
||||
).toContainText(/Last 15 min/i);
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Server-side: persisted timePreferance enum.
|
||||
expect((await fetchFixtureWidget(page)).timePreferance).toBe('LAST_15_MIN');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(
|
||||
page.locator('section.panel-time-preference').getByRole('button'),
|
||||
).toContainText(/Last 15 min/i);
|
||||
|
||||
// Reset
|
||||
await page
|
||||
.locator('section.panel-time-preference')
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: /Global Time/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-04 Y-axis unit applies a suffix to the rendered value and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// The Y-Axis Unit selector has showSearch enabled and a long virtualised
|
||||
// option list. Type "Seconds" to filter before clicking.
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
|
||||
// Live preview should now render a suffix unit `s`.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Back on the dashboard the panel card should also render the suffix.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
// Server-side: yAxisUnit must hold the unit code (catches a label-only
|
||||
// regression where the UI shows "Seconds" but persists nothing).
|
||||
expect((await fetchFixtureWidget(page)).yAxisUnit).toBe('s');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(
|
||||
page.locator('.y-axis-unit-selector-v2 .ant-select-selection-item').first(),
|
||||
).toContainText(/Seconds/);
|
||||
|
||||
// Reset — clear the unit via allowClear (X button on the Ant Select).
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-05 decimal precision reformats the rendered value when a unit is set', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Setting a unit is required for decimal precision to have a visible
|
||||
// effect — see Known Limitations #3 in the test plan.
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '0 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Live preview: the numeric text must be a non-empty integer (no decimal).
|
||||
await expect(page.getByTestId('value-graph-text').first()).toHaveText(
|
||||
/^[-+]?\d+$/,
|
||||
);
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Dashboard render: same assertion.
|
||||
await expect(page.getByTestId('value-graph-text').first()).toHaveText(
|
||||
/^[-+]?\d+$/,
|
||||
);
|
||||
|
||||
// Server-side: decimalPrecision is 0 and yAxisUnit is 's'.
|
||||
const persistedDecimals = await fetchFixtureWidget(page);
|
||||
expect(persistedDecimals.decimalPrecision).toBe(0);
|
||||
expect(persistedDecimals.yAxisUnit).toBe('s');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toContainText(
|
||||
/0 decimals/,
|
||||
);
|
||||
|
||||
// Reset: restore default 2 decimals and clear the unit.
|
||||
await page.getByTestId('decimal-precision-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '2 decimals' })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-06 Text-format threshold colors the rendered value text and persists', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds
|
||||
// (defaultOpen={!!thresholds.length}) — expand it first.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
|
||||
// VALUE panels do not render a threshold label input — only operator,
|
||||
// value, unit, format (Text/Background), and color. Defaults: operator
|
||||
// '>', format 'Text', value 0, color 'Red'. We force operator to '>=' so
|
||||
// the threshold reliably matches non-negative values.
|
||||
const thresholdCard = page.locator('.threshold-container').first();
|
||||
await thresholdCard
|
||||
.getByTestId('operator-input-selector')
|
||||
.click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Save the threshold row (commits it to the thresholds state array). The
|
||||
// dashboard PUT still needs `saveWidgetEdit` after this.
|
||||
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Dashboard render: value text should now carry an inline color style.
|
||||
const valueText = page.getByTestId('value-graph-text').first();
|
||||
await expect(valueText).toBeVisible();
|
||||
const inlineStyle = await valueText.getAttribute('style');
|
||||
expect(inlineStyle).toMatch(/color:/);
|
||||
|
||||
// Server-side: thresholds[] persisted with format=Text.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Text');
|
||||
expect(persistedThresholds[0].thresholdOperator).toBe('>=');
|
||||
|
||||
// Re-open editor and verify the threshold round-tripped.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// The ThresholdsSection defaultOpen is based on threshold count at mount
|
||||
// time; due to async state loading it may start collapsed. Expand it.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await expect(
|
||||
page.locator('.threshold-container').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset — delete the threshold via testid.
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-07 Background-format threshold paints the value container background', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Thresholds" section starts collapsed when there are no thresholds.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await page.getByTestId('add-threshold-cta').click();
|
||||
const thresholdCard = page.locator('.threshold-container').first();
|
||||
|
||||
// Set operator >= and switch format from Text to Background.
|
||||
await thresholdCard.getByTestId('operator-input-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: '>=' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await thresholdCard.getByTestId('threshold-color-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: 'Background' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await thresholdCard.getByRole('button', { name: /save changes/i }).click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Dashboard render: value-graph-container must carry an inline background.
|
||||
const container = page.getByTestId('value-graph-container').first();
|
||||
await expect(container).toBeVisible();
|
||||
const inlineStyle = await container.getAttribute('style');
|
||||
expect(inlineStyle).toMatch(/background-color:/);
|
||||
|
||||
// Server-side: thresholds[] persisted with format=Background.
|
||||
const persistedThresholds = (await fetchFixtureWidget(page)).thresholds ?? [];
|
||||
expect(persistedThresholds.length).toBe(1);
|
||||
expect(persistedThresholds[0].thresholdFormat).toBe('Background');
|
||||
|
||||
// Reset
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
// ThresholdsSection may start collapsed even with thresholds — always
|
||||
// expand before interacting with threshold cards.
|
||||
await expandSection(page, 'Thresholds');
|
||||
// Edit/delete buttons are display:none by default, revealed on :hover.
|
||||
const firstCard = page.locator('.threshold-card-container').first();
|
||||
await firstCard.hover();
|
||||
await firstCard.getByTestId('threshold-delete-btn').click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-08 clearing the Y-axis unit removes the suffix from the rendered value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// The "Formatting & Units" section starts collapsed — expand it first.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
|
||||
// Apply a unit first.
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
await saveWidgetEdit(page);
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
// Clear it.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Suffix should be gone from the rendered panel.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit')).toHaveCount(0);
|
||||
|
||||
// Server-side: yAxisUnit must be cleared (empty / undefined).
|
||||
const cleared = await fetchFixtureWidget(page);
|
||||
expect(cleared.yAxisUnit ?? '').toBe('');
|
||||
});
|
||||
|
||||
test('TC-09 panel type switch from Number to Time Series persists and re-renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await changePanelType(page, 'Time Series');
|
||||
// Time Series exposes Fill gaps — confirm the right pane re-rendered.
|
||||
await expect(page.locator('section.fill-gaps')).toBeVisible();
|
||||
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
// Server-side: panelTypes is 'graph' (PANEL_TYPES.TIME_SERIES).
|
||||
expect((await fetchFixtureWidget(page)).panelTypes).toBe('graph');
|
||||
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await expect(page).toHaveURL(/graphType=graph/);
|
||||
|
||||
// Reset: switch back to Number for downstream TCs.
|
||||
await changePanelType(page, 'Number');
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
|
||||
test('TC-10 sections hidden for VALUE are not rendered in the right pane', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
// Hidden by the panel-type matrix for VALUE — these sections are not
|
||||
// rendered in the DOM at all (conditionally excluded by RightContainer).
|
||||
await expect(page.locator('section.soft-min-max')).toHaveCount(0);
|
||||
await expect(page.locator('section.log-scale')).toHaveCount(0);
|
||||
await expect(page.locator('section.legend-position')).toHaveCount(0);
|
||||
await expect(page.locator('section.fill-gaps')).toHaveCount(0);
|
||||
await expect(page.locator('section.stack-chart')).toHaveCount(0);
|
||||
|
||||
// Expected to be present in the always-open General and Visualization
|
||||
// sections.
|
||||
await expect(page.getByTestId('panel-name-input')).toBeVisible();
|
||||
await expect(page.getByTestId('panel-change-select')).toBeVisible();
|
||||
|
||||
// The "Formatting & Units" section is collapsed on open — expand it to
|
||||
// verify the controls are rendered for VALUE.
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await expect(page.getByTestId('decimal-precision-selector')).toBeVisible();
|
||||
|
||||
// The "Thresholds" section is collapsed when there are no thresholds —
|
||||
// expand it to verify the Add Threshold CTA is rendered for VALUE.
|
||||
await expandSection(page, 'Thresholds');
|
||||
await expect(page.getByTestId('add-threshold-cta')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 discarding right-pane changes does not persist or visually update', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page.getByTestId('panel-name-input').fill('discard-value-test');
|
||||
|
||||
let putFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
putFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await page.getByTestId('discard-button').click();
|
||||
// If a discard confirmation appears, OK it. Right-pane-only changes
|
||||
// usually don't trigger one.
|
||||
const confirmDialog = page.getByRole('dialog').last();
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: /^OK$/i })
|
||||
.click({ timeout: 1000 })
|
||||
.catch(() => {
|
||||
// no modal — the editor navigated away immediately
|
||||
});
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
await expect(page.getByTestId(FIXTURE_PANEL_TITLE).first()).toBeVisible();
|
||||
|
||||
// Settle before asserting no PUT.
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(putFired).toBe(false);
|
||||
|
||||
// Server-side double-check: persisted title is still the fixture name.
|
||||
expect((await fetchFixtureWidget(page)).title).toBe(FIXTURE_PANEL_TITLE);
|
||||
});
|
||||
|
||||
// ─── Reload persistence ──────────────────────────────────────────────────
|
||||
|
||||
test('TC-12 panel state survives a hard dashboard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Apply unit + description + decimal precision, save, hard-reload, and
|
||||
// re-verify the panel renders correctly from the persisted JSON.
|
||||
await gotoFixtureDashboard(page);
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
|
||||
await page
|
||||
.getByTestId('panel-description-input')
|
||||
.fill('reload persistence description');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await selectYAxisUnit(page, 'Seconds', 'Seconds (s)');
|
||||
await saveWidgetEdit(page);
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(FIXTURE_PANEL_TITLE).first().waitFor({ state: 'visible' });
|
||||
|
||||
// Suffix unit must render after rehydration.
|
||||
await expect(page.getByTestId('value-graph-suffix-unit').first()).toBeVisible();
|
||||
|
||||
// Description info icon must render after rehydration.
|
||||
await expect(
|
||||
page
|
||||
.locator('.widget-header-container')
|
||||
.filter({ hasText: FIXTURE_PANEL_TITLE })
|
||||
.locator('.info-tooltip')
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Reset.
|
||||
await openWidgetEditor(page, FIXTURE_PANEL_TITLE);
|
||||
await page.getByTestId('panel-description-input').fill('');
|
||||
await expandSection(page, 'Formatting & Units');
|
||||
await page.locator('.y-axis-unit-selector-v2').first().hover();
|
||||
await page.locator('.y-axis-unit-selector-v2 .ant-select-clear').first().click();
|
||||
await saveWidgetEdit(page);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user