Compare commits

..

2 Commits

Author SHA1 Message Date
aks07
bee0924164 test(explorer): add e2e coverage for the list order-by
Covers the two reported bugs, back navigation on traces and a deep link on
logs, and the two paths that unit tests cannot reach: the URL round trip and
the clear between the LLM raw tabs.

The order-by control gets a data-testid. Its four mount points share no other
hook, since the LLM trace view styles it with a CSS module.
2026-10-14 15:44:07 +05:30
aks07
65298c25b0 test(logs-explorer): cover the list query rebuild on an order change
The existing cases assert the default order on first render. None of them
change the order, so nothing caught the effect that rebuilds the request.
2026-10-14 15:43:52 +05:30
215 changed files with 1990 additions and 8674 deletions

View File

@@ -17,4 +17,3 @@ For the Python integration suite under `tests/`. Setup, running, and suite layou
- **snake_case parametrize ids.** `ids=["fill_gaps", "fill_zero"]`, not camelCase.
- **Name suite files with the two-digit prefix (`NN_*.py`).** `pyproject.toml` restricts collection to `[0-9][0-9]_*.py` (plus the bootstrap `setup.py` / `run.py`) — a file that doesn't match is silently never collected.
- **Always run pytest from `tests/`.** `--import-mode=importlib` is what allows same-basename files across suites (`querier/01_logs.py` vs `rawexportdata/01_logs.py`), but it disables pytest's implicit `sys.path` injection — `import fixtures` only resolves via `pythonpath = ["."]` from that rootdir.
- **After changing any `.py` file, run `make py-fmt` and `make py-lint`** from the repo root and fix what they report.

View File

@@ -1,219 +1,5 @@
components:
schemas:
AiobservabilitytypesBlobPart:
properties:
content:
type: string
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
required:
- type
- modality
- content
type: object
AiobservabilitytypesChatMessage:
properties:
name:
nullable: true
type: string
parts:
$ref: '#/components/schemas/AiobservabilitytypesParts'
role:
type: string
required:
- role
- parts
type: object
AiobservabilitytypesCompactionPart:
properties:
content:
nullable: true
type: string
id:
nullable: true
type: string
type:
type: string
required:
- type
type: object
AiobservabilitytypesFilePart:
properties:
file_id:
type: string
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
required:
- type
- modality
- file_id
type: object
AiobservabilitytypesGenericPart:
additionalProperties: {}
type: object
AiobservabilitytypesGenericServerToolCall:
additionalProperties: {}
type: object
AiobservabilitytypesGenericServerToolCallResponse:
additionalProperties: {}
type: object
AiobservabilitytypesInputMessages:
items:
$ref: '#/components/schemas/AiobservabilitytypesChatMessage'
type: array
AiobservabilitytypesOutputMessage:
properties:
finish_reason:
nullable: true
type: string
name:
nullable: true
type: string
parts:
$ref: '#/components/schemas/AiobservabilitytypesParts'
role:
type: string
required:
- role
- parts
type: object
AiobservabilitytypesOutputMessages:
items:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessage'
type: array
AiobservabilitytypesPart:
discriminator:
mapping:
blob: '#/components/schemas/AiobservabilitytypesBlobPart'
compaction: '#/components/schemas/AiobservabilitytypesCompactionPart'
file: '#/components/schemas/AiobservabilitytypesFilePart'
reasoning: '#/components/schemas/AiobservabilitytypesReasoningPart'
server_tool_call: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
server_tool_call_response: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
text: '#/components/schemas/AiobservabilitytypesTextPart'
tool_call: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
tool_call_response: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
uri: '#/components/schemas/AiobservabilitytypesUriPart'
propertyName: type
oneOf:
- $ref: '#/components/schemas/AiobservabilitytypesTextPart'
- $ref: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
- $ref: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
- $ref: '#/components/schemas/AiobservabilitytypesBlobPart'
- $ref: '#/components/schemas/AiobservabilitytypesFilePart'
- $ref: '#/components/schemas/AiobservabilitytypesUriPart'
- $ref: '#/components/schemas/AiobservabilitytypesReasoningPart'
- $ref: '#/components/schemas/AiobservabilitytypesCompactionPart'
- $ref: '#/components/schemas/AiobservabilitytypesGenericPart'
type: object
AiobservabilitytypesParts:
items:
$ref: '#/components/schemas/AiobservabilitytypesPart'
type: array
AiobservabilitytypesReasoningPart:
properties:
content:
type: string
type:
type: string
required:
- type
- content
type: object
AiobservabilitytypesServerToolCallPart:
properties:
id:
nullable: true
type: string
name:
type: string
server_tool_call:
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCall'
type:
type: string
required:
- type
- name
- server_tool_call
type: object
AiobservabilitytypesServerToolCallResponsePart:
properties:
id:
nullable: true
type: string
server_tool_call_response:
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCallResponse'
type:
type: string
required:
- type
- server_tool_call_response
type: object
AiobservabilitytypesTextPart:
properties:
content:
type: string
type:
type: string
required:
- type
- content
type: object
AiobservabilitytypesToolCallRequestPart:
properties:
arguments: {}
id:
nullable: true
type: string
name:
type: string
type:
type: string
required:
- type
- name
type: object
AiobservabilitytypesToolCallResponsePart:
properties:
id:
nullable: true
type: string
response:
nullable: true
type:
type: string
required:
- type
- response
type: object
AiobservabilitytypesUriPart:
properties:
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
uri:
type: string
required:
- type
- modality
- uri
type: object
AlertmanagertypesChannel:
properties:
createdAt:
@@ -3581,69 +3367,6 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAIBuilderQuerySpec:
properties:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
type: boolean
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
legend:
type: string
limit:
type: integer
limitBy:
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
name:
type: string
offset:
type: integer
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
@@ -4697,7 +4420,6 @@ components:
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -4707,7 +4429,6 @@ components:
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
@@ -4717,25 +4438,12 @@ components:
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/AIBuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
properties:
kind:
enum:
- signoz/AIBuilderQuery
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
properties:
kind:
@@ -10517,16 +10225,6 @@ components:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
formatted_input:
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
formatted_output:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
formatter:
type: string
formatter_warnings:
items:
type: string
type: array
has_error:
type: boolean
kind_string:
@@ -10569,10 +10267,6 @@ components:
- attributes
- events
- references
- formatted_input
- formatted_output
- formatter
- formatter_warnings
type: object
SpantypesTraceAISummary:
properties:
@@ -16440,12 +16134,9 @@ paths:
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages, or
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
are decoded into formatted_input and formatted_output, with formatter naming
the converter and formatter_warnings what it could not resolve. Pass nextCursor
as after or prevCursor as before to page, or spanId to open the page around
a span.
description: Returns the spans carrying gen_ai input or output messages in timestamp
order. Pass nextCursor as after or prevCursor as before to page, or spanId
to open the page around a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.

View File

@@ -49,7 +49,6 @@
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@grafana/data": "^11.6.15",
"@lezer/highlight": "1.2.1",
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",

View File

@@ -62,9 +62,6 @@ importers:
'@grafana/data':
specifier: ^11.6.15
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@lezer/highlight':
specifier: 1.2.1
version: 1.2.1
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)

View File

@@ -4,264 +4,6 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
export enum AiobservabilitytypesBlobPartDTOType {
blob = 'blob',
}
export interface AiobservabilitytypesBlobPartDTO {
/**
* @type string
*/
content: string;
/**
* @type string,null
*/
mime_type?: string | null;
/**
* @type string
*/
modality: string;
/**
* @type string
* @enum blob
*/
type: AiobservabilitytypesBlobPartDTOType;
}
export enum AiobservabilitytypesTextPartDTOType {
text = 'text',
}
export interface AiobservabilitytypesTextPartDTO {
/**
* @type string
*/
content: string;
/**
* @type string
* @enum text
*/
type: AiobservabilitytypesTextPartDTOType;
}
export enum AiobservabilitytypesToolCallRequestPartDTOType {
tool_call = 'tool_call',
}
export interface AiobservabilitytypesToolCallRequestPartDTO {
arguments?: unknown;
/**
* @type string,null
*/
id?: string | null;
/**
* @type string
*/
name: string;
/**
* @type string
* @enum tool_call
*/
type: AiobservabilitytypesToolCallRequestPartDTOType;
}
export enum AiobservabilitytypesToolCallResponsePartDTOType {
tool_call_response = 'tool_call_response',
}
export type AiobservabilitytypesToolCallResponsePartDTOResponse =
| unknown
| null;
export interface AiobservabilitytypesToolCallResponsePartDTO {
/**
* @type string,null
*/
id?: string | null;
/**
* @nullable true
*/
response: AiobservabilitytypesToolCallResponsePartDTOResponse;
/**
* @type string
* @enum tool_call_response
*/
type: AiobservabilitytypesToolCallResponsePartDTOType;
}
export interface AiobservabilitytypesGenericServerToolCallDTO {
[key: string]: unknown;
}
export enum AiobservabilitytypesServerToolCallPartDTOType {
server_tool_call = 'server_tool_call',
}
export interface AiobservabilitytypesServerToolCallPartDTO {
/**
* @type string,null
*/
id?: string | null;
/**
* @type string
*/
name: string;
server_tool_call: AiobservabilitytypesGenericServerToolCallDTO;
/**
* @type string
* @enum server_tool_call
*/
type: AiobservabilitytypesServerToolCallPartDTOType;
}
export interface AiobservabilitytypesGenericServerToolCallResponseDTO {
[key: string]: unknown;
}
export enum AiobservabilitytypesServerToolCallResponsePartDTOType {
server_tool_call_response = 'server_tool_call_response',
}
export interface AiobservabilitytypesServerToolCallResponsePartDTO {
/**
* @type string,null
*/
id?: string | null;
server_tool_call_response: AiobservabilitytypesGenericServerToolCallResponseDTO;
/**
* @type string
* @enum server_tool_call_response
*/
type: AiobservabilitytypesServerToolCallResponsePartDTOType;
}
export enum AiobservabilitytypesFilePartDTOType {
file = 'file',
}
export interface AiobservabilitytypesFilePartDTO {
/**
* @type string
*/
file_id: string;
/**
* @type string,null
*/
mime_type?: string | null;
/**
* @type string
*/
modality: string;
/**
* @type string
* @enum file
*/
type: AiobservabilitytypesFilePartDTOType;
}
export enum AiobservabilitytypesUriPartDTOType {
uri = 'uri',
}
export interface AiobservabilitytypesUriPartDTO {
/**
* @type string,null
*/
mime_type?: string | null;
/**
* @type string
*/
modality: string;
/**
* @type string
* @enum uri
*/
type: AiobservabilitytypesUriPartDTOType;
/**
* @type string
*/
uri: string;
}
export enum AiobservabilitytypesReasoningPartDTOType {
reasoning = 'reasoning',
}
export interface AiobservabilitytypesReasoningPartDTO {
/**
* @type string
*/
content: string;
/**
* @type string
* @enum reasoning
*/
type: AiobservabilitytypesReasoningPartDTOType;
}
export enum AiobservabilitytypesCompactionPartDTOType {
compaction = 'compaction',
}
export interface AiobservabilitytypesCompactionPartDTO {
/**
* @type string,null
*/
content?: string | null;
/**
* @type string,null
*/
id?: string | null;
/**
* @type string
* @enum compaction
*/
type: AiobservabilitytypesCompactionPartDTOType;
}
export interface AiobservabilitytypesGenericPartDTO {
[key: string]: unknown;
}
export type AiobservabilitytypesPartDTO =
| AiobservabilitytypesTextPartDTO
| AiobservabilitytypesToolCallRequestPartDTO
| AiobservabilitytypesToolCallResponsePartDTO
| AiobservabilitytypesServerToolCallPartDTO
| AiobservabilitytypesServerToolCallResponsePartDTO
| AiobservabilitytypesBlobPartDTO
| AiobservabilitytypesFilePartDTO
| AiobservabilitytypesUriPartDTO
| AiobservabilitytypesReasoningPartDTO
| AiobservabilitytypesCompactionPartDTO
| AiobservabilitytypesGenericPartDTO;
export type AiobservabilitytypesPartsDTO = AiobservabilitytypesPartDTO[];
export interface AiobservabilitytypesChatMessageDTO {
/**
* @type string,null
*/
name?: string | null;
parts: AiobservabilitytypesPartsDTO;
/**
* @type string
*/
role: string;
}
export type AiobservabilitytypesInputMessagesDTO =
AiobservabilitytypesChatMessageDTO[];
export interface AiobservabilitytypesOutputMessageDTO {
/**
* @type string,null
*/
finish_reason?: string | null;
/**
* @type string,null
*/
name?: string | null;
parts: AiobservabilitytypesPartsDTO;
/**
* @type string
*/
role: string;
}
export type AiobservabilitytypesOutputMessagesDTO =
AiobservabilitytypesOutputMessageDTO[];
export interface AlertmanagertypesChannelDTO {
/**
* @type string
@@ -4469,10 +4211,195 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
traces = 'traces',
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
*/
isLogScale?: boolean;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export enum DashboardtypesPrecisionOptionDTO {
NUMBER_0 = '0',
NUMBER_1 = '1',
NUMBER_2 = '2',
NUMBER_3 = '3',
NUMBER_4 = '4',
full = 'full',
}
export interface DashboardtypesPanelFormattingDTO {
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
/**
* @type string
*/
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
}
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type DashboardtypesLegendDTOCustomColors =
DashboardtypesLegendDTOCustomColorsAnyOf | null;
export interface DashboardtypesLegendDTO {
/**
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
last_15_min = 'last_15_min',
last_30_min = 'last_30_min',
last_1_hr = 'last_1_hr',
last_6_hr = 'last_6_hr',
last_1_day = 'last_1_day',
last_3_days = 'last_3_days',
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
/**
* @type boolean
*/
stackedBarChart?: boolean;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesBarChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBarChartVisualizationDTO;
}
export interface DashboardtypesBasicVisualizationDTO {
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface Querybuildertypesv5LogAggregationDTO {
/**
* @type string
*/
@@ -4711,274 +4638,13 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
unit?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
'' = '',
}
export interface DashboardtypesAIBuilderQuerySpecDTO {
/**
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
cursor?: string;
/**
* @type boolean
*/
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
*/
legend?: string;
/**
* @type integer
*/
limit?: number;
limitBy?: Querybuildertypesv5LimitByDTO;
/**
* @type string
*/
name?: string;
/**
* @type integer
*/
offset?: number;
/**
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type array,null
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
/**
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @enum traces
* @type string
*/
signal: DashboardtypesAIBuilderQuerySpecDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
*/
isLogScale?: boolean;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export enum DashboardtypesPrecisionOptionDTO {
NUMBER_0 = '0',
NUMBER_1 = '1',
NUMBER_2 = '2',
NUMBER_3 = '3',
NUMBER_4 = '4',
full = 'full',
}
export interface DashboardtypesPanelFormattingDTO {
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
/**
* @type string
*/
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
}
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type DashboardtypesLegendDTOCustomColors =
DashboardtypesLegendDTOCustomColorsAnyOf | null;
export interface DashboardtypesLegendDTO {
/**
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
last_15_min = 'last_15_min',
last_30_min = 'last_30_min',
last_1_hr = 'last_1_hr',
last_6_hr = 'last_6_hr',
last_1_day = 'last_1_day',
last_3_days = 'last_3_days',
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
/**
* @type boolean
*/
stackedBarChart?: boolean;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesBarChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBarChartVisualizationDTO;
}
export interface DashboardtypesBasicVisualizationDTO {
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface Querybuildertypesv5LogAggregationDTO {
/**
* @type string
*/
alias?: string;
/**
* @type string
*/
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
/**
* @type array,null
@@ -5169,6 +4835,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
stepInterval?: Querybuildertypesv5StepDTO;
}
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @type string
*/
alias?: string;
/**
* @type string
*/
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
@@ -5727,18 +5404,6 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind {
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
}
export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO {
/**
* @enum signoz/AIBuilderQuery
* @type string
*/
kind: DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind;
spec: DashboardtypesAIBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
}
@@ -6030,7 +5695,6 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
export type DashboardtypesQueryPluginDTO =
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
@@ -6709,7 +6373,6 @@ export interface DashboardtypesPostablePublicDashboardDTO {
export enum DashboardtypesQueryPluginKindDTO {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
'signoz/Formula' = 'signoz/Formula',
'signoz/PromQLQuery' = 'signoz/PromQLQuery',
@@ -11789,16 +11452,6 @@ export interface SpantypesThreadSpanDTO {
* @type array
*/
events: SpantypesEventDTO[];
formatted_input: AiobservabilitytypesInputMessagesDTO;
formatted_output: AiobservabilitytypesOutputMessagesDTO;
/**
* @type string
*/
formatter: string;
/**
* @type array
*/
formatter_warnings: string[];
/**
* @type boolean
*/

View File

@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
};
/**
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (

View File

@@ -104,6 +104,7 @@ function ListViewOrderBy({
return (
<Select
data-testid="list-view-order-by"
showSearch
value={value}
onChange={onChange}

View File

@@ -4,11 +4,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getBuilderVariant } from 'lib/query/queryMode';
import {
BuilderVariant,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
@@ -64,16 +60,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[config],
);
const isAIQuery =
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
useEffect(() => {
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
// AI queries are traces-only; BAR must not force metrics on them.
if (isAIQuery) {
handleSetConfig(newPanelType, DataSource.TRACES);
return;
}
if (newPanelType === PANEL_TYPES.BAR) {
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
return;
@@ -86,20 +74,15 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
initialDataSource,
currentDataSource,
newPanelType,
isAIQuery,
]);
useEffect(() => {
// always clear on mount and unmount to avoid stale data
// hosts opting into savePreviousQuery own the clear, as they may remount this builder mid-edit
if (savePreviousQuery) {
return undefined;
}
clearPreviousQuery();
return (): void => {
clearPreviousQuery();
};
}, [savePreviousQuery]);
}, []);
const resolvedConfig = useMemo(
() =>

View File

@@ -18,11 +18,13 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
index,
version,
panelType,
signalSource = '',
}: {
query: IBuilderQuery;
index: number;
version: string;
panelType: PANEL_TYPES | null;
signalSource: string;
}): JSX.Element {
const { setAggregationOptions } = useQueryBuilderV2Context();
const {
@@ -204,6 +206,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
/>
</div>
</div>
@@ -240,6 +243,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
signalSource={signalSource}
/>
</div>
</div>

View File

@@ -316,6 +316,7 @@ export const QueryV2 = forwardRef(function QueryV2(
index={index}
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
version="v4"
signalSource={signalSource as 'meter' | ''}
/>
)}

View File

@@ -240,7 +240,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
prefix={<RefreshCw size={14} />}
/>
</TooltipSimple>
{showFilterCollapse && handleFilterVisibilityChange && (
{showFilterCollapse && (
<TooltipSimple title="Collapse Filters">
<Button
variant="ghost"

View File

@@ -1,6 +1,5 @@
import { ComponentProps, ReactNode, useState } from 'react';
import cx from 'classnames';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
@@ -8,17 +7,13 @@ import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
import styles from './QuickFiltersLayout.module.scss';
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
type QuickFiltersElementProps = Omit<
JSX.LibraryManagedAttributes<
typeof QuickFilters,
ComponentProps<typeof QuickFilters>
>,
'handleFilterVisibilityChange'
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
typeof QuickFilters,
ComponentProps<typeof QuickFilters>
>;
// What the page configures; the layout adds the open / close wiring.
@@ -31,7 +26,6 @@ export interface QuickFiltersLayoutProps {
quickFilterProps?: QuickFiltersElementProps;
savedViewProps?: SavedViewsElementProps;
showFilters: boolean;
onToggleFilters?: () => void;
className?: string;
contentClassName?: string;
testId?: string;
@@ -42,7 +36,6 @@ function QuickFiltersLayout({
quickFilterProps,
savedViewProps,
showFilters,
onToggleFilters,
className,
contentClassName,
testId,
@@ -67,7 +60,6 @@ function QuickFiltersLayout({
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
/>
) : undefined;
@@ -79,32 +71,29 @@ function QuickFiltersLayout({
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
data-testid="quick-filters-layout-filters"
>
<TooltipProvider delayDuration={SIDEBAR_TOOLTIP_DELAY_MS}>
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
handleFilterVisibilityChange={onToggleFilters}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
/>
)}
</div>
</TooltipProvider>
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
/>
)}
</div>
</aside>
)}
<section

View File

@@ -1,4 +1,3 @@
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
@@ -36,28 +35,20 @@ jest.mock('../../QuickFilters', () => ({
default: ({
source,
savedViewsHeader,
handleFilterVisibilityChange,
}: {
source: string;
savedViewsHeader?: React.ReactNode;
handleFilterVisibilityChange?: () => void;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
{handleFilterVisibilityChange && (
<Button
aria-label="Collapse Filters"
data-testid="quick-filters-collapse"
onClick={handleFilterVisibilityChange}
/>
)}
</div>
),
}));
const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
@@ -242,54 +233,8 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(
screen.queryByTestId('saved-views-panel-header'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
it('collapses from the quick filters header when they are shown', async () => {
const onToggleFilters = jest.fn();
render(
<QuickFiltersLayout
showFilters
onToggleFilters={onToggleFilters}
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.queryByTestId('saved-views-collapse')).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId('quick-filters-collapse'));
expect(onToggleFilters).toHaveBeenCalledTimes(1);
});
it('collapses from the saved views header without quick filters', async () => {
const onToggleFilters = jest.fn();
render(
<QuickFiltersLayout
showFilters
onToggleFilters={onToggleFilters}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
await userEvent.click(screen.getByTestId('saved-views-collapse'));
expect(onToggleFilters).toHaveBeenCalledTimes(1);
});
it('has no collapse without a toggle', () => {
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.queryByTestId('saved-views-collapse')).not.toBeInTheDocument();
});
});
});

View File

@@ -1 +0,0 @@
export const SIDEBAR_TOOLTIP_DELAY_MS = 500;

View File

@@ -27,15 +27,18 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-5);
border-bottom: 1px solid var(--l1-border);
padding: 10.5px;
.qf-title {
display: flex;
align-items: center;
gap: var(--spacing-6);
color: var(--l2-foreground);
font-size: var(--font-size-sm);
gap: 12px;
}
.qf-header-icon {
width: 16px;
height: 16px;
cursor: pointer;
}
}
@@ -52,17 +55,11 @@
}
.search {
--input-height: var(--spacing-20);
--input-border-radius: 0;
--input-border-color: transparent;
--input-hover-border-color: transparent;
--input-focus-border-color: transparent;
--input-focus-outline-width: 0;
--input-background: var(--l1-background);
--input-hover-background: var(--l1-background);
--input-focus-background: var(--l1-background);
border-bottom: 1px solid var(--l1-border);
.ant-input {
background-color: var(--l1-background);
height: 46px;
border-radius: 0;
}
}
.qf-other-filters-list {
@@ -128,7 +125,6 @@
justify-content: space-between;
padding: 8px;
border-radius: 4px;
color: var(--l2-foreground);
user-select: none;
transition: background-color 0.2s ease-in-out;

View File

@@ -1,4 +1,3 @@
import { Button as SignozButton } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
@@ -44,17 +43,14 @@ function QuickFiltersSettings({
<>
<div className="qf-header">
<div className="qf-title">
<TableColumnsSplit size={16} />
<TableColumnsSplit width={16} height={16} />
Edit quick filters
</div>
<SignozButton
variant="ghost"
color="secondary"
size="icon"
aria-label="Close"
prefix={<X size={14} />}
<X
className="qf-header-icon"
width={16}
height={16}
onClick={handleSettingsClose}
data-testid="quick-filters-settings-close"
/>
</div>
<section className="search">

View File

@@ -52,7 +52,7 @@ export interface QuickFilterChangeEventData {
export interface IQuickFiltersProps {
config: IQuickFiltersConfig[];
handleFilterVisibilityChange?: () => void;
handleFilterVisibilityChange: () => void;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;

View File

@@ -15,6 +15,7 @@ import {
ListMinus,
ScrollText,
Settings,
TowerControl,
Workflow,
} from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
@@ -137,6 +138,16 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.LOGS_PIPELINES),
},
{
id: 'logs',
name: 'Go to Logs Views',
shortcut: [GlobalShortcutsName.NavigateToLogsViews],
keywords: 'logs views',
section: 'Logs',
icon: <TowerControl size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.LOGS_SAVE_VIEWS),
},
// metrics
{
@@ -159,6 +170,16 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_EXPLORER),
},
{
id: 'metrics-views',
name: 'Go to Metrics Views',
shortcut: [GlobalShortcutsName.NavigateToMetricsViews],
keywords: 'metrics views',
section: 'Metrics',
icon: <TowerControl size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_VIEWS),
},
// Traces
{

View File

@@ -10,14 +10,17 @@ export const GlobalShortcuts = {
// logs
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
// traces
NavigateToTraces: 'shift+t',
NavigateToTracesFunnel: 'shift+t+f',
NavigateToTracesViews: 'shift+t+v',
// metrics
NavigateToMetricsSummary: 'shift+m',
NavigateToMetricsExplorer: 'shift+m+e',
NavigateToMetricsViews: 'shift+m+v',
// settings
NavigateToSettings: 'shift+g',
@@ -39,8 +42,10 @@ export const GlobalShortcutsName = {
ToggleSidebar: 'shift+b',
NavigateToHome: 'shift+h',
NavigateToTracesFunnel: 'shift+t+f',
NavigateToTracesViews: 'shift+t+v',
NavigateToMetricsSummary: 'shift+m',
NavigateToMetricsExplorer: 'shift+m+e',
NavigateToMetricsViews: 'shift+m+v',
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
@@ -50,6 +55,7 @@ export const GlobalShortcutsName = {
NavigateToSettingsMembers: 'shift+g+m',
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
};
export const GlobalShortcutsDescription = {
@@ -63,14 +69,17 @@ export const GlobalShortcutsDescription = {
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
ToggleSidebar: 'Toggle sidebar visibility',
NavigateToTracesFunnel: 'Navigate to Traces Funnel',
NavigateToTracesViews: 'Navigate to Traces Views',
NavigateToMetricsSummary: 'Navigate to Metrics Summary',
NavigateToMetricsExplorer: 'Navigate to Metrics Explorer',
NavigateToMetricsViews: 'Navigate to Metrics Views',
NavigateToSettings: 'Navigate to Settings',
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
NavigateToSettingsBilling: 'Navigate to Billing Settings',
NavigateToSettingsNotificationChannels:
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToLogsViews: 'Navigate to Logs Views',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',
NavigateToSettingsMembers: 'Navigate to Members Settings',

View File

@@ -26,7 +26,9 @@ function Explorer(): JSX.Element {
className: 'qf-api-monitoring',
source: QuickFiltersSource.API_MONITORING,
signal: SignalType.API_MONITORING,
showFilterCollapse: false,
showQueryName: false,
handleFilterVisibilityChange: (): void => {},
useFieldApis: quickFilterFieldApis,
}}
>

View File

@@ -0,0 +1,73 @@
import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';
function ExplorerOrderBy({ query, onChange }: OrderByFilterProps): JSX.Element {
const {
debouncedSearchText,
selectedValue,
aggregationOptions,
generateOptions,
createOptions,
handleChange,
handleSearchKeys,
} = useOrderByFilter({ query, onChange });
const { data, isFetching } = useGetAggregateKeys(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText: debouncedSearchText,
},
{
keepPreviousData: true,
},
);
const options = useMemo(() => {
const keysOptions = createOptions(data?.payload?.attributeKeys || []);
const customOptions = createOptions([
{ key: 'timestamp', type: '', dataType: DataTypes.EMPTY },
]);
const baseOptions = [
...customOptions,
...(query.aggregateOperator === StringOperators.NOOP
? []
: aggregationOptions),
...keysOptions,
];
return generateOptions(baseOptions);
}, [
aggregationOptions,
createOptions,
data?.payload?.attributeKeys,
generateOptions,
query.aggregateOperator,
]);
return (
<Select
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}
showSearch
showArrow={false}
value={selectedValue}
labelInValue
options={options}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
/>
);
}
export default memo(ExplorerOrderBy);

View File

@@ -12,7 +12,6 @@ import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery'
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import Card from 'periscope/components/Card/Card';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
@@ -36,7 +35,6 @@ export default function SavedViews({
loadingUserPreferences: boolean;
}): JSX.Element {
const { user } = useAppContext();
const isSavedViewEnabled = useSavedViewEnabled();
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
const [selectedEntityViews, setSelectedEntityViews] = useState<
SavedviewtypesSavedViewDTO[]
@@ -353,7 +351,7 @@ export default function SavedViews({
{selectedEntityViews.length > 0 ? renderSavedViews() : emptyStateCard()}
</Card.Content>
{!isSavedViewEnabled && selectedEntityViews.length > 0 && (
{selectedEntityViews.length > 0 && (
<Card.Footer>
<div className="services-footer home-data-card-footer">
<Link to={footerLink}>

View File

@@ -190,12 +190,14 @@ function Explorer(): JSX.Element {
className="trace-explorer-page"
testId="llm-observability-explorer"
showFilters={isOpen}
onToggleFilters={(): void => setOpen(!isOpen)}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.AI_OBSERVABILITY,
signal: SignalType.AI_OBSERVABILITY,
useFieldApis: quickFiltersFieldApis,
handleFilterVisibilityChange: (): void => {
setOpen(!isOpen);
},
}}
>
<div className="trace-explorer">

View File

@@ -45,7 +45,6 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useUrlQueryData from 'hooks/useUrlQueryData';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { isEmpty, isUndefined } from 'lodash-es';
@@ -83,7 +82,6 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const isSavedViewEnabled = useSavedViewEnabled();
const getExportToDashboardLink = useGetExportToDashboardLink();
const [showFrequencyChart, setShowFrequencyChart] = useState(
@@ -518,15 +516,13 @@ function LogsExplorerViewsContainer({
<GoToTop />
{!isSavedViewEnabled && (
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
onExport={handleExport}
sourcepage={DataSource.LOGS}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
onExport={handleExport}
sourcepage={DataSource.LOGS}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -8,7 +9,13 @@ import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { fireEvent, render, RenderResult, waitFor } from 'tests/test-utils';
import {
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from 'tests/test-utils';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
@@ -396,5 +403,95 @@ describe('LogsExplorerViews -', () => {
}
});
});
it('rebuilds the List View query when the order changes', async () => {
(useCopyLogLink as jest.Mock).mockReturnValue({ activeLogId: undefined });
(useGetExplorerQueryRange as jest.Mock).mockReturnValue({
data: { payload: logsQueryRangeSuccessNewFormatResponse },
});
const stagedQueryOrderedBy = (
columnName: string,
id: string,
): Record<string, unknown> => ({
...mockQueryBuilderContextValue.stagedQuery,
id,
builder: {
...mockQueryBuilderContextValue.stagedQuery.builder,
queryData: mockQueryBuilderContextValue.stagedQuery.builder.queryData.map(
(item: Record<string, unknown>) => ({
...item,
orderBy: [{ columnName, order: 'desc' }],
}),
),
},
});
// `customRender` wraps a captured element, so `rerender` does not reach the
// tree. The order change is driven from state inside it instead.
function Harness(): JSX.Element {
const [ordered, setOrdered] = useState(false);
const stagedQuery = ordered
? stagedQueryOrderedBy('severity_text', 'query-2')
: stagedQueryOrderedBy('timestamp', 'query-1');
return (
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 100 }}
>
<PreferenceContextProvider>
<QueryBuilderContext.Provider
value={
{
...mockQueryBuilderContextValue,
panelType: PANEL_TYPES.LIST,
stagedQuery,
} as any
}
>
<button
type="button"
data-testid="change-order"
onClick={(): void => setOrdered(true)}
/>
<LogsExplorerViews
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
setWarning={(): void => {}}
showLiveLogs={false}
handleChangeSelectedView={(): void => {}}
/>
</QueryBuilderContext.Provider>
</PreferenceContextProvider>
</VirtuosoMockContext.Provider>
);
}
const latestListOrderBy = (): unknown => {
const calls = (useGetExplorerQueryRange as jest.Mock).mock.calls.filter(
(call) => call[1] === PANEL_TYPES.LIST && call[0],
);
return calls[calls.length - 1]?.[0]?.builder?.queryData?.[0]?.orderBy;
};
render(<Harness />);
await waitFor(() => {
expect(latestListOrderBy()).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
{ columnName: 'id', order: 'desc' },
]);
});
fireEvent.click(screen.getByTestId('change-order'));
await waitFor(() => {
expect(latestListOrderBy()).toStrictEqual([
{ columnName: 'severity_text', order: 'desc' },
{ columnName: 'id', order: 'desc' },
]);
});
});
});
});

View File

@@ -4,7 +4,6 @@ import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
@@ -18,7 +17,6 @@ import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashb
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -40,7 +38,6 @@ function Explorer(): JSX.Element {
currentQuery,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const isSavedViewEnabled = useSavedViewEnabled();
const getExportToDashboardLink = useGetExportToDashboardLink();
const queryClient = useQueryClient();
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
@@ -126,14 +123,15 @@ function Explorer(): JSX.Element {
<QuickFiltersLayout
className="meter-explorer-container"
showFilters={showQuickFilters}
onToggleFilters={(): void => setShowQuickFilters(!showQuickFilters)}
savedViewProps={{ source: SavedviewtypesSourceDTO.meter }}
quickFilterProps={{
className: 'qf-meter-explorer',
source: QuickFiltersSource.METER_EXPLORER,
signal: SignalType.METER_EXPLORER,
showFilterCollapse: true,
showQueryName: false,
handleFilterVisibilityChange: (): void => {
setShowQuickFilters(!showQuickFilters);
},
useFieldApis: quickFilterFieldApis,
}}
>
@@ -179,17 +177,15 @@ function Explorer(): JSX.Element {
/>
</div>
</div>
{!isSavedViewEnabled && (
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.METRICS}
signalSource="meter"
onExport={handleExport}
isOneChartPerQuery={false}
splitedQueries={splitedQueries}
/>
)}
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.METRICS}
signalSource="meter"
onExport={handleExport}
isOneChartPerQuery={false}
splitedQueries={splitedQueries}
/>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>

View File

@@ -3,10 +3,7 @@ import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Tooltip } from 'antd';
import { Filter } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -28,7 +25,6 @@ import {
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import {
@@ -68,12 +64,10 @@ function Explorer(): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const isSavedViewEnabled = useSavedViewEnabled();
const getExportToDashboardLink = useGetExportToDashboardLink();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const [showSidebar, setShowSidebar] = useState(true);
const queryClient = useQueryClient();
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
@@ -356,26 +350,12 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<QuickFiltersLayout
showFilters={showSidebar}
onToggleFilters={(): void => setShowSidebar(!showSidebar)}
showFilters
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
{!showSidebar && (
<TooltipSimple title="Show Views">
<Button
variant="outlined"
color="secondary"
size="icon"
aria-label="Show Views"
prefix={<Filter size={16} />}
onClick={(): void => setShowSidebar(true)}
data-testid="metrics-explorer-show-sidebar"
/>
</TooltipSimple>
)}
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
@@ -425,17 +405,15 @@ function Explorer(): JSX.Element {
</div>
</div>
</QuickFiltersLayout>
{!isSavedViewEnabled && (
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.METRICS}
onExport={handleExport}
isOneChartPerQuery={showOneChartPerQuery}
splitedQueries={splitedQueries}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.METRICS}
onExport={handleExport}
isOneChartPerQuery={showOneChartPerQuery}
splitedQueries={splitedQueries}
handleChangeSelectedView={handleChangeSelectedView}
/>
{isMetricDetailsOpen && selectedMetricName && (
<MetricDetails
metricName={selectedMetricName}

View File

@@ -1,5 +1,18 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
// Total, not Partial, so a signal added to the generated enum has to be mapped here
// before it compiles.
const SIGNAL_TO_DATA_SOURCE: Record<
TelemetrytypesSignalDTO,
DataSource | undefined
> = {
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
// The "unset" member: not a data source a query can be built against.
[TelemetrytypesSignalDTO['']]: undefined,
};
export function signalsToDataSources(
signals: readonly TelemetrytypesSignalDTO[],

View File

@@ -5,4 +5,5 @@ export type GroupByFilterProps = {
query: IBuilderQuery;
onChange: (values: BaseAutocompleteData[]) => void;
disabled: boolean;
signalSource?: string;
};

View File

@@ -6,37 +6,39 @@ import {
useMemo,
useState,
} from 'react';
import { useQueryClient } from 'react-query';
import { Select, Spin } from 'antd';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
// ** Constants
import { idDivider } from 'constants/queryBuilder';
import { idDivider, QueryBuilderKeys } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import useDebounce from 'hooks/useDebounce';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
// ** Components
// ** Helpers
import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
DATA_SOURCE_TO_SIGNAL,
QUERY_SOURCE_TO_FIELD_SOURCE,
} from 'types/common/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { getMetricNameForQuery } from '../utils';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
import { toAttributeKey } from './utils';
export const GroupByFilter = memo(function GroupByFilter({
query,
onChange,
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
SelectOption<string, ReactNode>[]
>([]);
const [localValues, setLocalValues] = useState<SelectOption<string, string>[]>(
[],
);
@@ -44,40 +46,74 @@ export const GroupByFilter = memo(function GroupByFilter({
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
const dataSource = useMemo(() => {
if (signalSource === 'meter') {
return 'meter' as DataSource;
}
return query.dataSource;
}, [signalSource, query.dataSource]);
const { isFetching } = useGetAggregateKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
metricName: getMetricNameForQuery(query),
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText: debouncedValue,
},
query.builderQueryType,
{ enabled: !disabled && isFocused, keepPreviousData: true },
{
queryKey: [debouncedValue, isFocused],
enabled: !disabled && isFocused,
onSuccess: (data) => {
const keys = query.groupBy.reduce<string[]>((acc, item) => {
acc.push(item.key);
return acc;
}, []);
const filteredOptions: BaseAutocompleteData[] =
data?.payload?.attributeKeys?.filter(
(attrKey) => !keys.includes(attrKey.key),
) || [];
const options: SelectOption<string, ReactNode>[] =
filteredOptions.map((item) => ({
label: (
<OptionRenderer
key={item.key}
label={item.key}
value={item.key}
dataType={item.dataType || ''}
type={item.type || ''}
/>
),
value: `${item.id}`,
})) || [];
setOptionsData(options);
},
},
);
const attributeKeys = useMemo(
() => (fieldKeys ?? []).map(toAttributeKey),
[fieldKeys],
);
const getAttributeKeys = useCallback(async () => {
const response = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, searchText, isFocused],
async () =>
getAggregateKeys({
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText,
}),
);
const optionsData = useMemo((): SelectOption<string, ReactNode>[] => {
const selectedKeys = new Set(query.groupBy.map((item) => item.key));
return attributeKeys
.filter((item) => !selectedKeys.has(item.key))
.map((item) => ({
label: (
<OptionRenderer
key={item.key}
label={item.key}
value={item.key}
dataType={item.dataType || ''}
type={item.type || ''}
/>
),
value: `${item.id}`,
}));
}, [attributeKeys, query.groupBy]);
return response.payload?.attributeKeys || [];
}, [
isFocused,
query.aggregateAttribute?.key,
query.aggregateOperator,
dataSource,
queryClient,
searchText,
]);
const handleSearchKeys = (searchText: string): void => {
setSearchText(searchText);
@@ -93,13 +129,15 @@ export const GroupByFilter = memo(function GroupByFilter({
};
const handleChange = useCallback(
(values: SelectOption<string, string>[]): void => {
async (values: SelectOption<string, string>[]): Promise<void> => {
const keys = await getAttributeKeys();
const groupByValues: BaseAutocompleteData[] = values.map((item) => {
const id = item.value;
const currentValue = item.value.split(idDivider)[0];
if (id && id.includes(idDivider)) {
const attribute = attributeKeys.find((item) => item.id === id);
const attribute = keys.find((item) => item.id === id);
const existAttribute = query.groupBy.find((item) => item.id === id);
if (attribute) {
@@ -111,14 +149,14 @@ export const GroupByFilter = memo(function GroupByFilter({
}
}
return chooseAutocompleteFromCustomValue(attributeKeys, currentValue);
return chooseAutocompleteFromCustomValue(keys, currentValue);
});
const result = uniqWith(groupByValues, isEqual);
onChange(result);
},
[attributeKeys, onChange, query.groupBy],
[getAttributeKeys, onChange, query.groupBy],
);
const clearSearch = useCallback(() => {

View File

@@ -9,140 +9,137 @@ import { IBuilderQuery } from '../../../../../types/api/queryBuilder/queryBuilde
import { DataSource } from '../../../../../types/common/queryBuilder';
import { GroupByFilter } from '../GroupByFilter';
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
const BASE_URL = ENVIRONMENT.baseURL;
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
type FieldKeyVariant = {
name: string;
fieldContext: string;
fieldDataType?: string;
};
const fieldKeysBody = (
variants: FieldKeyVariant[],
): Record<string, unknown> => ({
status: 'success',
data: {
complete: true,
keys: variants.reduce<Record<string, FieldKeyVariant[]>>(
(acc, variant) => ({
...acc,
[variant.name]: [...(acc[variant.name] ?? []), variant],
}),
{},
),
},
});
const groupByInput = (): HTMLInputElement =>
screen.getByTestId('group-by').querySelector('input') as HTMLInputElement;
function setup(queryOverrides?: Partial<IBuilderQuery>): {
onChange: jest.Mock;
} {
function setup(
overrides?: Partial<React.ComponentProps<typeof GroupByFilter>>,
): { onChange: jest.Mock } {
const onChange = jest.fn();
const query = {
dataSource: DataSource.METRICS,
aggregateAttribute: { key: 'signoz_calls_total' },
aggregateAttribute: { key: 'service.name' },
aggregateOperator: 'COUNT',
groupBy: [],
...queryOverrides,
} as unknown as IBuilderQuery;
render(<GroupByFilter query={query} onChange={onChange} disabled={false} />);
render(
<GroupByFilter
query={query}
onChange={onChange}
disabled={false}
signalSource="meter"
{...overrides}
/>,
);
return { onChange };
}
// wait for dropdown and select the option instead of hitting Enter
async function clickOption(label: string): Promise<void> {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const option = await screen.findByText(
(content, el) =>
content.trim() === label && !!el?.closest('.ant-select-item-option'),
);
await user.click(option);
}
describe('GroupByFilter', () => {
const requests: URLSearchParams[] = [];
const dataSourceCalls: string[] = [];
let callCount = 0;
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
requests.length = 0;
dataSourceCalls.length = 0;
callCount = 0;
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
function useFieldKeys(variants: FieldKeyVariant[]): void {
it('uses meter datasource for suggestions and normalization', async () => {
server.use(
rest.get(fieldKeysURL, (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(fieldKeysBody(variants)));
rest.get(attributeKeysURL, (req, res, ctx) => {
const ds = req.url.searchParams.get('dataSource') || '';
dataSourceCalls.push(ds);
callCount += 1;
const attributeKeys =
callCount === 1
? [
{
id: 'service.name--string--',
key: 'service.name',
dataType: 'string',
type: '',
},
]
: [];
return res(
ctx.status(200),
ctx.json({
data: { attributeKeys },
}),
);
}),
);
}
it('asks the fields API for meter keys and emits a typed custom key', async () => {
useFieldKeys([]);
const { onChange } = setup({ source: 'meter' });
const { onChange } = setup();
fireEvent.focus(screen.getByTestId('group-by'));
const select = screen.getByTestId('group-by');
fireEvent.focus(select);
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
expect(requests[0].get('signal')).toBe('metrics');
expect(requests[0].get('source')).toBe('meter');
expect(requests[0].get('metricName')).toBe('signoz_calls_total');
await waitFor(() => expect(dataSourceCalls.length).toBeGreaterThanOrEqual(1));
expect(dataSourceCalls[0]).toBe('meter');
const input = select.querySelector('input') as HTMLInputElement;
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.type(groupByInput(), 'custom.attr');
await clickOption('custom.attr');
await user.type(input, 'custom.attr');
// wait for dropdown and select the option instead of hitting Enter
await screen.findByRole('listbox');
const option = await screen.findByRole('option', { name: 'custom.attr' });
await user.click(option);
expect(onChange).toHaveBeenCalled();
expect(dataSourceCalls[dataSourceCalls.length - 1]).toBe('meter');
const emitted = onChange.mock.calls[0][0][0];
expect(emitted.key).toBe('custom.attr');
expect(emitted.id).toBeTruthy();
expect(emitted.id).not.toBe('----');
});
it('emits the picked suggestion with its context and data type', async () => {
useFieldKeys([
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
]);
const { onChange } = setup();
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(groupByInput());
await clickOption('service.name');
await waitFor(() => expect(onChange).toHaveBeenCalled());
// Expect payload to be resolved to the exact attribute returned by MSW
expect(onChange.mock.calls[0][0][0]).toMatchObject({
key: 'service.name',
type: 'resource',
dataType: 'string',
id: 'service.name--string--resource',
});
});
it('offers each context of a name as its own option', async () => {
useFieldKeys([
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
{ name: 'service.name', fieldContext: 'attribute', fieldDataType: 'string' },
]);
setup();
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(groupByInput());
await waitFor(() =>
expect(
screen
.getAllByText('service.name')
.filter((el) => !!el.closest('.ant-select-item-option')),
).toHaveLength(2),
it('clicks suggested option and emits proper value', async () => {
server.use(
rest.get(attributeKeysURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
attributeKeys: [
{
id: 'service.name--string--',
key: 'service.name',
dataType: 'string',
type: '',
},
],
},
}),
),
),
);
const { onChange } = setup();
const combo = screen.getByRole('combobox');
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(combo);
await screen.findByRole('listbox');
const option = await screen.findByText(
(content, el) =>
content.trim() === 'service.name' &&
!!el?.closest('.ant-select-item-option'),
);
await user.click(option);
// Expect payload to be resolved to the exact attribute returned by MSW
await waitFor(() => expect(onChange).toHaveBeenCalled());
const emitted = onChange.mock.calls[0][0][0];
expect(emitted.key).toBe('service.name');
expect(emitted.id).toBe('service.name--string--');
});
});

View File

@@ -1,24 +0,0 @@
import {
baseAutoCompleteIdKeysOrder,
initialAutocompleteData,
} from 'constants/queryBuilder';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
export const toAttributeKey = (
key: TelemetryFieldKey,
): BaseAutocompleteData => {
const fields = {
key: key.name,
dataType: fieldDataTypeToDataType(key.fieldDataType),
type: key.fieldContext ?? '',
};
return {
...initialAutocompleteData,
...fields,
id: createIdFromObjectFields(fields, baseAutoCompleteIdKeysOrder),
};
};

View File

@@ -1,17 +1,11 @@
import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
DATA_SOURCE_TO_SIGNAL,
DataSource,
MetricAggregateOperator,
QUERY_SOURCE_TO_FIELD_SOURCE,
} from 'types/common/queryBuilder';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { getMetricNameForQuery } from '../utils';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';
@@ -33,14 +27,13 @@ export function OrderByFilter({
handleSearchKeys,
} = useOrderByFilter({ query, onChange, entityVersion });
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
const { data, isFetching } = useGetAggregateKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
metricName: getMetricNameForQuery(query),
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText: debouncedSearchText,
},
query.builderQueryType,
{
enabled: !!query.aggregateAttribute?.key || isRawQuery,
keepPreviousData: true,
@@ -54,11 +47,9 @@ export function OrderByFilter({
);
const optionsData = useMemo(() => {
const keyOptions = createOptions((fieldKeys ?? []).map((key) => key.name));
const groupByOptions = createOptions(query.groupBy.map((item) => item.key));
const aggregationOptionsFromParsed = createOptions(
parsedAggregationOptions.map((item) => item.key),
);
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
const groupByOptions = createOptions(query.groupBy);
const aggregationOptionsFromParsed = createOptions(parsedAggregationOptions);
const options =
query.aggregateOperator === MetricAggregateOperator.NOOP
@@ -71,7 +62,7 @@ export function OrderByFilter({
return generateOptions(options);
}, [
createOptions,
fieldKeys,
data?.payload?.attributeKeys,
query.groupBy,
query.aggregateOperator,
parsedAggregationOptions,
@@ -101,7 +92,6 @@ export function OrderByFilter({
options={optionsData}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
data-testid="order-by"
/>
);
}

View File

@@ -1,85 +0,0 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilter } from '../OrderByFilter';
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
const fieldKeysBody = (names: string[]): Record<string, unknown> => ({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [name, [{ name, fieldContext: 'resource' }]]),
),
},
});
const orderByInput = (): HTMLInputElement =>
screen.getByTestId('order-by').querySelector('input') as HTMLInputElement;
function setup(query: Partial<IBuilderQuery>): { onChange: jest.Mock } {
const onChange = jest.fn();
render(
<OrderByFilter
query={
{
dataSource: DataSource.LOGS,
aggregateOperator: 'noop',
aggregateAttribute: { key: '' },
groupBy: [],
orderBy: [],
aggregations: [{ expression: 'count()' }],
...query,
} as unknown as IBuilderQuery
}
onChange={onChange}
isRawQuery
isNewQueryV2
/>,
);
return { onChange };
}
describe('OrderByFilter', () => {
const requests: URLSearchParams[] = [];
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
requests.length = 0;
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
it('offers fields API keys in both directions for a raw query', async () => {
server.use(
rest.get(fieldKeysURL, (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(ctx.status(200), ctx.json(fieldKeysBody(['service.name'])));
}),
);
const { onChange } = setup({});
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(orderByInput());
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
expect(requests[0].get('signal')).toBe('logs');
await screen.findByText('service.name desc');
await user.click(await screen.findByText('service.name asc'));
expect(onChange).toHaveBeenCalledWith([
{ columnName: 'service.name', order: 'asc' },
]);
});
});

View File

@@ -4,6 +4,7 @@ import useDebounce from 'hooks/useDebounce';
import { IOption } from 'hooks/useResourceAttribute/types';
import { isEqual, uniqWith } from 'lodash-es';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
@@ -24,7 +25,7 @@ export type UseOrderByFilterResult = {
selectedValue: IOption[];
aggregationOptions: IOption[];
generateOptions: (options: IOption[]) => IOption[];
createOptions: (keys: string[]) => IOption[];
createOptions: (data: BaseAutocompleteData[]) => IOption[];
handleChange: (values: IOption[]) => void;
handleSearchKeys: (search: string) => void;
};
@@ -120,7 +121,7 @@ export const useOrderByFilter = ({
};
const createOptions = useCallback(
(keys: string[]): IOption[] => mapLabelValuePairs(keys).flat(),
(data: BaseAutocompleteData[]): IOption[] => mapLabelValuePairs(data).flat(),
[],
);

View File

@@ -1,5 +1,6 @@
import { IOption } from 'hooks/useResourceAttribute/types';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
OrderByPayload,
@@ -33,8 +34,11 @@ export const transformToOrderByStringValues = (
return prepareSelectedValue;
};
export function mapLabelValuePairs(keys: string[]): Array<IOption>[] {
return keys.map((value) => {
export function mapLabelValuePairs(
arr: BaseAutocompleteData[],
): Array<IOption>[] {
return arr.map((item) => {
const value = item.key;
return [
{
label: `${value} ${ORDERBY_FILTERS.ASC}`,

View File

@@ -3,12 +3,7 @@ import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
import uniqWith from 'lodash-es/unionWith';
import { parse } from 'papaparse';
import {
HavingForm,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { ORDERBY_FILTERS } from './OrderByFilter/config';
import {
@@ -116,17 +111,3 @@ export const filterByOperatorConfig = (
OPERATORS_CONFIG[key].includes(option.label),
);
};
export const getMetricNameForQuery = (
query: IBuilderQuery,
): string | undefined => {
if (query.dataSource !== DataSource.METRICS) {
return undefined;
}
return (
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
undefined
);
};

View File

@@ -1,55 +0,0 @@
import { Check, Trash2, X } from '@signozhq/icons';
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { DeleteSavedViewDialogProps } from './types';
function DeleteSavedViewDialog({
isDeleting,
onCancel,
onConfirm,
}: DeleteSavedViewDialogProps): JSX.Element {
return (
<AlertDialog
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Delete this view"
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="delete-saved-view-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={onConfirm}
prefix={<Check size={12} />}
testId="delete-saved-view-confirm"
>
Delete view
</Button>
</>
}
>
<Typography.Text>
Deleting this view is irreversible and cannot be undone.
</Typography.Text>
</AlertDialog>
);
}
export default DeleteSavedViewDialog;

View File

@@ -14,12 +14,11 @@ const NAME_INPUT_ID = 'save-view-name';
function SaveViewModal({
mode,
initialName = '',
isSaving,
onClose,
onSave,
}: SaveViewModalProps): JSX.Element {
const [name, setName] = useState(initialName);
const [name, setName] = useState('');
const displayName = name.trim();
const handleSave = async (): Promise<void> => {

View File

@@ -1,32 +0,0 @@
import { useMemo } from 'react';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { highlightQueryExpression } from './utils/highlightQueryExpression';
function SavedViewHighlightedExpression({
expression,
}: {
expression: string;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const parts = useMemo(
() => highlightQueryExpression(expression, isDarkMode),
[expression, isDarkMode],
);
return (
<>
{parts.map(({ from, text, color }) =>
color ? (
<span key={from} style={{ color }}>
{text}
</span>
) : (
text
),
)}
</>
);
}
export default SavedViewHighlightedExpression;

View File

@@ -1,110 +0,0 @@
.anchor {
position: absolute;
left: 100%;
width: 1px;
pointer-events: none;
}
.card {
--popover-padding: 0;
--popover-background: var(--l1-background);
--popover-border-color: var(--l2-border);
--popover-border-radius: var(--radius-2);
--popover-foreground: var(--l1-foreground);
width: 324px;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-6) var(--spacing-8);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
}
.name {
min-width: 0;
color: var(--l1-foreground);
font-size: var(--periscope-font-size-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.query {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--radius-2);
background: var(--l2-background);
}
.queryItem {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.queryName {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-small);
}
.metric {
color: var(--l2-foreground);
font-family: var(--periscope-font-family-mono);
font-size: var(--font-size-xs);
word-break: break-all;
}
.expression {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
color: var(--l1-foreground);
font-family: var(--periscope-font-family-mono);
font-size: var(--font-size-xs);
word-break: break-all;
}
.noFilters {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.updated {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.sectionTitle {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-18);
letter-spacing: 0.88px;
text-transform: uppercase;
}
.meta {
display: flex;
align-items: center;
gap: var(--spacing-4);
color: var(--l2-foreground);
}
.metaText {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,130 +0,0 @@
import { CalendarClock } from '@signozhq/icons';
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { useTimezone } from 'providers/Timezone';
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
import { SavedViewHoverCardProps } from './types';
import styles from './SavedViewHoverCard.module.scss';
function SavedViewHoverCard({
target,
onCardEnter,
onCardLeave,
onClose,
}: SavedViewHoverCardProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const view = target?.view;
const querySummaries = useMemo(
() => (view ? getSavedViewQuerySummaries(view) : []),
[view],
);
const hasQueryNames = querySummaries.length > 1;
const copyValue = querySummaries
.filter(({ expression }) => expression.length > 0)
.map(({ queryName, expression }) =>
hasQueryNames ? `${queryName}: ${expression}` : expression,
)
.join('\n');
return (
<Popover
open={!!view}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
>
<PopoverAnchor asChild>
<div
className={styles.anchor}
style={{ top: target?.anchorTop ?? 0, height: target?.anchorHeight ?? 0 }}
/>
</PopoverAnchor>
<PopoverContent
side="right"
align="start"
sideOffset={4}
className={styles.card}
onOpenAutoFocus={(event): void => event.preventDefault()}
onCloseAutoFocus={(event): void => event.preventDefault()}
>
{view && (
<div
className={styles.content}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
data-testid="saved-view-hover-card"
>
<div className={styles.header}>
<Typography.Text className={styles.name}>
{view.spec.displayName}
</Typography.Text>
{copyValue && (
<CopyButton
value={copyValue}
ariaLabel="Copy query"
testId="saved-view-hover-card-copy"
/>
)}
</div>
<div className={styles.query} data-testid="saved-view-hover-card-query">
{querySummaries.length === 0 ? (
<Typography.Text className={styles.noFilters}>
No filters
</Typography.Text>
) : (
querySummaries.map(({ queryName, expression, metric }) => (
<div key={queryName} className={styles.queryItem}>
{hasQueryNames && (
<Typography.Text className={styles.queryName}>
{queryName}
</Typography.Text>
)}
{metric && (
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
)}
{expression && (
<Typography.Text className={styles.expression}>
<SavedViewHighlightedExpression expression={expression} />
</Typography.Text>
)}
</div>
))
)}
</div>
<div className={styles.updated}>
<Typography.Text className={styles.sectionTitle}>
Last updated
</Typography.Text>
{view.updatedBy && (
<Typography.Text className={styles.metaText}>
{view.updatedBy}
</Typography.Text>
)}
{view.updatedAt && (
<div className={styles.meta}>
<CalendarClock size={14} />
<Typography.Text className={styles.metaText}>
{formatTimezoneAdjustedTimestamp(
view.updatedAt,
SAVED_VIEW_UPDATED_AT_FORMAT,
)}
</Typography.Text>
</div>
)}
</div>
</div>
)}
</PopoverContent>
</Popover>
);
}
export default SavedViewHoverCard;

View File

@@ -1,56 +0,0 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import SaveViewModal from './SaveViewModal';
import { SavedViewsEmptyStateProps } from './types';
import styles from './SavedViewsList.module.scss';
function SavedViewsEmptyState({
isSearching,
isSaving,
onSave,
}: SavedViewsEmptyStateProps): JSX.Element {
const [isModalOpen, setIsModalOpen] = useState(false);
if (isSearching) {
return (
<div className={styles.state} data-testid="saved-views-list-no-results">
<Typography.Text className={styles.stateText}>
No views match your search
</Typography.Text>
</div>
);
}
return (
<div className={styles.state} data-testid="saved-views-list-empty">
<Typography.Text className={styles.stateTitle}>No views yet</Typography.Text>
<Typography.Text className={styles.stateText}>
Save the current view to open it later.
</Typography.Text>
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => setIsModalOpen(true)}
data-testid="saved-views-list-create"
>
Create view
</Button>
{isModalOpen && (
<SaveViewModal
mode="create"
isSaving={isSaving}
onClose={(): void => setIsModalOpen(false)}
onSave={onSave}
/>
)}
</div>
);
}
export default SavedViewsEmptyState;

View File

@@ -15,22 +15,6 @@
transparent
);
}
&.isError {
background: linear-gradient(
to right,
var(--callout-error-background),
transparent
);
}
}
.viewTitle {
display: flex;
flex: 1;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.name {
@@ -48,11 +32,6 @@
background: var(--callout-warning-background);
color: var(--callout-warning-title);
}
&.isError {
background: var(--callout-error-background);
color: var(--callout-error-title);
}
}
.nameText {
@@ -80,7 +59,3 @@
flex-shrink: 0;
gap: var(--spacing-1);
}
.collapseIcon {
rotate: 270deg;
}

View File

@@ -1,18 +1,13 @@
import { useCallback, useState } from 'react';
import cx from 'classnames';
import { ArrowUpToLine, List, Plus, Undo2, X } from '@signozhq/icons';
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
import { Skeleton } from '@signozhq/ui/skeleton';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
MY_VIEW_NAME,
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
@@ -24,21 +19,31 @@ import styles from './SavedViewsHeader.module.scss';
function SavedViewsHeader({
source,
onOpenViews,
onCollapse,
}: {
source: SavedviewtypesSourceDTO;
// Absent while the list is on screen.
onOpenViews?: () => void;
onCollapse?: () => void;
}): JSX.Element {
const { view, isLoading, isError, hasUnsavedChanges } =
useActiveSavedView(source);
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useSaveNewView(createView);
const handleSave = useCallback(
async (displayName: string): Promise<boolean> => {
const id = await createView(displayName);
if (!id) {
return false;
}
toast.success('You have created a new view.', {
position: SAVED_VIEW_TOAST_POSITION,
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
});
return true;
},
[createView, onOpenViews],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {
@@ -49,51 +54,33 @@ function SavedViewsHeader({
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
const closeModal = useCallback((): void => setModalMode(null), []);
const name = isError
? SAVED_VIEW_LOAD_FAILED_NAME
: (view?.spec.displayName ?? MY_VIEW_NAME);
const isDirty = !isError && !!view && hasUnsavedChanges;
const name = view?.spec.displayName ?? MY_VIEW_NAME;
const isDirty = !!view && hasUnsavedChanges;
return (
<div
className={cx(styles.header, {
[styles.isDirty]: isDirty,
[styles.isError]: isError,
})}
className={cx(styles.header, { [styles.isDirty]: isDirty })}
data-testid="saved-views-header"
data-source={source}
>
<div className={styles.viewTitle}>
{!isDirty && onOpenViews && (
<SavedViewsIconButton
title="All views"
icon={<List size={14} />}
onClick={onOpenViews}
testId="saved-views-open"
/>
)}
{isLoading ? (
<Skeleton.Input
active
size="small"
className={styles.skeleton}
testId="saved-views-loading"
/>
) : (
<TooltipSimple title={name}>
<span
className={cx(styles.name, {
[styles.isDirty]: isDirty,
[styles.isError]: isError,
})}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</TooltipSimple>
)}
</div>
{isLoading ? (
<Skeleton.Input
active
size="small"
className={styles.skeleton}
testId="saved-views-loading"
/>
) : (
<TooltipSimple title={name}>
<span
className={cx(styles.name, { [styles.isDirty]: isDirty })}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</TooltipSimple>
)}
<div className={styles.actions}>
{!view && (
@@ -105,7 +92,7 @@ function SavedViewsHeader({
testId="saved-views-create"
/>
)}
{(isError || (view && !isDirty)) && (
{view && !isDirty && (
<SavedViewsIconButton
title="Clear view"
icon={<Undo2 size={14} />}
@@ -132,12 +119,12 @@ function SavedViewsHeader({
/>
</>
)}
{onCollapse && (
{!isDirty && onOpenViews && (
<SavedViewsIconButton
title="Collapse Filters"
icon={<ArrowUpToLine size={14} className={styles.collapseIcon} />}
onClick={onCollapse}
testId="saved-views-collapse"
title="All views"
icon={<Menu size={14} />}
onClick={onOpenViews}
testId="saved-views-open"
/>
)}
</div>

View File

@@ -1,132 +0,0 @@
.list {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.section {
display: flex;
flex-direction: column;
min-height: 0;
margin-bottom: var(--spacing-6);
&:first-child:not(:last-child) {
flex-shrink: 0;
max-height: 40%;
}
&:last-child {
flex: 1;
}
}
.sectionList {
flex: 1;
min-height: 0;
overflow: hidden;
}
.sectionTitle {
padding: var(--spacing-4) var(--spacing-6);
color: var(--muted-foreground);
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-18);
letter-spacing: 0.88px;
text-transform: uppercase;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-2);
height: var(--spacing-16);
padding-right: var(--spacing-4);
border-radius: var(--radius-2);
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: var(--l2-background);
}
&.isActive {
background-color: var(--l3-background);
.rowName {
--button-variant-ghost-color: var(--l1-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
}
}
}
.rowMenu {
display: flex;
width: 0;
overflow: hidden;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.row:hover .rowMenu,
.row:focus-within .rowMenu,
.rowMenu:has([data-state='open']) {
width: auto;
opacity: 1;
}
.rowButton {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--spacing-12);
height: var(--spacing-12);
padding: var(--spacing-1);
}
.rowName {
--button-height: 100%;
--button-padding: 0 var(--spacing-6);
--button-justify-content: flex-start;
--button-font-size: var(--periscope-font-size-base);
--button-variant-ghost-color: var(--l2-foreground);
--button-variant-ghost-hover-color: var(--l2-foreground);
--button-variant-ghost-hover-background-color: transparent;
flex: 1;
min-width: 0;
}
.rowNameText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-6);
}
.state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-8) var(--spacing-6);
text-align: center;
}
.stateTitle {
color: var(--l1-foreground);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-medium);
}
.stateText {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,200 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Skeleton } from '@signozhq/ui/skeleton';
import { Typography } from '@signozhq/ui/typography';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewsList } from './hooks/useSavedViewsList';
import SavedViewHoverCard from './SavedViewHoverCard';
import SavedViewsEmptyState from './SavedViewsEmptyState';
import SavedViewsListSection from './SavedViewsListSection';
import SaveViewModal from './SaveViewModal';
import {
PendingSavedViewAction,
SavedViewRowActionKey,
SavedViewsListRowHoverProps,
} from './types';
import styles from './SavedViewsList.module.scss';
function SavedViewsList({
source,
search,
}: {
source: SavedviewtypesSourceDTO;
search: string;
}): JSX.Element {
const { viewKey } = useGetSavedViewParams();
const { selectView, clearView, createView, isSaving } =
useSavedViewActions(source);
const saveNewView = useSaveNewView(createView);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
useSavedViewsList(source, search);
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
useSavedViewRowActions(source);
const [pendingAction, setPendingAction] =
useState<PendingSavedViewAction | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const hoverCard = useSavedViewHoverCard();
const {
onRowEnter: onHoverCardRowEnter,
onRowLeave: onHoverCardRowLeave,
close: closeHoverCard,
} = hoverCard;
useEffect(() => {
closeHoverCard();
}, [search, closeHoverCard]);
const rowHover = useMemo<SavedViewsListRowHoverProps>(
() => ({
onRowEnter: (view, row): void => {
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
const rowRect = row.getBoundingClientRect();
onHoverCardRowEnter({
view,
anchorTop: rowRect.top - listTop,
anchorHeight: rowRect.height,
});
},
onRowLeave: onHoverCardRowLeave,
onMenuOpen: closeHoverCard,
}),
[onHoverCardRowEnter, onHoverCardRowLeave, closeHoverCard],
);
const handleAction = useCallback(
(view: SavedviewtypesSavedViewDTO, key: SavedViewRowActionKey): void => {
if (key === 'copyLink') {
copyViewLink(view);
return;
}
closeHoverCard();
setPendingAction({ key, view });
},
[copyViewLink, closeHoverCard],
);
const closePendingAction = useCallback((): void => setPendingAction(null), []);
const handleDelete = useCallback(async (): Promise<void> => {
if (!pendingAction || !(await deleteView(pendingAction.view))) {
return;
}
if (pendingAction.view.id === viewKey) {
clearView();
}
setPendingAction(null);
}, [pendingAction, deleteView, viewKey, clearView]);
if (isLoading) {
return (
<div className={styles.loading} data-testid="saved-views-list-loading">
{Array.from({ length: SAVED_VIEWS_LIST_LOADING_ROWS }, (_, index) => (
<Skeleton.Input key={index} active size="small" block />
))}
</div>
);
}
if (isError) {
return (
<div className={styles.state} data-testid="saved-views-list-error">
<Typography.Text className={styles.stateText}>
Could not load views
</Typography.Text>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={refetch}
data-testid="saved-views-list-retry"
>
Retry
</Button>
</div>
);
}
if (createdByMe.length === 0 && createdByOthers.length === 0) {
return (
<SavedViewsEmptyState
isSearching={search.trim().length > 0}
isSaving={isSaving}
onSave={saveNewView}
/>
);
}
return (
<div
ref={listRef}
className={styles.list}
onScrollCapture={closeHoverCard}
data-testid="saved-views-list"
>
{createdByMe.length > 0 && (
<SavedViewsListSection
title="Created by me"
views={createdByMe}
activeViewId={viewKey}
onSelect={selectView}
onClear={clearView}
onAction={handleAction}
hover={rowHover}
testId="saved-views-created-by-me"
/>
)}
{createdByOthers.length > 0 && (
<SavedViewsListSection
title="Created by others"
views={createdByOthers}
activeViewId={viewKey}
onSelect={selectView}
onClear={clearView}
onAction={handleAction}
hover={rowHover}
testId="saved-views-created-by-others"
/>
)}
<SavedViewHoverCard
target={hoverCard.target}
onCardEnter={hoverCard.onCardEnter}
onCardLeave={hoverCard.onCardLeave}
onClose={closeHoverCard}
/>
{pendingAction?.key === 'editDetails' && (
<SaveViewModal
mode="edit"
initialName={pendingAction.view.spec.displayName}
isSaving={isRenaming}
onClose={closePendingAction}
onSave={(displayName): Promise<boolean> =>
renameView(pendingAction.view, displayName)
}
/>
)}
{pendingAction?.key === 'delete' && (
<DeleteSavedViewDialog
isDeleting={isDeleting}
onCancel={closePendingAction}
onConfirm={(): void => {
void handleDelete();
}}
/>
)}
</div>
);
}
export default SavedViewsList;

View File

@@ -1,61 +0,0 @@
import cx from 'classnames';
import { Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import SavedViewsRowButton from './SavedViewsRowButton';
import SavedViewsRowMenu from './SavedViewsRowMenu';
import { SavedViewsListRowProps } from './types';
import styles from './SavedViewsList.module.scss';
function SavedViewsListRow({
view,
isActive,
onSelect,
onClear,
onAction,
hover,
}: SavedViewsListRowProps): JSX.Element {
const name = view.spec.displayName;
return (
<div
className={cx(styles.row, { [styles.isActive]: isActive })}
data-testid="saved-views-row"
data-view-id={view.id}
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
onMouseLeave={hover.onRowLeave}
>
<Button
variant="ghost"
color="secondary"
className={styles.rowName}
onClick={(): void => {
if (!isActive) {
onSelect(view);
}
}}
data-testid="saved-views-row-name"
>
<span className={styles.rowNameText}>{name}</span>
</Button>
{isActive && (
<SavedViewsRowButton
icon={<Undo2 size={14} />}
label="Clear view"
tooltip="Clear view"
onClick={onClear}
testId="saved-views-row-clear"
/>
)}
<span className={styles.rowMenu}>
<SavedViewsRowMenu
onAction={(key): void => onAction(view, key)}
onOpen={hover.onMenuOpen}
/>
</span>
</div>
);
}
export default SavedViewsListRow;

View File

@@ -1,43 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsListRow from './SavedViewsListRow';
import { SavedViewsListSectionProps } from './types';
import styles from './SavedViewsList.module.scss';
function SavedViewsListSection({
title,
views,
activeViewId,
onSelect,
onClear,
onAction,
hover,
testId,
}: SavedViewsListSectionProps): JSX.Element {
return (
<section className={styles.section} data-testid={testId}>
<Typography.Text className={styles.sectionTitle}>{title}</Typography.Text>
<div className={styles.sectionList}>
<OverlayScrollbar>
<div>
{views.map((view) => (
<SavedViewsListRow
key={view.id}
view={view}
isActive={view.id === activeViewId}
onSelect={onSelect}
onClear={onClear}
onAction={onAction}
hover={hover}
/>
))}
</div>
</OverlayScrollbar>
</div>
</section>
);
}
export default SavedViewsListSection;

View File

@@ -10,34 +10,12 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-5);
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
}
.search {
--input-height: var(--spacing-20);
--input-border-radius: 0;
--input-border-color: transparent;
--input-hover-border-color: transparent;
--input-focus-border-color: transparent;
--input-focus-outline-width: 0;
--input-background: var(--l1-background);
--input-hover-background: var(--l1-background);
--input-focus-background: var(--l1-background);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-6);
color: var(--l2-foreground);
}
.titleText {
color: inherit;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -1,12 +1,8 @@
import { useState } from 'react';
import { TowerControl, X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsList from './SavedViewsList';
import styles from './SavedViewsPanel.module.scss';
function SavedViewsPanel({
@@ -17,38 +13,26 @@ function SavedViewsPanel({
// Absent when the list is always on screen.
onClose?: () => void;
}): JSX.Element {
const [search, setSearch] = useState('');
return (
<div
className={styles.panel}
data-testid="saved-views-panel"
data-source={source}
>
{onClose && (
<div className={styles.header} data-testid="saved-views-panel-header">
<div className={styles.title}>
<TowerControl size={16} />
<Typography.Text className={styles.titleText}>All views</Typography.Text>
</div>
<SavedViewsIconButton
title="Close"
icon={<X size={14} />}
<div className={styles.header}>
<Typography.Text className={styles.title}>All views</Typography.Text>
{onClose && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Close"
prefix={<X size={14} />}
onClick={onClose}
testId="saved-views-close"
data-testid="saved-views-close"
/>
</div>
)}
<div className={styles.search}>
<Input
value={search}
onChange={(event): void => setSearch(event.target.value)}
placeholder="Search..."
aria-label="Search views"
testId="saved-views-search"
/>
)}
</div>
<SavedViewsList source={source} search={search} />
</div>
);
}

View File

@@ -1,40 +0,0 @@
import { forwardRef } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedViewsRowButtonProps } from './types';
import styles from './SavedViewsList.module.scss';
// forwardRef and rest props so a menu can use it as its trigger.
const SavedViewsRowButton = forwardRef<
HTMLButtonElement,
SavedViewsRowButtonProps
>(function SavedViewsRowButton(
{ icon, label, tooltip, testId, onClick, ...triggerProps },
ref,
): JSX.Element {
const button = (
<Button
ref={ref}
variant="link"
color="secondary"
size="sm"
className={styles.rowButton}
aria-label={label}
onClick={onClick}
{...triggerProps}
data-testid={testId}
>
{icon}
</Button>
);
return tooltip ? (
<TooltipSimple title={tooltip}>{button}</TooltipSimple>
) : (
button
);
});
export default SavedViewsRowButton;

View File

@@ -1,44 +0,0 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
import SavedViewsRowButton from './SavedViewsRowButton';
import { SavedViewsRowMenuProps } from './types';
function SavedViewsRowMenu({
onAction,
onOpen,
}: SavedViewsRowMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(
() =>
SAVED_VIEW_ROW_ACTIONS.flatMap(
({ key, label, icon: Icon, danger, hasDividerBefore }): MenuItem[] => [
...(hasDividerBefore ? [{ type: 'divider' as const }] : []),
{
key,
label,
icon: <Icon size={14} />,
danger,
onClick: (): void => onAction(key),
},
],
),
[onAction],
);
return (
<DropdownMenuSimple menu={{ items }} side="right" align="start">
<SavedViewsRowButton
icon={<Ellipsis size={14} />}
label="More options"
tooltip="More options"
testId="saved-views-row-menu"
onPointerDown={onOpen}
onKeyDown={onOpen}
/>
</DropdownMenuSimple>
);
}
export default SavedViewsRowMenu;

View File

@@ -1,263 +0,0 @@
import { act, fireEvent, screen, within } from '@testing-library/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
} from '../constants';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
explorerUrl,
makeView,
queryWith,
renderWithExplorerProviders,
} from './savedViewsTestUtils';
const mockCopy = jest.fn();
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
function twoQueries(): Query {
const base = queryWith(DataSource.TRACES, 'has_error = true');
const [first] = base.builder.queryData;
return {
...base,
builder: {
...base.builder,
queryData: [
first,
{
...first,
queryName: 'B',
expression: 'B',
filter: { expression: 'duration_nano > 1000' },
},
],
},
};
}
function metricsQuery(): Query {
const base = initialQueriesMap[DataSource.METRICS];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression: '' },
aggregations: [
{
metricName: 'app.requests',
temporality: '',
timeAggregation: 'rate',
spaceAggregation: 'sum',
},
],
},
],
},
} as Query;
}
const errors = makeView({
id: 'view-1',
displayName: 'Errors',
updatedBy: 'editor@signoz.io',
updatedAt: '2026-09-28T12:00:00Z',
});
const slow = makeView({
id: 'view-2',
displayName: 'Slow spans',
expression: 'duration_nano > 1000',
});
const unfiltered = makeView({
id: 'view-3',
displayName: 'Everything',
expression: '',
});
const combined = makeView({
id: 'view-4',
displayName: 'Errors and slow',
query: twoQueries(),
});
const requestsMetric = makeView({
id: 'view-5',
displayName: 'Request rate',
source: SavedviewtypesSourceDTO.metrics,
dataSource: DataSource.METRICS,
query: metricsQuery(),
panelType: PANEL_TYPES.TIME_SERIES,
});
async function renderPanel(
source: SavedviewtypesSourceDTO = SavedviewtypesSourceDTO.traces,
dataSource: DataSource = DataSource.TRACES,
): Promise<void> {
renderWithExplorerProviders(
<SavedViewsPanel source={source} onClose={jest.fn()} />,
explorerUrl(ROUTES.TRACES_EXPLORER, {}),
dataSource,
);
await screen.findByTestId('saved-views-list');
jest.useFakeTimers();
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const card = (): HTMLElement | null =>
screen.queryByTestId('saved-view-hover-card');
function advance(ms: number): void {
act(() => {
jest.advanceTimersByTime(ms);
});
}
function openCard(name: string): void {
fireEvent.mouseEnter(row(name));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
}
describe('SavedViewHoverCard', () => {
beforeEach(() => {
localStorage.clear();
mockCopy.mockClear();
mockSavedViewsApi([errors, slow, unfiltered, combined, requestsMetric]);
});
afterEach(() => {
jest.useRealTimers();
});
describe('opening and closing', () => {
it('opens for a row after the delay, not before', async () => {
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS - 1);
expect(card()).toBeNull();
advance(1);
expect(card()).toHaveTextContent('Errors');
});
it('does not open when the pointer leaves before the delay', async () => {
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
fireEvent.mouseLeave(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
expect(card()).toBeNull();
});
it('stays open while the pointer moves into the card', async () => {
await renderPanel();
openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(card() as HTMLElement);
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS * 2);
expect(card()).toBeInTheDocument();
fireEvent.mouseLeave(card() as HTMLElement);
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
expect(card()).toBeNull();
});
it('closes on leaving the row and reopens for the next row after the delay', async () => {
await renderPanel();
openCard('Errors');
fireEvent.mouseLeave(row('Errors'));
fireEvent.mouseEnter(row('Slow spans'));
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
expect(card()).toBeNull();
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
expect(card()).toHaveTextContent('Slow spans');
});
it('closes when the row menu is pressed', async () => {
await renderPanel();
openCard('Errors');
fireEvent.pointerDown(
within(row('Errors')).getByTestId('saved-views-row-menu'),
);
expect(card()).toBeNull();
});
});
describe('content', () => {
it('shows the filter, the updater and the update time', async () => {
await renderPanel();
openCard('Errors');
const content = card() as HTMLElement;
expect(
within(content).getByTestId('saved-view-hover-card-query'),
).toHaveTextContent('has_error = true');
expect(content).toHaveTextContent('editor@signoz.io');
expect(content).toHaveTextContent('Sep 28, 2026');
});
it('copies the filter expression', async () => {
await renderPanel();
openCard('Errors');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
expect(mockCopy).toHaveBeenCalledWith('has_error = true');
});
it('names each query when there are several, and copies them per line', async () => {
await renderPanel();
openCard('Errors and slow');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('A');
expect(query).toHaveTextContent('B');
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
expect(mockCopy).toHaveBeenCalledWith(
'A: has_error = true\nB: duration_nano > 1000',
);
});
it('says there are no filters, with nothing to copy', async () => {
await renderPanel();
openCard('Everything');
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
'No filters',
);
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
});
it('shows the metric of a metrics query', async () => {
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
openCard('Request rate');
const query = screen.getByTestId('saved-view-hover-card-query');
expect(query).toHaveTextContent('app.requests · rate · sum');
expect(query).not.toHaveTextContent('No filters');
});
});
});

View File

@@ -1,120 +0,0 @@
import { useState } from 'react';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import SavedViewsHeader from '../SavedViewsHeader';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
makeView,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const baseView = makeView({ id: 'view-1', displayName: 'Errors' });
const errors = {
...baseView,
spec: {
...baseView.spec,
selectedFields: [{ name: 'service.name' }, { name: 'name' }],
},
};
function Harness(): JSX.Element {
const { traces } = usePreferenceContext();
const [headerKey, setHeaderKey] = useState(0);
const columns = traces.preferences?.columns ?? [];
return (
<>
<SavedViewsHeader
key={headerKey}
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>
<span data-testid="columns">
{columns.map((column) => column.name).join(',')}
</span>
<Button
data-testid="add-column"
onClick={(): void =>
traces.updateColumns([...columns, { name: `extra-${columns.length}` }])
}
/>
<Button
data-testid="remount-header"
onClick={(): void => setHeaderKey((key) => key + 1)}
/>
</>
);
}
const columnsText = (): string | null =>
screen.getByTestId('columns').textContent;
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
async function openView(): Promise<void> {
renderWithExplorerProviders(<Harness />, viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
);
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
}
describe('SavedViewsHeader columns', () => {
beforeEach(() => {
localStorage.clear();
});
it('keeps edited columns when the view is fetched again', async () => {
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('remount-header'));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(columnsText()).toBe('service.name,name,extra-2');
expect(isDirty()).toBe(true);
});
it('discard puts the saved columns back', async () => {
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
expect(isDirty()).toBe(false);
});
it('discard after an update goes back to the updated columns', async () => {
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Update selected view' }),
);
await waitFor(() => expect(isDirty()).toBe(false));
await userEvent.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name,extra-2'));
expect(isDirty()).toBe(false);
});
});

View File

@@ -1,78 +0,0 @@
import { act, screen, waitFor } from '@testing-library/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsRestore from '../SavedViewsRestore';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
explorerUrl,
makeView,
queryWith,
renderWithExplorerProviders,
urlParam,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
function renderHeader(
url: string,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsRestore source={SavedviewtypesSourceDTO.traces} />
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>
</>,
url,
);
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
async function waitForView(name: string): Promise<void> {
await waitFor(() => expect(chip()).toHaveTextContent(name));
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
describe('SavedViewsHeader last used view', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
});
it('reopens on a bare explorer', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
});
it('leaves a url with its own query alone', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
}),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
});

View File

@@ -1,63 +0,0 @@
import { screen, waitFor } from '@testing-library/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import SavedViewsHeader from '../SavedViewsHeader';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
makeView,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const baseView = makeView({ id: 'view-1', displayName: 'Errors' });
const withColumns = {
...baseView,
spec: {
...baseView.spec,
selectedFields: [{ name: 'service.name' }, { name: 'duration_nano' }],
},
};
function renderHeader(url: string): void {
renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>,
url,
);
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
describe('SavedViewsHeader when loading fails', () => {
beforeEach(() => {
localStorage.clear();
});
it('shows the load failed state with create and clear when the view does not load', async () => {
mockSavedViewsApi([]);
renderHeader(viewUrl(PATH, withColumns));
await waitFor(() => expect(chip()).toHaveTextContent('View failed to load'));
expect(isDirty()).toBe(false);
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('keeps the view clean when only the views list fails', async () => {
mockSavedViewsApi([withColumns], { listFailures: Number.POSITIVE_INFINITY });
renderHeader(viewUrl(PATH, withColumns));
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
expect(chip()).toHaveTextContent('Errors');
expect(isDirty()).toBe(false);
});
});

View File

@@ -11,12 +11,12 @@ import SavedViewsHeader from '../SavedViewsHeader';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
import { mockSavedViewsApi } from './savedViewsApiMock';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
@@ -112,7 +112,7 @@ describe('SavedViewsHeader saving', () => {
),
);
expect(toast.success).toHaveBeenCalledWith(
'View created',
'You have created a new view.',
expect.objectContaining({ position: 'top-right' }),
);
await waitFor(() => expect(isModalOpen()).toBe(false));

View File

@@ -15,12 +15,12 @@ import SavedViewsRestore from '../SavedViewsRestore';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
import { mockSavedViewsApi } from './savedViewsApiMock';
const PATH = ROUTES.TRACES_EXPLORER;
@@ -130,6 +130,16 @@ describe('SavedViewsHeader', () => {
expect(isDirty()).toBe(false);
});
it('falls back to My view when the view in the url does not load', async () => {
mockSavedViewsApi([]);
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
expect(chip()).toHaveTextContent('My view');
});
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
@@ -217,6 +227,38 @@ describe('SavedViewsHeader', () => {
).toStrictEqual({});
});
describe('last used view', () => {
beforeEach(() => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
});
it('reopens on a bare explorer', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
});
it('leaves a url with its own query alone', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
}),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
});
describe('unsaved changes indicator', () => {
it('never flashes while switching between views already loaded', async () => {
mockSavedViewsApi([errors, slow]);

View File

@@ -1,190 +0,0 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
explorerUrl,
makeView,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const PATH = ROUTES.TRACES_EXPLORER;
const mine = makeView({ id: 'view-1', displayName: 'Errors' });
const alsoMine = makeView({ id: 'view-2', displayName: 'Slow spans' });
const theirs = makeView({
id: 'view-3',
displayName: 'Checkout errors',
createdBy: 'someone@signoz.io',
});
function renderPanel(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
url,
);
}
const rowNames = (sectionTestId: string): string[] =>
within(screen.getByTestId(sectionTestId))
.getAllByTestId('saved-views-row-name')
.map((row) => row.textContent ?? '');
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
describe('SavedViewsPanel list', () => {
beforeEach(() => {
localStorage.clear();
});
it('splits the views into created by me and created by others', async () => {
mockSavedViewsApi([mine, alsoMine, theirs]);
renderPanel();
await screen.findByTestId('saved-views-list');
expect(rowNames('saved-views-created-by-me')).toStrictEqual([
'Errors',
'Slow spans',
]);
expect(rowNames('saved-views-created-by-others')).toStrictEqual([
'Checkout errors',
]);
});
it('hides a section with no views', async () => {
mockSavedViewsApi([mine]);
renderPanel();
await screen.findByTestId('saved-views-created-by-me');
expect(screen.queryByTestId('saved-views-created-by-others')).toBeNull();
});
it('opens a view from its row', async () => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
await userEvent.click(
within(row('Slow spans')).getByTestId('saved-views-row-name'),
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(alsoMine.id),
),
);
});
it('marks the open view and clears it from its row', async () => {
mockSavedViewsApi([mine, alsoMine]);
const { history } = renderPanel(viewUrl(PATH, mine));
await screen.findByTestId('saved-views-list');
const activeRow = row('Errors');
expect(within(activeRow).getByTestId('saved-views-row-clear')).toBeVisible();
expect(
within(row('Slow spans')).queryByTestId('saved-views-row-clear'),
).toBeNull();
await userEvent.click(within(activeRow).getByTestId('saved-views-row-clear'));
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBeNull(),
);
});
it('leaves the url alone when the open view is clicked again', async () => {
mockSavedViewsApi([mine]);
const { history } = renderPanel(viewUrl(PATH, mine));
await screen.findByTestId('saved-views-list');
const before = history.location.search;
await userEvent.click(
within(row('Errors')).getByTestId('saved-views-row-name'),
);
expect(history.location.search).toBe(before);
});
describe('search', () => {
it('filters both sections by name, ignoring case', async () => {
mockSavedViewsApi([mine, alsoMine, theirs]);
renderPanel();
await screen.findByTestId('saved-views-list');
await userEvent.type(screen.getByTestId('saved-views-search'), 'ERRORS');
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
expect(rowNames('saved-views-created-by-others')).toStrictEqual([
'Checkout errors',
]);
});
it('says so when nothing matches, and recovers when cleared', async () => {
mockSavedViewsApi([mine]);
renderPanel();
await screen.findByTestId('saved-views-list');
const search = screen.getByTestId('saved-views-search');
await userEvent.type(search, 'nothing like this');
expect(
screen.getByTestId('saved-views-list-no-results'),
).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-list-create')).toBeNull();
await userEvent.clear(search);
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
});
});
it('offers to create a view when there are none, and opens it', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderPanel();
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(requests.created).toHaveLength(1));
expect(toast.success).toHaveBeenCalledWith('View created', expect.anything());
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-new'),
),
);
await expect(
screen.findByTestId('saved-views-created-by-me'),
).resolves.toHaveTextContent('First view');
});
it('shows the list error and loads the list on retry', async () => {
mockSavedViewsApi([mine], { listFailures: 1 });
renderPanel();
await userEvent.click(await screen.findByTestId('saved-views-list-retry'));
await expect(
screen.findByTestId('saved-views-created-by-me'),
).resolves.toHaveTextContent('Errors');
});
});

View File

@@ -1,195 +0,0 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
explorerUrl,
makeView,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const mockCopy = jest.fn();
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
id: 'view-2',
displayName: 'Slow spans',
expression: 'duration_nano > 1000',
});
function renderPanelWithHeader(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader source={SavedviewtypesSourceDTO.traces} />
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>
</>,
url,
);
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
async function chooseFromRowMenu(
viewName: string,
item: string,
): Promise<void> {
await screen.findByTestId('saved-views-list');
await userEvent.click(
within(row(viewName)).getByTestId('saved-views-row-menu'),
);
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
describe('SavedViewsPanel row menu', () => {
beforeEach(() => {
localStorage.clear();
mockCopy.mockClear();
jest.mocked(toast.success).mockClear();
});
it('offers copy link, edit details and delete', async () => {
mockSavedViewsApi([errors]);
renderPanelWithHeader();
await screen.findByTestId('saved-views-list');
await userEvent.click(
within(row('Errors')).getByTestId('saved-views-row-menu'),
);
const items = await screen.findAllByRole('menuitem');
expect(items.map((item) => item.textContent)).toStrictEqual([
'Copy link',
'Edit details',
'Delete',
]);
});
it('copies a link that opens the view with its query and tab', async () => {
mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Copy link');
expect(mockCopy).toHaveBeenCalledTimes(1);
const link = new URL(mockCopy.mock.calls[0][0]);
expect(link.pathname).toBe(PATH);
expect(link.searchParams.get(QueryParams.viewKey)).toBe(
JSON.stringify(slow.id),
);
expect(link.searchParams.get(QueryParams.panelTypes)).toBe(
JSON.stringify(slow.spec.panelType),
);
expect(
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
).toContain('duration_nano > 1000');
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
});
it('renames a view without touching its query', async () => {
const requests = mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
expect(name).toHaveValue('Slow spans');
await userEvent.clear(name);
await userEvent.type(name, 'Slow checkout spans');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(requests.updated).toHaveLength(1));
const [update] = requests.updated;
expect(update.id).toBe(slow.id);
expect(update.body).toMatchObject({
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
});
expect(toast.success).toHaveBeenCalledWith('View updated', expect.anything());
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
});
it('shows the new name in the header when the open view is renamed', async () => {
mockSavedViewsApi([errors]);
renderPanelWithHeader(viewUrl(PATH, errors));
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
await chooseFromRowMenu('Errors', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.clear(name);
await userEvent.type(name, 'All errors');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(chip()).toHaveTextContent('All errors'));
});
it('asks before deleting and keeps the view on cancel', async () => {
const requests = mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Delete');
expect(screen.getByText('Delete this view')).toBeInTheDocument();
expect(
screen.getByText('Deleting this view is irreversible and cannot be undone.'),
).toBeInTheDocument();
await userEvent.click(screen.getByTestId('delete-saved-view-cancel'));
expect(requests.deleted).toHaveLength(0);
expect(row('Slow spans')).toBeInTheDocument();
});
it('deletes a view on confirm', async () => {
const requests = mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() => expect(requests.deleted).toStrictEqual([slow.id]));
expect(toast.success).toHaveBeenCalledWith('View deleted', expect.anything());
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
});
it('clears the open view when it is deleted', async () => {
mockSavedViewsApi([errors, slow]);
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
await chooseFromRowMenu('Errors', 'Delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBeNull(),
);
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
});
});

View File

@@ -1,102 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
export const API = 'http://localhost/api/v2/saved_views';
export const TEST_USER_EMAIL = 'test@signoz.io';
export interface Requests {
created: unknown[];
updated: { id: string; body: unknown }[];
deleted: string[];
}
// Mutates `views` on create, update and delete, so a refetch sees the change.
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
createdId = 'view-new',
getDelay = 0,
listFailures = 0,
}: {
createStatus?: number;
createdId?: string;
getDelay?: number;
listFailures?: number;
} = {},
): Requests {
const requests: Requests = { created: [], updated: [], deleted: [] };
let failedLists = 0;
server.use(
rest.get(API, (req, res, ctx) => {
if (failedLists < listFailures) {
failedLists += 1;
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
const source = req.url.searchParams.get('source');
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: views.filter((view) => !source || view.source === source),
}),
);
}),
rest.get(`${API}/:id`, (req, res, ctx) => {
const view = views.find((v) => v.id === req.params.id);
if (!view) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.delay(getDelay),
ctx.status(200),
ctx.json({ status: 'success', data: view }),
);
}),
rest.post(API, async (req, res, ctx) => {
const body = await req.json();
requests.created.push(body);
if (createStatus >= 400) {
return res(
ctx.status(createStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
views.push({
...body,
id: createdId,
createdBy: TEST_USER_EMAIL,
updatedBy: TEST_USER_EMAIL,
} as SavedviewtypesSavedViewDTO);
return res(
ctx.status(createStatus),
ctx.json({ status: 'success', data: { id: createdId } }),
);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = await req.json();
const id = String(req.params.id);
requests.updated.push({ id, body });
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views[index] = { ...views[index], spec: body.spec };
}
return res(ctx.status(204));
}),
rest.delete(`${API}/:id`, (req, res, ctx) => {
const id = String(req.params.id);
requests.deleted.push(id);
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views.splice(index, 1);
}
return res(ctx.status(204));
}),
);
return requests;
}

View File

@@ -19,11 +19,12 @@ import { createMemoryHistory, MemoryHistory } from 'history';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppContext } from 'providers/App/App';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import TimezoneProvider from 'providers/Timezone';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import store from 'store';
@@ -34,7 +35,8 @@ import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchPa
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { TEST_USER_EMAIL } from './savedViewsApiMock';
export const API = 'http://localhost/api/v2/saved_views';
// The server stores queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
@@ -76,25 +78,17 @@ export function makeView({
source = SavedviewtypesSourceDTO.traces,
dataSource = DataSource.TRACES,
expression = 'has_error = true',
query = queryWith(dataSource, expression),
panelType = PANEL_TYPES.LIST,
createdBy = TEST_USER_EMAIL,
updatedBy = createdBy,
updatedAt,
}: {
id: string;
displayName: string;
source?: SavedviewtypesSourceDTO;
dataSource?: DataSource;
expression?: string;
query?: Query;
panelType?: PANEL_TYPES;
createdBy?: string;
updatedBy?: string;
updatedAt?: string;
}): SavedviewtypesSavedViewDTO {
const spec = toSavedViewSpec({
query,
query: queryWith(dataSource, expression),
panelType,
displayName,
});
@@ -103,9 +97,8 @@ export function makeView({
name: `${displayName.toLowerCase()}-abc`,
source,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdBy,
updatedBy,
updatedAt,
createdBy: 'test@signoz.io',
updatedBy: 'test@signoz.io',
spec: {
...spec,
queries: asStoredByServer(
@@ -160,6 +153,69 @@ export function viewUrl(
});
}
export interface Requests {
created: unknown[];
updated: { id: string; body: unknown }[];
}
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
createdId = 'view-new',
getDelay = 0,
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
): Requests {
const requests: Requests = { created: [], updated: [] };
server.use(
rest.get(API, (req, res, ctx) => {
const source = req.url.searchParams.get('source');
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: views.filter((view) => !source || view.source === source),
}),
);
}),
rest.get(`${API}/:id`, (req, res, ctx) => {
const view = views.find((v) => v.id === req.params.id);
if (!view) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.delay(getDelay),
ctx.status(200),
ctx.json({ status: 'success', data: view }),
);
}),
rest.post(API, async (req, res, ctx) => {
const body = await req.json();
requests.created.push(body);
if (createStatus >= 400) {
return res(
ctx.status(createStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
return res(
ctx.status(createStatus),
ctx.json({ status: 'success', data: { id: createdId } }),
);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = await req.json();
requests.updated.push({ id: String(req.params.id), body });
return res(ctx.status(204));
}),
);
return requests;
}
const mockStore = configureStore([thunk]);
// What the explorer page does around the header: writes its default query to a
@@ -218,29 +274,21 @@ export function renderWithExplorerProviders(
},
});
const defaultAppContext = getAppContextMock('ADMIN');
const appContext = {
...defaultAppContext,
user: { ...defaultAppContext.user, email: TEST_USER_EMAIL },
};
const result = render(
<Router history={history}>
<CompatRouter>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<Provider store={mockStore(store.getState())}>
<AppContext.Provider value={appContext}>
<TimezoneProvider>
<TooltipProvider>
<PreferenceContextProvider>
<QueryBuilderProvider>
<ExplorerShell dataSource={dataSource} />
{ui}
</QueryBuilderProvider>
</PreferenceContextProvider>
</TooltipProvider>
</TimezoneProvider>
<AppContext.Provider value={getAppContextMock('ADMIN')}>
<TooltipProvider>
<PreferenceContextProvider>
<QueryBuilderProvider>
<ExplorerShell dataSource={dataSource} />
{ui}
</QueryBuilderProvider>
</PreferenceContextProvider>
</TooltipProvider>
</AppContext.Provider>
</Provider>
</QueryClientProvider>

View File

@@ -1,4 +1,3 @@
import { Link, PenLine, Trash2 } from '@signozhq/icons';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
@@ -6,7 +5,7 @@ import {
import { QueryParams } from 'constants/query';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
import { SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
@@ -28,33 +27,9 @@ export const SAVED_VIEW_URL_PARAMS = [
export const MY_VIEW_NAME = 'My view';
export const SAVED_VIEW_LOAD_FAILED_NAME = 'View failed to load';
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
// Long enough for the pointer to cross from the row into the card.
export const SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS = 150;
export const SAVED_VIEW_UPDATED_AT_FORMAT = 'HH:mm:ss — MMM D, YYYY';
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
create: 'Create new view',
saveAsNew: 'Save as new view',
edit: 'Edit view details',
};
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
{ key: 'copyLink', label: 'Copy link', icon: Link },
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
{
key: 'delete',
label: 'Delete',
icon: Trash2,
danger: true,
hasDividerBefore: true,
},
];
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;

View File

@@ -39,7 +39,7 @@ export function useActiveSavedView(
});
const hasUnsavedChanges = useMemo((): boolean => {
if (!view || isError) {
if (!view) {
return false;
}
if (!isStagedQueryCurrent) {
@@ -56,7 +56,6 @@ export function useActiveSavedView(
});
}, [
view,
isError,
isStagedQueryCurrent,
stagedQuery,
panelType,

View File

@@ -1,22 +0,0 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
import { UseSavedViewActionsResult } from '../types';
export function useSaveNewView(
createView: UseSavedViewActionsResult['createView'],
): (displayName: string) => Promise<boolean> {
return useCallback(
async (displayName: string): Promise<boolean> => {
if (!(await createView(displayName))) {
return false;
}
toast.success('View created', {
position: SAVED_VIEW_TOAST_POSITION,
});
return true;
},
[createView],
);
}

View File

@@ -3,6 +3,8 @@ import { useQueryClient } from 'react-query';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateGetSavedView,
invalidateListSavedViews,
useCreateSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
@@ -19,7 +21,6 @@ import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
@@ -32,8 +33,6 @@ import {
} from '../constants';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
@@ -55,11 +54,6 @@ export function useSavedViewActions(
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
@@ -67,7 +61,14 @@ export function useSavedViewActions(
useUpdateSavedView();
const invalidateViews = useCallback(
(id?: string): Promise<void> => invalidateSavedViews(queryClient, source, id),
async (id?: string): Promise<void> => {
await Promise.all([
invalidateListSavedViews(queryClient),
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
// The saved views bar still lists through v1.
queryClient.invalidateQueries([{ sourcepage: source }]),
]);
},
[queryClient, source],
);
@@ -94,21 +95,14 @@ export function useSavedViewActions(
[handleExplorerTabChange, setLastUsedView],
);
// The preference provider re-reads a view's columns only when its list
// changes, so the list is refetched as the view is applied again.
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (optionsDataSource) {
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
);
preferencesSlice.updateColumns(columns);
if (formatting) {
preferencesSlice.updateFormatting(formatting);
}
}
void invalidateListSavedViews(queryClient);
selectView(view);
},
[optionsDataSource, preferencesSlice, selectView],
[queryClient, selectView],
);
// The query builder redirects only ever add params, so the url is rebuilt

View File

@@ -1,72 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
} from '../constants';
import {
SavedViewHoverCardTarget,
UseSavedViewHoverCardResult,
} from '../types';
export function useSavedViewHoverCard(): UseSavedViewHoverCardResult {
const [target, setTarget] = useState<SavedViewHoverCardTarget | null>(null);
const openTimer = useRef<ReturnType<typeof setTimeout>>();
const closeTimer = useRef<ReturnType<typeof setTimeout>>();
const cancelOpen = useCallback(
(): void => clearTimeout(openTimer.current),
[],
);
const cancelClose = useCallback(
(): void => clearTimeout(closeTimer.current),
[],
);
const scheduleClose = useCallback((): void => {
cancelClose();
closeTimer.current = setTimeout(
() => setTarget(null),
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
);
}, [cancelClose]);
const onRowEnter = useCallback(
(next: SavedViewHoverCardTarget): void => {
cancelOpen();
openTimer.current = setTimeout(() => {
cancelClose();
setTarget(next);
}, SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
},
[cancelOpen, cancelClose],
);
const onRowLeave = useCallback((): void => {
cancelOpen();
scheduleClose();
}, [cancelOpen, scheduleClose]);
const close = useCallback((): void => {
cancelOpen();
cancelClose();
setTarget(null);
}, [cancelOpen, cancelClose]);
useEffect(
() => (): void => {
cancelOpen();
cancelClose();
},
[cancelOpen, cancelClose],
);
return {
target,
onRowEnter,
onRowLeave,
onCardEnter: cancelClose,
onCardLeave: scheduleClose,
close,
};
}

View File

@@ -1,105 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import {
useDeleteSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
import {
RenderErrorResponseDTO,
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
import { getAbsoluteUrl } from 'utils/basePath';
import { toAPIError } from 'utils/errorUtils';
import {
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
} from '../constants';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
function showErrorToast(error: unknown, fallback: string): void {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
fallback,
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
}
export function useSavedViewRowActions(
source: SavedviewtypesSourceDTO,
): UseSavedViewRowActionsResult {
const queryClient = useQueryClient();
const { pathname } = useLocation();
const [, copyToClipboard] = useCopyToClipboard();
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
useDeleteSavedView();
const copyViewLink = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
[QueryParams.panelTypes]: view.spec.panelType,
[QueryParams.viewKey]: view.id,
});
copyToClipboard(getAbsoluteUrl(path));
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
},
[pathname, copyToClipboard],
);
const renameView = useCallback(
async (
view: SavedviewtypesSavedViewDTO,
displayName: string,
): Promise<boolean> => {
try {
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: { ...view.spec, displayName },
},
});
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
return true;
} catch (error) {
showErrorToast(error, 'Could not update the view');
return false;
}
},
[updateSavedView, source, queryClient],
);
// The deleted view's own query is left alone: refetching it would fail
// while the header still has it selected.
const deleteView = useCallback(
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
try {
await deleteSavedView({ pathParams: { id: view.id } });
await invalidateSavedViews(queryClient, source);
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
return true;
} catch (error) {
showErrorToast(error, 'Could not delete the view');
return false;
}
},
[deleteSavedView, source, queryClient],
);
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
}

View File

@@ -1,35 +0,0 @@
import { useMemo } from 'react';
import { useListSavedViews } from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useAppContext } from 'providers/App/App';
import { UseSavedViewsListResult } from '../types';
export function useSavedViewsList(
source: SavedviewtypesSourceDTO,
search: string,
): UseSavedViewsListResult {
const { user } = useAppContext();
const { data, isLoading, isError, refetch } = useListSavedViews({ source });
const { createdByMe, createdByOthers } = useMemo(() => {
const term = search.trim().toLowerCase();
const views = (data?.data ?? []).filter((view) =>
view.spec.displayName.toLowerCase().includes(term),
);
return {
createdByMe: views.filter((view) => view.createdBy === user.email),
createdByOthers: views.filter((view) => view.createdBy !== user.email),
};
}, [data, search, user.email]);
return {
createdByMe,
createdByOthers,
isLoading,
isError,
refetch: (): void => {
void refetch();
},
};
}

View File

@@ -1,5 +1,4 @@
import { ReactElement } from 'react';
import type { Link } from '@signozhq/icons';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
@@ -58,11 +57,10 @@ export interface UseRestoreLastUsedViewArgs {
selectView: UseSavedViewActionsResult['selectView'];
}
export type SaveViewModalMode = 'create' | 'saveAsNew' | 'edit';
export type SaveViewModalMode = 'create' | 'saveAsNew';
export interface SaveViewModalProps {
mode: SaveViewModalMode;
initialName?: string;
isSaving: boolean;
onClose: () => void;
// Resolves true when saved; the modal stays open otherwise.
@@ -83,127 +81,3 @@ export interface SavedViewsIconButtonProps {
disabled?: boolean;
onClick?: () => void;
}
export interface UseSavedViewsListResult {
createdByMe: SavedviewtypesSavedViewDTO[];
createdByOthers: SavedviewtypesSavedViewDTO[];
isLoading: boolean;
isError: boolean;
refetch: () => void;
}
export interface SavedViewsListSectionProps {
title: string;
views: SavedviewtypesSavedViewDTO[];
activeViewId?: string;
onSelect: (view: SavedviewtypesSavedViewDTO) => void;
onClear: () => void;
onAction: SavedViewsListRowProps['onAction'];
hover: SavedViewsListRowHoverProps;
testId: string;
}
export interface SavedViewsListRowProps {
view: SavedviewtypesSavedViewDTO;
isActive: boolean;
onSelect: (view: SavedviewtypesSavedViewDTO) => void;
onClear: () => void;
onAction: (
view: SavedviewtypesSavedViewDTO,
key: SavedViewRowActionKey,
) => void;
hover: SavedViewsListRowHoverProps;
}
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
export interface SavedViewRowActionConfig {
key: SavedViewRowActionKey;
label: string;
icon: typeof Link;
danger?: boolean;
hasDividerBefore?: boolean;
}
export interface PendingSavedViewAction {
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
view: SavedviewtypesSavedViewDTO;
}
export interface SavedViewsRowButtonProps {
icon: ReactElement;
label: string;
tooltip?: string;
testId: string;
onClick?: () => void;
onPointerDown?: () => void;
onKeyDown?: () => void;
}
export interface SavedViewsRowMenuProps {
onAction: (key: SavedViewRowActionKey) => void;
onOpen: () => void;
}
export interface DeleteSavedViewDialogProps {
isDeleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export interface UseSavedViewRowActionsResult {
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
renameView: (
view: SavedviewtypesSavedViewDTO,
displayName: string,
) => Promise<boolean>;
deleteView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isRenaming: boolean;
isDeleting: boolean;
}
export interface SavedViewHoverCardTarget {
view: SavedviewtypesSavedViewDTO;
anchorTop: number;
anchorHeight: number;
}
export interface UseSavedViewHoverCardResult {
target: SavedViewHoverCardTarget | null;
onRowEnter: (target: SavedViewHoverCardTarget) => void;
onRowLeave: () => void;
onCardEnter: () => void;
onCardLeave: () => void;
close: () => void;
}
export interface SavedViewHoverCardProps {
target: SavedViewHoverCardTarget | null;
onCardEnter: () => void;
onCardLeave: () => void;
onClose: () => void;
}
export interface SavedViewsListRowHoverProps {
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
onRowLeave: () => void;
onMenuOpen: () => void;
}
export interface SavedViewQuerySummary {
queryName: string;
expression: string;
metric?: string;
}
export interface HighlightedQueryPart {
from: number;
text: string;
color?: string;
}
export interface SavedViewsEmptyStateProps {
isSearching: boolean;
isSaving: boolean;
onSave: (displayName: string) => Promise<boolean>;
}

View File

@@ -204,169 +204,6 @@ describe('hasUnsavedViewChanges', () => {
});
});
it('ignores fields the save path never writes, kept by older views', () => {
const metricsView = viewFrom(
toSavedViewSpec({
query: query(DataSource.METRICS, ''),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'Old metrics view',
}),
);
const [storedQuery] = metricsView.spec.queries;
const olderView = {
...metricsView,
spec: {
...metricsView.spec,
queries: [
{
...storedQuery,
spec: {
...storedQuery.spec,
aggregations: [
{ metricName: 'erlang_vm_msacc_sleep_seconds_total', reduceTo: 'avg' },
],
},
},
],
},
} as SavedviewtypesSavedViewDTO;
expect(
hasUnsavedViewChanges({
view: olderView,
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
panelType: PANEL_TYPES.TIME_SERIES,
}),
).toBe(false);
});
it('reads a group-by stored as attribute and staged as tag as the same field', () => {
const attributeView = viewFrom(
toSavedViewSpec({
query: query(DataSource.METRICS, ''),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'By os',
}),
);
const [storedQuery] = attributeView.spec.queries;
const storedView = {
...attributeView,
spec: {
...attributeView.spec,
queries: [
{
...storedQuery,
spec: {
...storedQuery.spec,
groupBy: [
{ name: 'os.type', fieldContext: 'attribute', fieldDataType: 'string' },
],
},
},
],
},
} as SavedviewtypesSavedViewDTO;
const opened = stagedFrom(storedView, DataSource.METRICS);
const stagedWithTag: Query = {
...opened,
builder: {
...opened.builder,
queryData: opened.builder.queryData.map((queryData) => ({
...queryData,
groupBy: [{ key: 'os.type', dataType: 'string', type: 'tag' }],
})),
},
} as Query;
expect(
hasUnsavedViewChanges({
view: storedView,
stagedQuery: stagedWithTag,
panelType: PANEL_TYPES.TIME_SERIES,
}),
).toBe(false);
});
describe('list order', () => {
const withOrderBy = (
staged: Query,
orderBy: Query['builder']['queryData'][number]['orderBy'],
): Query => ({
...staged,
builder: {
...staged.builder,
queryData: staged.builder.queryData.map((queryData) => ({
...queryData,
orderBy,
})),
},
});
const listView = (
orderBy: Query['builder']['queryData'][number]['orderBy'],
) =>
viewFrom(
toSavedViewSpec({
query: withOrderBy(query(DataSource.LOGS), orderBy),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
it('opens a view saved with no order clean', () => {
const noOrder = listView([]);
expect(noOrder.spec.queries[0].spec).not.toHaveProperty('order');
expect(
hasUnsavedViewChanges({
view: noOrder,
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
}),
).toBe(false);
});
it('is dirty once the sort changes, and clean again after saving it', () => {
const noOrder = listView([]);
const sorted = withOrderBy(stagedFrom(noOrder, DataSource.LOGS), [
{ columnName: 'timestamp', order: 'asc' },
]);
expect(
hasUnsavedViewChanges({
view: noOrder,
stagedQuery: sorted,
panelType: PANEL_TYPES.LIST,
}),
).toBe(true);
const saved = listView([{ columnName: 'timestamp', order: 'asc' }]);
expect(
hasUnsavedViewChanges({
view: saved,
stagedQuery: stagedFrom(saved, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
}),
).toBe(false);
});
it('opens a legacy view sorted by timestamp and id clean', () => {
const legacy = listView([
{ columnName: 'timestamp', order: 'desc' },
{ columnName: 'id', order: 'desc' },
]);
expect(legacy.spec.queries[0].spec).toHaveProperty('order.length', 2);
expect(
hasUnsavedViewChanges({
view: legacy,
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
}),
).toBe(false);
});
});
it('ignores formatting on traces', () => {
expect(
hasUnsavedViewChanges({

View File

@@ -1,43 +0,0 @@
import { tags } from '@lezer/highlight';
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
import { githubLightStyle } from '@uiw/codemirror-theme-github';
import { highlightQueryExpression } from '../highlightQueryExpression';
const EXPRESSION =
"service.name = 'frontend' AND duration_nano > 100 AND status IN ['ok', 'error']";
function stringColor(styles: typeof copilotDarkStyle): string | undefined {
return (styles ?? []).find(({ tag }) =>
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
)?.color;
}
describe('highlightQueryExpression', () => {
it.each([
['dark', true],
['light', false],
])('keeps every character of the expression in %s mode', (_, isDarkMode) => {
const parts = highlightQueryExpression(EXPRESSION, isDarkMode);
expect(parts.map(({ text }) => text).join('')).toBe(EXPRESSION);
});
it.each([
['dark', true, copilotDarkStyle],
['light', false, githubLightStyle],
])(
'colours string values like the %s query search theme',
(_, isDarkMode, styles) => {
const parts = highlightQueryExpression(EXPRESSION, isDarkMode);
expect(parts.find(({ text }) => text === "'frontend'")?.color).toBe(
stringColor(styles),
);
},
);
it('returns nothing for an empty expression', () => {
expect(highlightQueryExpression('', true)).toStrictEqual([]);
});
});

View File

@@ -1,34 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewQuerySummary } from '../types';
import { getSavedViewQuery } from './getSavedViewQuery';
export function getSavedViewQuerySummaries(
view: SavedviewtypesSavedViewDTO,
): SavedViewQuerySummary[] {
return getSavedViewQuery(view)
.builder.queryData.map((query): SavedViewQuerySummary => {
const aggregation = query.aggregations?.[0] as MetricAggregation | undefined;
return {
queryName: query.queryName,
expression: convertFiltersToExpressionWithExistingQuery(
query.filters ?? { items: [], op: 'AND' },
query.filter?.expression,
).filter.expression.trim(),
metric:
query.dataSource === DataSource.METRICS && aggregation?.metricName
? [
aggregation.metricName,
aggregation.timeAggregation,
aggregation.spaceAggregation,
]
.filter(Boolean)
.join(' · ')
: undefined,
};
})
.filter(({ expression, metric }) => expression.length > 0 || !!metric);
}

View File

@@ -1,22 +1,11 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { getSavedViewQuery } from './getSavedViewQuery';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
// Legacy context names the server stores under their v5 name.
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
tag: TelemetrytypesFieldContextDTO.attribute,
point: TelemetrytypesFieldContextDTO.attribute,
spanfield: TelemetrytypesFieldContextDTO.span,
logfield: TelemetrytypesFieldContextDTO.log,
tracefield: TelemetrytypesFieldContextDTO.trace,
};
// The server drops empty values when it stores a query, while the query
// builder fills them with defaults, so both sides lose them before comparing.
function withoutEmptyValues(value: unknown): unknown {
@@ -28,12 +17,7 @@ function withoutEmptyValues(value: unknown): unknown {
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, item]) => {
if (key === 'fieldContext' && typeof item === 'string') {
return [key, FIELD_CONTEXT_ALIASES[item] ?? item] as const;
}
return [key, withoutEmptyValues(item)] as const;
})
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
.filter(([, item]) => item !== undefined);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
@@ -43,8 +27,8 @@ function withoutEmptyValues(value: unknown): unknown {
return value;
}
// Both sides go through the v5 mapper, so fields it never writes (reduceTo on
// a graph in older views) drop out; the builder's shape gets different defaults.
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
// side gets different defaults.
export function hasUnsavedViewChanges({
view,
stagedQuery,
@@ -58,16 +42,15 @@ export function hasUnsavedViewChanges({
return true;
}
const { queries: storedQueries } = mapCompositeQueryFromQuery(
getSavedViewQuery(view),
panelType,
);
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
stagedQuery,
panelType,
);
if (
!isEqual(withoutEmptyValues(storedQueries), withoutEmptyValues(stagedQueries))
!isEqual(
withoutEmptyValues(view.spec.queries),
withoutEmptyValues(stagedQueries),
)
) {
return true;
}

View File

@@ -1,58 +0,0 @@
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { Highlighter, highlightTree, Tag } from '@lezer/highlight';
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
import { githubLightStyle } from '@uiw/codemirror-theme-github';
import { HighlightedQueryPart } from '../types';
function createThemeHighlighter(styles: typeof copilotDarkStyle): Highlighter {
const colorByTag = new Map<Tag, string>();
(styles ?? []).forEach(({ tag, color }) => {
if (typeof color !== 'string') {
return;
}
(Array.isArray(tag) ? tag : [tag]).forEach((themeTag: Tag) => {
if (!colorByTag.has(themeTag)) {
colorByTag.set(themeTag, color);
}
});
});
return {
style: (tags): string | null => {
const match = tags
.flatMap((tag) => tag.set)
.find((candidate) => colorByTag.has(candidate));
return match ? (colorByTag.get(match) ?? null) : null;
},
};
}
const DARK_HIGHLIGHTER = createThemeHighlighter(copilotDarkStyle);
const LIGHT_HIGHLIGHTER = createThemeHighlighter(githubLightStyle);
export function highlightQueryExpression(
expression: string,
isDarkMode: boolean,
): HighlightedQueryPart[] {
const tree = javascriptLanguage.parser.parse(expression);
const parts: HighlightedQueryPart[] = [];
let position = 0;
highlightTree(
tree,
isDarkMode ? DARK_HIGHLIGHTER : LIGHT_HIGHLIGHTER,
(from, to, color) => {
if (from > position) {
parts.push({ from: position, text: expression.slice(position, from) });
}
parts.push({ from, text: expression.slice(from, to), color });
position = to;
},
);
if (position < expression.length) {
parts.push({ from: position, text: expression.slice(position) });
}
return parts;
}

View File

@@ -1,19 +0,0 @@
import { QueryClient } from 'react-query';
import {
invalidateGetSavedView,
invalidateListSavedViews,
} from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
export async function invalidateSavedViews(
queryClient: QueryClient,
source: SavedviewtypesSourceDTO,
id?: string,
): Promise<void> {
await Promise.all([
invalidateListSavedViews(queryClient),
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
// The saved views bar still lists through v1.
queryClient.invalidateQueries([{ sourcepage: source }]),
]);
}

View File

@@ -719,12 +719,18 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToTracesFunnel, () =>
onClickHandler(ROUTES.TRACES_FUNNELS, null),
);
registerShortcut(GlobalShortcuts.NavigateToTracesViews, () =>
onClickHandler(ROUTES.TRACES_SAVE_VIEWS, null),
);
registerShortcut(GlobalShortcuts.NavigateToMetricsSummary, () =>
onClickHandler(ROUTES.METRICS_EXPLORER, null),
);
registerShortcut(GlobalShortcuts.NavigateToMetricsExplorer, () =>
onClickHandler(ROUTES.METRICS_EXPLORER_EXPLORER, null),
);
registerShortcut(GlobalShortcuts.NavigateToMetricsViews, () =>
onClickHandler(ROUTES.METRICS_EXPLORER_VIEWS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettings, () =>
onClickHandler(ROUTES.SETTINGS, null),
);
@@ -749,6 +755,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToLogsPipelines, () =>
onClickHandler(ROUTES.LOGS_PIPELINES, null),
);
registerShortcut(GlobalShortcuts.NavigateToLogsViews, () =>
onClickHandler(ROUTES.LOGS_SAVE_VIEWS, null),
);
return (): void => {
deregisterShortcut(GlobalShortcuts.NavigateToHome);
deregisterShortcut(GlobalShortcuts.NavigateToServices);
@@ -761,6 +770,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToTracesFunnel);
deregisterShortcut(GlobalShortcuts.NavigateToMetricsSummary);
deregisterShortcut(GlobalShortcuts.NavigateToMetricsExplorer);
deregisterShortcut(GlobalShortcuts.NavigateToMetricsViews);
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
@@ -769,6 +779,8 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
deregisterShortcut(GlobalShortcuts.NavigateToTracesViews);
};
}, [deregisterShortcut, onClickHandler, registerShortcut]);

View File

@@ -43,16 +43,8 @@ export const getFieldKeysQueryOptions = (
refetchOnWindowFocus: false,
});
export type FieldKeysQueryOverrides = Partial<
Omit<FieldKeysQueryOptions, 'queryKey' | 'queryFn' | 'select'>
>;
export const useFieldKeysSuggestion = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
options?: FieldKeysQueryOverrides,
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
useQuery({
...getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType),
...options,
});
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));

View File

@@ -30,10 +30,7 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const builderQueryTypes: Record<
string,
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
'builder_query' | 'builder_formula' | 'builder_trace_operator'
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
@@ -47,14 +44,6 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
);
builderQueryTypes[spec.name] = 'builder_query';
}
} else if (q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: 'builder_ai_query',
};
builderQueryTypes[spec.name] = 'builder_ai_query';
}
} else if (q.type === 'builder_formula') {
if (spec.name) {
builderQueries[spec.name] = convertQueryBuilderFormulaToIBuilderFormula(

View File

@@ -15,10 +15,7 @@ export const transformQueryBuilderDataModel = (
data: BuilderQueryDataResourse,
queryTypes?: Record<
string,
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
'builder_query' | 'builder_formula' | 'builder_trace_operator'
>,
): QueryBuilderData => {
const queryData: QueryBuilderData['queryData'] = [];

View File

@@ -1,53 +0,0 @@
import type { Querybuildertypesv5QueryEnvelopeDTO } from 'api/generated/services/sigNoz.schemas';
import {
isAIBuilderEnvelope,
isBuilderEnvelope,
isBuilderPluginKind,
} from '../builderEnvelope';
const envelope = (type: string): Querybuildertypesv5QueryEnvelopeDTO =>
({ type, spec: {} }) as unknown as Querybuildertypesv5QueryEnvelopeDTO;
describe('builder envelope predicates', () => {
describe('isBuilderEnvelope', () => {
it.each(['builder_query', 'builder_ai_query'])('accepts %s', (type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(true);
});
it.each([
'builder_formula',
'builder_trace_operator',
'promql',
'clickhouse_sql',
])('rejects %s', (type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(false);
});
});
describe('isAIBuilderEnvelope', () => {
it('accepts builder_ai_query', () => {
expect(isAIBuilderEnvelope(envelope('builder_ai_query'))).toBe(true);
});
it('rejects builder_query', () => {
expect(isAIBuilderEnvelope(envelope('builder_query'))).toBe(false);
});
});
describe('isBuilderPluginKind', () => {
it.each(['signoz/BuilderQuery', 'signoz/AIBuilderQuery'])(
'accepts %s',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(true);
},
);
it.each(['signoz/CompositeQuery', 'signoz/PromQLQuery'])(
'rejects %s',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(false);
},
);
});
});

View File

@@ -61,16 +61,6 @@ describe('panelTypeDataSourceFormValuesMap', () => {
expect(uncovered).toStrictEqual([]);
});
// The AI tab is derived from this tag, so a panel that drops it on a switch falls
// back to plain Query Builder mid-edit.
it('carries the AI query tag into every panel type', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.TRACES].builder.queryData).toContain(
'builderQueryType',
);
});
});
it('shares one builder surface between logs and traces', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(

View File

@@ -1,47 +0,0 @@
import type {
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginKindDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
} from 'api/generated/services/sigNoz.schemas';
const BUILDER_ENVELOPE_TYPES: string[] = [
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
];
/** True for `builder_query` and `builder_ai_query`. Formulas and trace operators are excluded. */
export function isBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): envelope is
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
return BUILDER_ENVELOPE_TYPES.includes(envelope.type);
}
export function isAIBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): envelope is Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
return (
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query
);
}
const BUILDER_PLUGIN_KINDS: string[] = [
DashboardtypesQueryPluginKindDTO['signoz/BuilderQuery'],
DashboardtypesQueryPluginKindDTO['signoz/AIBuilderQuery'],
];
/**
* True for a bare `signoz/BuilderQuery` or `signoz/AIBuilderQuery` plugin (the form List
* panels use). Inside a CompositeQuery an AI query is a `builder_ai_query` envelope, matched
* by `isBuilderEnvelope`.
*/
export function isBuilderPluginKind(kind: string): boolean {
return BUILDER_PLUGIN_KINDS.includes(kind);
}

View File

@@ -44,8 +44,6 @@ const IS_CARRIED = {
orderBy: true,
reduceTo: true,
legend: true,
// Identifies an AI query, so the panel keeps authoring in AI mode after the switch.
builderQueryType: true,
// `dataSource` is appended by the provider; the rest drive surfaces this switch
// does not reach.
// Heatmap only; listed for it below.
@@ -56,6 +54,7 @@ const IS_CARRIED = {
offset: false,
selectColumns: false,
source: false,
builderQueryType: false,
} satisfies Record<BuilderField, boolean>;
function omit(

View File

@@ -1,51 +0,0 @@
import {
BuilderVariant,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
/** Which variant the `builder` slot holds, whatever query type is active. */
export function getBuilderVariant(builder: Query['builder']): BuilderVariant {
return builder.queryData[0]?.builderQueryType === QueryMode.AI_QUERY_BUILDER
? BuilderVariant.AI
: BuilderVariant.MAIN;
}
/** The variant a tab authors; `undefined` for tabs that own their own slot. */
export function toBuilderVariant(mode: QueryMode): BuilderVariant | undefined {
if (mode === QueryMode.AI_QUERY_BUILDER) {
return BuilderVariant.AI;
}
return mode === QueryMode.QUERY_BUILDER ? BuilderVariant.MAIN : undefined;
}
/** The tab a query belongs to: its query type, or AI when a builder query carries the tag. */
export function getQueryMode(query: Query): QueryMode {
if (query.queryType !== QueryMode.QUERY_BUILDER) {
return query.queryType;
}
return getBuilderVariant(query.builder) === BuilderVariant.AI
? QueryMode.AI_QUERY_BUILDER
: QueryMode.QUERY_BUILDER;
}
/** Query Builder and AI both author `Query.builder`; the other modes own their own slot. */
export function isBuilderMode(mode: QueryMode): boolean {
return toBuilderVariant(mode) !== undefined;
}
/** Drops the AI tag, leaving the same query readable as a plain builder query. */
export function withoutAIQueryTag(query: Query): Query {
if (getBuilderVariant(query.builder) !== BuilderVariant.AI) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map(
({ builderQueryType: _tag, ...queryData }) => queryData,
),
},
};
}

View File

@@ -61,11 +61,11 @@ function AllErrors(): JSX.Element {
className="all-errors-page"
contentClassName="all-errors-right-section"
showFilters={showFilters}
onToggleFilters={handleFilterVisibilityChange}
quickFilterProps={{
className: 'qf-exceptions',
source: QuickFiltersSource.EXCEPTIONS,
signal: SignalType.EXCEPTIONS,
handleFilterVisibilityChange,
useFieldApis: quickFilterFieldApis,
}}
>

View File

@@ -80,31 +80,4 @@ describe('getPanelIdsReferencingVariable', () => {
);
expect(ids.sort()).toStrictEqual(['p1', 'p3']);
});
it('includes an AI list panel and an AI composite panel', () => {
const aiList = listPanel('service.name IN $svc');
(
aiList as {
spec: { queries: { spec: { plugin: { kind: string } } }[] };
}
).spec.queries[0].spec.plugin.kind = 'signoz/AIBuilderQuery';
const aiComposite = compositePanel('service.name IN $svc');
(
aiComposite as {
spec: {
queries: {
spec: { plugin: { spec: { queries: { type: string }[] } } };
}[];
};
}
).spec.queries[0].spec.plugin.spec.queries[0].type = 'builder_ai_query';
const ids = getPanelIdsReferencingVariable(
panels({ aiList, aiComposite }),
'service.name',
'svc',
);
expect(ids.sort()).toStrictEqual(['aiComposite', 'aiList']);
});
});

View File

@@ -6,11 +6,6 @@ import type {
Querybuildertypesv5QueryEnvelopeBuilderDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
isBuilderEnvelope,
isBuilderPluginKind,
} from 'lib/query/builderEnvelope';
function clauseFor(attribute: string, variableName: string): string {
return `${attribute} IN $${variableName}`;
}
@@ -25,13 +20,15 @@ function forEachBuilderSpec(
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? []).filter(isBuilderEnvelope).forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (isBuilderPluginKind(plugin.kind)) {
(composite.queries ?? [])
.filter((envelope) => envelope.type === 'builder_query')
.forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (plugin.kind === 'signoz/BuilderQuery') {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}

View File

@@ -11,7 +11,6 @@ import {
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { getTextPanelBody } from './getTextPanelBody';
import { dtoToFormModel } from '../variableAdapters';
@@ -55,7 +54,7 @@ function envelopeReferenceText(
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (isBuilderEnvelope(envelope)) {
if (envelope.type === 'builder_query') {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
@@ -233,7 +232,7 @@ export function findApplyUsages(
});
};
if (isBuilderEnvelope(envelope)) {
if (envelope.type === 'builder_query') {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;
@@ -295,7 +294,7 @@ export function isVariableAppliedToAllPanels(
return true;
}
return toQueryEnvelopes(queries).every((envelope) => {
if (isBuilderEnvelope(envelope)) {
if (envelope.type === 'builder_query') {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;

View File

@@ -15,10 +15,10 @@ import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import PanelTitleInput from './PanelTitleInput/PanelTitleInput';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
@@ -106,9 +106,12 @@ function ConfigPane({
<div className={styles.group}>
<div className={styles.field}>
<Typography.Text>Title</Typography.Text>
<PanelTitleInput
<Input
data-testid="panel-editor-v2-title"
value={spec.display.name}
onChange={(value): void => setDisplayField('name', value)}
placeholder="Panel title"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => setDisplayField('name', e.target.value)}
/>
</div>

View File

@@ -1,107 +0,0 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { Input } from '@signozhq/ui/input';
import { AutoComplete } from 'antd';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import { findVariableToken, insertVariable } from './variableToken';
import styles from './PanelTitleInput.module.scss';
interface PanelTitleInputProps {
value: string;
onChange: (value: string) => void;
}
interface VariableOption {
/** Whole title after insertion, so antd's change event carries it. */
value: string;
label: string;
cursor: number;
}
function PanelTitleInput({
value,
onChange,
}: PanelTitleInputProps): JSX.Element {
const variableNames = useDashboardVariableNames();
const inputRef = useRef<HTMLInputElement>(null);
const pendingCursor = useRef<number | null>(null);
const [cursor, setCursor] = useState(0);
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
const options = useMemo<VariableOption[]>(() => {
const token = findVariableToken(value, cursor);
if (!token) {
return [];
}
const query = token.query.toLowerCase();
return variableNames
.filter((name) => name.toLowerCase().startsWith(query))
.map((name) => {
const next = insertVariable(value, token, name);
return { value: next.text, label: name, cursor: next.cursor };
});
}, [value, cursor, variableNames]);
useLayoutEffect(() => {
if (pendingCursor.current === null) {
return;
}
inputRef.current?.setSelectionRange(
pendingCursor.current,
pendingCursor.current,
);
setCursor(pendingCursor.current);
pendingCursor.current = null;
}, [value]);
const syncCursor = (): void => {
setCursor(inputRef.current?.selectionStart ?? 0);
};
const handleChange = (next: string): void => {
const picked = options.find((option) => option.value === next);
if (picked) {
pendingCursor.current = picked.cursor;
setDismissed(true);
} else {
setDismissed(false);
syncCursor();
}
onChange(next);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Escape') {
setDismissed(true);
}
};
return (
<AutoComplete
className={styles.autoComplete}
value={value}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
options={options}
open={focused && !dismissed && options.length > 0}
filterOption={false}
onChange={handleChange}
>
<Input
ref={inputRef}
testId="panel-editor-v2-title"
placeholder="Panel title"
onKeyDown={handleKeyDown}
onKeyUp={syncCursor}
onClick={syncCursor}
onFocus={(): void => setFocused(true)}
onBlur={(): void => setFocused(false)}
/>
</AutoComplete>
);
}
export default PanelTitleInput;

View File

@@ -1,51 +0,0 @@
import { useState } from 'react';
import { render, screen, userEvent } from 'tests/test-utils';
import PanelTitleInput from '../PanelTitleInput';
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({
useDashboardVariableNames: (): string[] => ['service.name', 'env'],
}),
);
function Harness({ initial = '' }: { initial?: string }): JSX.Element {
const [value, setValue] = useState(initial);
return <PanelTitleInput value={value} onChange={setValue} />;
}
describe('PanelTitleInput', () => {
it('suggests no variables until a `$` is typed', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.type(screen.getByTestId('panel-editor-v2-title'), 'Latency');
expect(screen.queryByText('env')).not.toBeInTheDocument();
});
it('suggests variables matching the text after `$`', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.type(
screen.getByTestId('panel-editor-v2-title'),
'Latency of $se',
);
await expect(screen.findByText('service.name')).resolves.toBeInTheDocument();
expect(screen.queryByText('env')).not.toBeInTheDocument();
});
it('inserts the picked variable into the title', async () => {
const user = userEvent.setup();
render(<Harness />);
const input = screen.getByTestId('panel-editor-v2-title');
await user.type(input, 'Latency of $se');
await user.click(await screen.findByText('service.name'));
expect(input).toHaveValue('Latency of $service.name');
});
});

View File

@@ -1,30 +0,0 @@
import { findVariableToken, insertVariable } from '../variableToken';
describe('findVariableToken', () => {
it('finds the token being typed before the cursor', () => {
expect(findVariableToken('Latency of $ser', 15)).toStrictEqual({
start: 11,
query: 'ser',
});
});
it('matches a bare `$`', () => {
expect(findVariableToken('by $', 4)).toStrictEqual({ start: 3, query: '' });
});
it('ignores a token the cursor has moved past', () => {
expect(findVariableToken('$env in prod', 12)).toBeNull();
});
});
describe('insertVariable', () => {
it('replaces the whole token, including text after the cursor', () => {
const token = { start: 4, query: 'ser' };
expect(
insertVariable('p99 $service_x for', token, 'service.name'),
).toStrictEqual({
text: 'p99 $service.name for',
cursor: 17,
});
});
});

View File

@@ -1,30 +0,0 @@
const TOKEN_BEFORE_CURSOR = /\$([\w.]*)$/;
const IDENTIFIER_PREFIX = /^[\w.]*/;
export interface VariableToken {
start: number;
query: string;
}
export function findVariableToken(
text: string,
cursor: number,
): VariableToken | null {
const match = TOKEN_BEFORE_CURSOR.exec(text.slice(0, cursor));
if (!match) {
return null;
}
return { start: match.index, query: match[1] };
}
/** Also replaces identifier chars after the cursor: `$ser|vice` leaves no `vice`. */
export function insertVariable(
text: string,
token: VariableToken,
name: string,
): { text: string; cursor: number } {
const before = text.slice(0, token.start);
const after = text.slice(token.start + 1).replace(IDENTIFIER_PREFIX, '');
const inserted = `${before}$${name}`;
return { text: `${inserted}${after}`, cursor: inserted.length };
}

View File

@@ -8,7 +8,6 @@ import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
import ConfigField from '../controls/ConfigField/ConfigField';
import styles from './PanelTypeSwitcher.module.scss';
@@ -103,7 +102,6 @@ function PanelTypeSwitcher({
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerContentProps={JS_DRIVEN_SLIDE}
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser

View File

@@ -71,29 +71,19 @@ describe('PanelTypeSwitcher', () => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => {
const signals =
mockGetPanelDefinition.mockImplementation((kind: string) => ({
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'];
const queryTypes = SUPPORTED_QUERY_TYPES[kind] ?? [
: ['metrics', 'logs', 'traces'],
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
];
return {
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedQueryModesKindMap: Object.fromEntries(
queryTypes.map((queryType) => [
queryType,
queryType === EQueryType.QUERY_BUILDER
? { kind: 'signal', signals }
: { kind: 'signal-less' },
]),
),
};
});
],
}));
});
it('shows the current type and switches to the chosen one', async () => {

View File

@@ -23,11 +23,6 @@ jest.mock(
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({ useDashboardVariableNames: (): string[] => [] }),
);
function textSpec(): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Runbook', description: 'steps' },

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
import type { VariableItem } from './types';
@@ -21,7 +22,15 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
export function useContextLinkVariables(): VariableItem[] {
const { currentQuery } = useQueryBuilder();
const dashboardVariableNames = useDashboardVariableNames();
const { variables: variableDtos } = useDashboardFetchRequired();
const dashboardVariableNames = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variableDtos],
);
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
const fieldVariableNames = useMemo(() => {

Some files were not shown because too many files have changed in this diff Show More