mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-14 22:20:49 +01:00
Compare commits
13 Commits
nv/context
...
ns/read-at
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80efc5218 | ||
|
|
ecc2219f9c | ||
|
|
de4691ad0a | ||
|
|
c8fa90bab0 | ||
|
|
aaec7c89c5 | ||
|
|
57c193ec8f | ||
|
|
da556abac1 | ||
|
|
cc8440248b | ||
|
|
e84ddfb61e | ||
|
|
dc9fda2916 | ||
|
|
670677bae2 | ||
|
|
e4d66c5413 | ||
|
|
1371cb0beb |
@@ -17,3 +17,4 @@ 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.
|
||||
|
||||
@@ -1,5 +1,219 @@
|
||||
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:
|
||||
@@ -3367,6 +3581,69 @@ 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:
|
||||
@@ -4420,6 +4697,7 @@ components:
|
||||
DashboardtypesQueryPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
|
||||
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
@@ -4429,6 +4707,7 @@ components:
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
|
||||
@@ -4438,12 +4717,25 @@ 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:
|
||||
@@ -10225,6 +10517,16 @@ 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:
|
||||
@@ -10267,6 +10569,10 @@ components:
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
- formatted_input
|
||||
- formatted_output
|
||||
- formatter
|
||||
- formatter_warnings
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
@@ -16134,9 +16440,12 @@ paths:
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
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.
|
||||
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.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@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",
|
||||
|
||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -62,6 +62,9 @@ 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)
|
||||
|
||||
@@ -4,6 +4,264 @@
|
||||
* * 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
|
||||
@@ -4211,195 +4469,10 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAreaFillModeDTO {
|
||||
solid = 'solid',
|
||||
gradient = 'gradient',
|
||||
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
|
||||
traces = 'traces',
|
||||
}
|
||||
/**
|
||||
* @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 {
|
||||
export interface Querybuildertypesv5TraceAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4638,13 +4711,274 @@ 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
|
||||
@@ -4835,17 +5169,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TraceAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
|
||||
traces = 'traces',
|
||||
}
|
||||
@@ -5404,6 +5727,18 @@ 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',
|
||||
}
|
||||
@@ -5695,6 +6030,7 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
|
||||
|
||||
export type DashboardtypesQueryPluginDTO =
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
|
||||
@@ -6373,6 +6709,7 @@ 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',
|
||||
@@ -11452,6 +11789,16 @@ export interface SpantypesThreadSpanDTO {
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
formatted_input: AiobservabilitytypesInputMessagesDTO;
|
||||
formatted_output: AiobservabilitytypesOutputMessagesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
formatter: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatter_warnings: string[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
|
||||
@@ -4,7 +4,11 @@ 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 { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getBuilderVariant } from 'lib/query/queryMode';
|
||||
import {
|
||||
BuilderVariant,
|
||||
IBuilderTraceOperator,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from './queryBuilderFields.types';
|
||||
@@ -60,8 +64,16 @@ 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;
|
||||
@@ -74,15 +86,20 @@ 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(
|
||||
() =>
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
prefix={<RefreshCw size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
{showFilterCollapse && (
|
||||
{showFilterCollapse && handleFilterVisibilityChange && (
|
||||
<TooltipSimple title="Collapse Filters">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -7,13 +8,17 @@ 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 = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
type QuickFiltersElementProps = Omit<
|
||||
JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>,
|
||||
'handleFilterVisibilityChange'
|
||||
>;
|
||||
|
||||
// What the page configures; the layout adds the open / close wiring.
|
||||
@@ -26,6 +31,7 @@ export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps?: QuickFiltersElementProps;
|
||||
savedViewProps?: SavedViewsElementProps;
|
||||
showFilters: boolean;
|
||||
onToggleFilters?: () => void;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
@@ -36,6 +42,7 @@ function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
savedViewProps,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
@@ -60,6 +67,7 @@ function QuickFiltersLayout({
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
@@ -71,29 +79,32 @@ function QuickFiltersLayout({
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -35,20 +36,28 @@ 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 };
|
||||
|
||||
@@ -233,8 +242,54 @@ 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-close')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-panel-header'),
|
||||
).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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SIDEBAR_TOOLTIP_DELAY_MS = 500;
|
||||
@@ -27,18 +27,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.qf-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qf-header-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-6);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +52,17 @@
|
||||
}
|
||||
|
||||
.search {
|
||||
.ant-input {
|
||||
background-color: var(--l1-background);
|
||||
height: 46px;
|
||||
border-radius: 0;
|
||||
}
|
||||
--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);
|
||||
}
|
||||
|
||||
.qf-other-filters-list {
|
||||
@@ -125,6 +128,7 @@
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -43,14 +44,17 @@ function QuickFiltersSettings({
|
||||
<>
|
||||
<div className="qf-header">
|
||||
<div className="qf-title">
|
||||
<TableColumnsSplit width={16} height={16} />
|
||||
<TableColumnsSplit size={16} />
|
||||
Edit quick filters
|
||||
</div>
|
||||
<X
|
||||
className="qf-header-icon"
|
||||
width={16}
|
||||
height={16}
|
||||
<SignozButton
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
onClick={handleSettingsClose}
|
||||
data-testid="quick-filters-settings-close"
|
||||
/>
|
||||
</div>
|
||||
<section className="search">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
@@ -138,16 +137,6 @@ 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
|
||||
{
|
||||
@@ -170,16 +159,6 @@ 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
|
||||
{
|
||||
|
||||
@@ -10,17 +10,14 @@ 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',
|
||||
@@ -42,10 +39,8 @@ 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',
|
||||
@@ -55,7 +50,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsDescription = {
|
||||
@@ -69,17 +63,14 @@ 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',
|
||||
|
||||
@@ -26,9 +26,7 @@ function Explorer(): JSX.Element {
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
|
||||
@@ -35,6 +36,7 @@ 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[]
|
||||
@@ -351,7 +353,7 @@ export default function SavedViews({
|
||||
{selectedEntityViews.length > 0 ? renderSavedViews() : emptyStateCard()}
|
||||
</Card.Content>
|
||||
|
||||
{selectedEntityViews.length > 0 && (
|
||||
{!isSavedViewEnabled && selectedEntityViews.length > 0 && (
|
||||
<Card.Footer>
|
||||
<div className="services-footer home-data-card-footer">
|
||||
<Link to={footerLink}>
|
||||
|
||||
@@ -190,14 +190,12 @@ 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">
|
||||
|
||||
@@ -45,6 +45,7 @@ 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';
|
||||
@@ -82,6 +83,7 @@ function LogsExplorerViewsContainer({
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
@@ -516,13 +518,15 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
<GoToTop />
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
onExport={handleExport}
|
||||
sourcepage={DataSource.LOGS}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
onExport={handleExport}
|
||||
sourcepage={DataSource.LOGS}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -17,6 +18,7 @@ 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';
|
||||
@@ -38,6 +40,7 @@ function Explorer(): JSX.Element {
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
@@ -123,15 +126,14 @@ 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,
|
||||
}}
|
||||
>
|
||||
@@ -177,15 +179,17 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
signalSource="meter"
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
signalSource="meter"
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -3,7 +3,10 @@ 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';
|
||||
@@ -25,6 +28,7 @@ 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 {
|
||||
@@ -64,10 +68,12 @@ 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);
|
||||
@@ -350,12 +356,26 @@ function Explorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
showFilters={showSidebar}
|
||||
onToggleFilters={(): void => setShowSidebar(!showSidebar)}
|
||||
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}
|
||||
@@ -405,15 +425,17 @@ function Explorer(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
<MetricDetails
|
||||
metricName={selectedMetricName}
|
||||
|
||||
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
@@ -14,11 +14,12 @@ const NAME_INPUT_ID = 'save-view-name';
|
||||
|
||||
function SaveViewModal({
|
||||
mode,
|
||||
initialName = '',
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SaveViewModalProps): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [name, setName] = useState(initialName);
|
||||
const displayName = name.trim();
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
.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);
|
||||
}
|
||||
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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;
|
||||
56
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
56
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
@@ -15,6 +15,22 @@
|
||||
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 {
|
||||
@@ -32,6 +48,11 @@
|
||||
background: var(--callout-warning-background);
|
||||
color: var(--callout-warning-title);
|
||||
}
|
||||
|
||||
&.isError {
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
}
|
||||
}
|
||||
|
||||
.nameText {
|
||||
@@ -59,3 +80,7 @@
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
rotate: 270deg;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
|
||||
import { ArrowUpToLine, List, 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_TOAST_POSITION } from './constants';
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
SAVED_VIEW_LOAD_FAILED_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';
|
||||
@@ -19,31 +24,21 @@ 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, hasUnsavedChanges } = useActiveSavedView(source);
|
||||
const { view, isLoading, isError, hasUnsavedChanges } =
|
||||
useActiveSavedView(source);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
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 handleSave = useSaveNewView(createView);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
@@ -54,33 +49,51 @@ function SavedViewsHeader({
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = view?.spec.displayName ?? MY_VIEW_NAME;
|
||||
const isDirty = !!view && hasUnsavedChanges;
|
||||
const name = isError
|
||||
? SAVED_VIEW_LOAD_FAILED_NAME
|
||||
: (view?.spec.displayName ?? MY_VIEW_NAME);
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.header, { [styles.isDirty]: isDirty })}
|
||||
className={cx(styles.header, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
{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.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>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{!view && (
|
||||
@@ -92,7 +105,7 @@ function SavedViewsHeader({
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
)}
|
||||
{view && !isDirty && (
|
||||
{(isError || (view && !isDirty)) && (
|
||||
<SavedViewsIconButton
|
||||
title="Clear view"
|
||||
icon={<Undo2 size={14} />}
|
||||
@@ -119,12 +132,12 @@ function SavedViewsHeader({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isDirty && onOpenViews && (
|
||||
{onCollapse && (
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
title="Collapse Filters"
|
||||
icon={<ArrowUpToLine size={14} className={styles.collapseIcon} />}
|
||||
onClick={onCollapse}
|
||||
testId="saved-views-collapse"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
132
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
132
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
.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);
|
||||
}
|
||||
200
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
200
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
61
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
61
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
43
frontend/src/container/SavedViews/SavedViewsListSection.tsx
Normal file
43
frontend/src/container/SavedViews/SavedViewsListSection.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
@@ -10,12 +10,34 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
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);
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.titleText {
|
||||
color: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useState } from 'react';
|
||||
import { TowerControl, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
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({
|
||||
@@ -13,26 +17,38 @@ 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}
|
||||
>
|
||||
<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} />}
|
||||
{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} />}
|
||||
onClick={onClose}
|
||||
data-testid="saved-views-close"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
40
frontend/src/container/SavedViews/SavedViewsRowButton.tsx
Normal file
40
frontend/src/container/SavedViews/SavedViewsRowButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
44
frontend/src/container/SavedViews/SavedViewsRowMenu.tsx
Normal file
44
frontend/src/container/SavedViews/SavedViewsRowMenu.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
@@ -0,0 +1,263 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
'You have created a new view.',
|
||||
'View created',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
);
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
|
||||
@@ -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,16 +130,6 @@ 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));
|
||||
@@ -227,38 +217,6 @@ 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]);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
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'));
|
||||
});
|
||||
});
|
||||
102
frontend/src/container/SavedViews/__tests__/savedViewsApiMock.ts
Normal file
102
frontend/src/container/SavedViews/__tests__/savedViewsApiMock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
}
|
||||
@@ -19,12 +19,11 @@ 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';
|
||||
@@ -35,8 +34,7 @@ import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchPa
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
|
||||
export const API = 'http://localhost/api/v2/saved_views';
|
||||
import { TEST_USER_EMAIL } from './savedViewsApiMock';
|
||||
|
||||
// The server stores queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
@@ -78,17 +76,25 @@ 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: queryWith(dataSource, expression),
|
||||
query,
|
||||
panelType,
|
||||
displayName,
|
||||
});
|
||||
@@ -97,8 +103,9 @@ export function makeView({
|
||||
name: `${displayName.toLowerCase()}-abc`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'test@signoz.io',
|
||||
updatedBy: 'test@signoz.io',
|
||||
createdBy,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
@@ -153,69 +160,6 @@ 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
|
||||
@@ -274,21 +218,29 @@ 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={getAppContextMock('ADMIN')}>
|
||||
<TooltipProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>
|
||||
<ExplorerShell dataSource={dataSource} />
|
||||
{ui}
|
||||
</QueryBuilderProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TooltipProvider>
|
||||
<AppContext.Provider value={appContext}>
|
||||
<TimezoneProvider>
|
||||
<TooltipProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>
|
||||
<ExplorerShell dataSource={dataSource} />
|
||||
{ui}
|
||||
</QueryBuilderProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TooltipProvider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SaveViewModalMode } from './types';
|
||||
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
|
||||
|
||||
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
|
||||
|
||||
@@ -27,9 +28,33 @@ 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;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useActiveSavedView(
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo((): boolean => {
|
||||
if (!view) {
|
||||
if (!view || isError) {
|
||||
return false;
|
||||
}
|
||||
if (!isStagedQueryCurrent) {
|
||||
@@ -56,6 +56,7 @@ export function useActiveSavedView(
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isError,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
|
||||
22
frontend/src/container/SavedViews/hooks/useSaveNewView.ts
Normal file
22
frontend/src/container/SavedViews/hooks/useSaveNewView.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ 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';
|
||||
@@ -21,6 +19,7 @@ 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';
|
||||
@@ -33,6 +32,8 @@ 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';
|
||||
|
||||
@@ -54,6 +55,11 @@ 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();
|
||||
@@ -61,14 +67,7 @@ export function useSavedViewActions(
|
||||
useUpdateSavedView();
|
||||
|
||||
const invalidateViews = useCallback(
|
||||
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 }]),
|
||||
]);
|
||||
},
|
||||
(id?: string): Promise<void> => invalidateSavedViews(queryClient, source, id),
|
||||
[queryClient, source],
|
||||
);
|
||||
|
||||
@@ -95,14 +94,21 @@ 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 => {
|
||||
void invalidateListSavedViews(queryClient);
|
||||
if (optionsDataSource) {
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
optionsDataSource,
|
||||
);
|
||||
preferencesSlice.updateColumns(columns);
|
||||
if (formatting) {
|
||||
preferencesSlice.updateFormatting(formatting);
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
},
|
||||
[queryClient, selectView],
|
||||
[optionsDataSource, preferencesSlice, selectView],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 };
|
||||
}
|
||||
35
frontend/src/container/SavedViews/hooks/useSavedViewsList.ts
Normal file
35
frontend/src/container/SavedViews/hooks/useSavedViewsList.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { Link } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -57,10 +58,11 @@ export interface UseRestoreLastUsedViewArgs {
|
||||
selectView: UseSavedViewActionsResult['selectView'];
|
||||
}
|
||||
|
||||
export type SaveViewModalMode = 'create' | 'saveAsNew';
|
||||
export type SaveViewModalMode = 'create' | 'saveAsNew' | 'edit';
|
||||
|
||||
export interface SaveViewModalProps {
|
||||
mode: SaveViewModalMode;
|
||||
initialName?: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
// Resolves true when saved; the modal stays open otherwise.
|
||||
@@ -81,3 +83,127 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,169 @@ 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({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
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 {
|
||||
@@ -17,7 +28,12 @@ function withoutEmptyValues(value: unknown): unknown {
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
|
||||
.map(([key, item]) => {
|
||||
if (key === 'fieldContext' && typeof item === 'string') {
|
||||
return [key, FIELD_CONTEXT_ALIASES[item] ?? item] as const;
|
||||
}
|
||||
return [key, withoutEmptyValues(item)] as const;
|
||||
})
|
||||
.filter(([, item]) => item !== undefined);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
@@ -27,8 +43,8 @@ function withoutEmptyValues(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
|
||||
// side gets different defaults.
|
||||
// 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.
|
||||
export function hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
@@ -42,15 +58,16 @@ export function hasUnsavedViewChanges({
|
||||
return true;
|
||||
}
|
||||
|
||||
const { queries: storedQueries } = mapCompositeQueryFromQuery(
|
||||
getSavedViewQuery(view),
|
||||
panelType,
|
||||
);
|
||||
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
|
||||
stagedQuery,
|
||||
panelType,
|
||||
);
|
||||
if (
|
||||
!isEqual(
|
||||
withoutEmptyValues(view.spec.queries),
|
||||
withoutEmptyValues(stagedQueries),
|
||||
)
|
||||
!isEqual(withoutEmptyValues(storedQueries), withoutEmptyValues(stagedQueries))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 }]),
|
||||
]);
|
||||
}
|
||||
@@ -719,18 +719,12 @@ 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),
|
||||
);
|
||||
@@ -755,9 +749,6 @@ 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);
|
||||
@@ -770,7 +761,6 @@ 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);
|
||||
@@ -779,8 +769,6 @@ 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]);
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
|
||||
> = {};
|
||||
const builderQueryTypes: Record<
|
||||
string,
|
||||
'builder_query' | 'builder_formula' | 'builder_trace_operator'
|
||||
| 'builder_query'
|
||||
| 'builder_ai_query'
|
||||
| 'builder_formula'
|
||||
| 'builder_trace_operator'
|
||||
> = {};
|
||||
const promQueries: IPromQLQuery[] = [];
|
||||
const clickhouseQueries: IClickHouseQuery[] = [];
|
||||
@@ -44,6 +47,14 @@ 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(
|
||||
|
||||
@@ -15,7 +15,10 @@ export const transformQueryBuilderDataModel = (
|
||||
data: BuilderQueryDataResourse,
|
||||
queryTypes?: Record<
|
||||
string,
|
||||
'builder_query' | 'builder_formula' | 'builder_trace_operator'
|
||||
| 'builder_query'
|
||||
| 'builder_ai_query'
|
||||
| 'builder_formula'
|
||||
| 'builder_trace_operator'
|
||||
>,
|
||||
): QueryBuilderData => {
|
||||
const queryData: QueryBuilderData['queryData'] = [];
|
||||
|
||||
53
frontend/src/lib/query/__tests__/builderEnvelope.test.ts
Normal file
53
frontend/src/lib/query/__tests__/builderEnvelope.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,16 @@ 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(
|
||||
|
||||
47
frontend/src/lib/query/builderEnvelope.ts
Normal file
47
frontend/src/lib/query/builderEnvelope.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
}
|
||||
@@ -44,6 +44,8 @@ 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.
|
||||
@@ -54,7 +56,6 @@ const IS_CARRIED = {
|
||||
offset: false,
|
||||
selectColumns: false,
|
||||
source: false,
|
||||
builderQueryType: false,
|
||||
} satisfies Record<BuilderField, boolean>;
|
||||
|
||||
function omit(
|
||||
|
||||
51
frontend/src/lib/query/queryMode.ts
Normal file
51
frontend/src/lib/query/queryMode.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -80,4 +80,31 @@ 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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ 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}`;
|
||||
}
|
||||
@@ -20,15 +25,13 @@ function forEachBuilderSpec(
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
(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') {
|
||||
(composite.queries ?? []).filter(isBuilderEnvelope).forEach((envelope) => {
|
||||
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
|
||||
if (spec) {
|
||||
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
});
|
||||
} else if (isBuilderPluginKind(plugin.kind)) {
|
||||
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -54,7 +55,7 @@ function envelopeReferenceText(
|
||||
const spec = envelope.spec as
|
||||
| { query?: string; filter?: { expression?: string } }
|
||||
| undefined;
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const text = spec?.filter?.expression;
|
||||
return typeof text === 'string' ? { kind: 'builder', text } : null;
|
||||
}
|
||||
@@ -232,7 +233,7 @@ export function findApplyUsages(
|
||||
});
|
||||
};
|
||||
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
@@ -294,7 +295,7 @@ export function isVariableAppliedToAllPanels(
|
||||
return true;
|
||||
}
|
||||
return toQueryEnvelopes(queries).every((envelope) => {
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
|
||||
@@ -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,12 +106,9 @@ function ConfigPane({
|
||||
<div className={styles.group}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Title</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-title"
|
||||
<PanelTitleInput
|
||||
value={spec.display.name}
|
||||
placeholder="Panel title"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => setDisplayField('name', e.target.value)}
|
||||
onChange={(value): void => setDisplayField('name', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.autoComplete {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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;
|
||||
@@ -0,0 +1,51 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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 };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -102,6 +103,7 @@ function PanelTypeSwitcher({
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-switcher-drawer"
|
||||
drawerContentProps={JS_DRIVEN_SLIDE}
|
||||
drawerDescriptionProps={{ className: styles.drawerBody }}
|
||||
>
|
||||
<PanelTypeBrowser
|
||||
|
||||
@@ -71,19 +71,29 @@ 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) => ({
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
supportedSignals:
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => {
|
||||
const signals =
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
|
||||
: ['metrics', 'logs', 'traces'];
|
||||
const queryTypes = 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 () => {
|
||||
|
||||
@@ -23,6 +23,11 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({ useDashboardVariableNames: (): string[] => [] }),
|
||||
);
|
||||
|
||||
function textSpec(): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Runbook', description: 'steps' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
|
||||
|
||||
import type { VariableItem } from './types';
|
||||
|
||||
@@ -22,15 +21,7 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
|
||||
export function useContextLinkVariables(): VariableItem[] {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { variables: variableDtos } = useDashboardFetchRequired();
|
||||
|
||||
const dashboardVariableNames = useMemo(
|
||||
() =>
|
||||
variableDtos
|
||||
.map((dto) => dtoToFormModel(dto).name)
|
||||
.filter((name): name is string => !!name),
|
||||
[variableDtos],
|
||||
);
|
||||
const dashboardVariableNames = useDashboardVariableNames();
|
||||
|
||||
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
|
||||
const fieldVariableNames = useMemo(() => {
|
||||
|
||||
@@ -14,8 +14,11 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
displayName: 'Time Series',
|
||||
icon: (): null => null,
|
||||
mode: 'query',
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
supportedQueryModesKindMap: {
|
||||
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
|
||||
clickhouse_sql: { kind: 'signal-less' },
|
||||
promql: { kind: 'signal-less' },
|
||||
},
|
||||
})),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
|
||||
@@ -2,26 +2,33 @@ import {
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Atom, Brain, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { clearPreviousQuery } from 'components/QueryBuilderV2/QueryV2/previousQuery.utils';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { isRawRequest } from '../../Panels/types/panelCapabilities';
|
||||
import {
|
||||
getQueryModeSignals,
|
||||
isRawRequest,
|
||||
} from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
import { getQueryMode, isBuilderMode } from 'lib/query/queryMode';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
@@ -29,6 +36,8 @@ interface PanelEditorQueryBuilderProps {
|
||||
/** The edited kind's definition — drives supported query types, the signals the
|
||||
* builder may be pointed at, and how it narrows the builder's fields. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Switch authoring tab; owned by the host, which remembers the Query Builder query. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
@@ -48,6 +57,7 @@ interface PanelEditorQueryBuilderProps {
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelDefinition,
|
||||
onChangeQueryMode,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
@@ -60,18 +70,16 @@ function PanelEditorQueryBuilder({
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
|
||||
useEffect(() => {
|
||||
clearPreviousQuery();
|
||||
return (): void => {
|
||||
clearPreviousQuery();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
|
||||
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
|
||||
@@ -88,10 +96,11 @@ function PanelEditorQueryBuilder({
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
const queryTypeComponents: Record<
|
||||
QueryMode,
|
||||
{ icon: ReactNode; label: string; component: ReactNode }
|
||||
> = {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
component: (
|
||||
@@ -99,7 +108,10 @@ function PanelEditorQueryBuilder({
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
allowedDataSources={panelDefinition.supportedSignals}
|
||||
allowedDataSources={getQueryModeSignals(
|
||||
panelDefinition.supportedQueryModesKindMap,
|
||||
QueryMode.QUERY_BUILDER,
|
||||
)}
|
||||
showTraceOperator={!isRawQuery}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
@@ -109,12 +121,12 @@ function PanelEditorQueryBuilder({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: {
|
||||
[QueryMode.CLICKHOUSE]: {
|
||||
icon: <Terminal size={14} />,
|
||||
label: 'ClickHouse Query',
|
||||
component: <ClickHouseQueryContainer />,
|
||||
},
|
||||
[EQueryType.PROM]: {
|
||||
[QueryMode.PROM]: {
|
||||
icon: (
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
@@ -123,9 +135,33 @@ function PanelEditorQueryBuilder({
|
||||
label: 'PromQL',
|
||||
component: <PromQLQueryContainer />,
|
||||
},
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
icon: <Brain size={14} />,
|
||||
label: 'AI Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
showTraceOperator={false}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
config={{
|
||||
initialDataSource: DataSource.TRACES,
|
||||
queryVariant: 'static',
|
||||
}}
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
const modes = Object.keys(
|
||||
panelDefinition.supportedQueryModesKindMap,
|
||||
) as QueryMode[];
|
||||
|
||||
return modes.map((queryType) => ({
|
||||
key: queryType,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
@@ -134,6 +170,8 @@ function PanelEditorQueryBuilder({
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
// QB wants a null data source and AI pins traces: both mounted, they overwrite each other's provider config in a loop.
|
||||
destroyInactiveTabPane: isBuilderMode(queryType),
|
||||
}));
|
||||
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
|
||||
|
||||
@@ -150,8 +188,8 @@ function PanelEditorQueryBuilder({
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
activeKey={getQueryMode(currentQuery)}
|
||||
onChange={onChangeQueryMode}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
|
||||
function QueryBuilderEditorPane({
|
||||
panelDefinition,
|
||||
isLoadingQueries,
|
||||
onChangeQueryMode,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
stickyHeader,
|
||||
@@ -16,6 +17,7 @@ function QueryBuilderEditorPane({
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
stickyHeader={stickyHeader}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
toPanelType,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
import { useQueryModeChange } from '../../hooks/useQueryModeChange';
|
||||
|
||||
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
|
||||
const mockQueryBuilderV2 = jest.fn();
|
||||
@@ -43,17 +49,41 @@ jest.mock('assets/Dashboard/PromQl', () => ({
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
|
||||
function renderBuilder(panelKind: string): void {
|
||||
render(
|
||||
const BUILDER_QUERY = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ dataSource: 'traces' }] },
|
||||
};
|
||||
const AI_QUERY = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
|
||||
},
|
||||
};
|
||||
|
||||
/** Stands in for the editor shell: owns the tab switch and its parked Query Builder query. */
|
||||
function BuilderHost({ panelKind }: { panelKind: PanelKind }): JSX.Element {
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const parkedBuilders = useRef<BuildersByVariant>({});
|
||||
const onChangeQueryMode = useQueryModeChange({
|
||||
panelType: toPanelType(panelKind),
|
||||
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
|
||||
parkedBuilders,
|
||||
});
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
onCancelQuery={jest.fn()}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBuilder(panelKind: string): void {
|
||||
render(<BuilderHost panelKind={panelKind as PanelKind} />);
|
||||
}
|
||||
|
||||
function lastQueryBuilderProps(): {
|
||||
panelType: string;
|
||||
isRawQuery: boolean;
|
||||
@@ -69,12 +99,12 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: BUILDER_QUERY,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the Query Builder tab for the List kind', () => {
|
||||
it('shows no ClickHouse or PromQL tab for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
@@ -90,12 +120,126 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all three tabs for the Time Series kind', () => {
|
||||
it('shows all four tabs for the Time Series kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the AI tab for the Table kind', () => {
|
||||
renderBuilder('signoz/TablePanel');
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the AI tab for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelEditorQueryBuilder AI tab', () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const updateAllQueriesOperators = jest.fn((query: unknown) => query);
|
||||
|
||||
function mockBuilder(currentQuery: unknown): void {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
updateAllQueriesOperators,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('activates the AI tab for an AI query and pins the builder to traces', () => {
|
||||
mockBuilder(AI_QUERY);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
|
||||
).toBeInTheDocument();
|
||||
expect(lastQueryBuilderProps()).toMatchObject({
|
||||
config: { initialDataSource: 'traces', queryVariant: 'static' },
|
||||
showTraceOperator: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds an AI traces query when switching to the AI tab', () => {
|
||||
mockBuilder(BUILDER_QUERY);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
fireEvent.click(screen.getByText('AI Query Builder'));
|
||||
|
||||
expect(updateAllQueriesOperators).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'graph',
|
||||
'traces',
|
||||
);
|
||||
const [seeded] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
expect(seeded.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(seeded.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('keeps the builder query when returning from ClickHouse', () => {
|
||||
const logsQuery = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ dataSource: 'logs', legend: 'kept' }] },
|
||||
};
|
||||
mockBuilder(logsQuery);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
fireEvent.click(screen.getByText('ClickHouse Query'));
|
||||
const [onClickHouse] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
expect(onClickHouse.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
// The builder slot already holds the Query Builder query — nothing to swap.
|
||||
expect(onClickHouse.builder).toBe(logsQuery.builder);
|
||||
});
|
||||
|
||||
it('shows a fresh default query when leaving AI', () => {
|
||||
mockBuilder(AI_QUERY);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
fireEvent.click(screen.getByText('Query Builder'));
|
||||
|
||||
const [next] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
expect(next.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(next.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
expect(next.builder.queryData[0].dataSource).toBe('metrics');
|
||||
});
|
||||
|
||||
it("keeps each tab's own query across Query Builder ↔ AI round trips", () => {
|
||||
const logsQuery = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ dataSource: 'logs' }] },
|
||||
};
|
||||
const host = (): JSX.Element => (
|
||||
<BuilderHost panelKind="signoz/TimeSeriesPanel" />
|
||||
);
|
||||
mockBuilder(logsQuery);
|
||||
const { rerender } = render(host());
|
||||
|
||||
fireEvent.click(screen.getByText('AI Query Builder'));
|
||||
const [aiQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
// The user writes an AI query, then goes back to Query Builder.
|
||||
const editedAIBuilder = {
|
||||
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
|
||||
};
|
||||
mockBuilder({ ...aiQuery, builder: editedAIBuilder });
|
||||
rerender(host());
|
||||
fireEvent.click(screen.getByText('Query Builder'));
|
||||
const [backToQB] = redirectWithQueryBuilderData.mock.calls[1];
|
||||
expect(backToQB.builder).toBe(logsQuery.builder);
|
||||
|
||||
mockBuilder(backToQB);
|
||||
rerender(host());
|
||||
fireEvent.click(screen.getByText('AI Query Builder'));
|
||||
const [backToAI] = redirectWithQueryBuilderData.mock.calls[2];
|
||||
expect(backToAI.builder).toBe(editedAIBuilder);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +247,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: BUILDER_QUERY,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
@@ -144,7 +288,7 @@ describe('PanelEditorQueryBuilder signal dropdown (driven by the capabilities gu
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: BUILDER_QUERY,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,8 @@ interface QueryEditorBodyProps {
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
/** Tab switch, owned by the shell alongside the kind switch it shares memory with. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +94,7 @@ function QueryEditorBody({
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
onChangeQueryMode,
|
||||
}: QueryEditorBodyProps): JSX.Element {
|
||||
// Read here rather than taken as props: this renders inside a loaded dashboard
|
||||
// subtree, so it resolves the same context every other consumer does.
|
||||
@@ -307,6 +310,7 @@ function QueryEditorBody({
|
||||
panelDefinition={panelDefinition}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
spec={spec}
|
||||
|
||||
@@ -60,8 +60,9 @@ jest.mock('../hooks/usePanelEditorQuerySync', () => ({
|
||||
}));
|
||||
|
||||
const mockUseTypeSwitch = jest.fn();
|
||||
jest.mock('../hooks/usePanelTypeSwitch', () => ({
|
||||
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
|
||||
jest.mock('../hooks/usePanelKindAndQueryModeSwitch', () => ({
|
||||
usePanelKindAndQueryModeSwitch: (args: unknown): unknown =>
|
||||
mockUseTypeSwitch(args),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
@@ -21,6 +21,8 @@ import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import { useQueryModeChange } from '../hooks/useQueryModeChange';
|
||||
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
@@ -82,10 +84,18 @@ function EditorRoute(): JSX.Element {
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
savedQueries: panel.spec.queries,
|
||||
});
|
||||
const panelDefinition = requireQueryPanelDefinition('signoz/TimeSeriesPanel');
|
||||
const parkedBuilders = useRef<BuildersByVariant>({});
|
||||
const onChangeQueryMode = useQueryModeChange({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
|
||||
parkedBuilders,
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
onCancelQuery={noop}
|
||||
|
||||
@@ -45,8 +45,16 @@ const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
|
||||
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
|
||||
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
|
||||
const SEED_V1 = {
|
||||
id: 'seed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const STAGED_V1 = {
|
||||
id: 'staged',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
|
||||
function makeDraft(
|
||||
queries = SAVED_QUERIES,
|
||||
@@ -74,7 +82,11 @@ function builderState(
|
||||
handleRunQuery: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
|
||||
currentQuery: {
|
||||
id: 'current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query,
|
||||
stagedQuery: STAGED_V1,
|
||||
handleRunQuery: jest.fn(),
|
||||
...overrides,
|
||||
@@ -396,8 +408,16 @@ describe('usePanelEditorQuerySync', () => {
|
||||
const EDITED_ENVELOPES = [
|
||||
{ id: 'edited-envelopes' },
|
||||
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
|
||||
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
|
||||
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
|
||||
const editedQuery = {
|
||||
id: 'edited',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const unchangedQuery = {
|
||||
id: 'unchanged',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
|
||||
beforeEach(() => {
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
@@ -468,4 +488,66 @@ describe('usePanelEditorQuerySync', () => {
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI queries are saved like any builder query', () => {
|
||||
const aiQuery = {
|
||||
id: 'ai',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
} as unknown as Query;
|
||||
|
||||
it('keeps the AI tag when seeding, so a saved AI panel reopens on the AI tab', () => {
|
||||
const savedAiQuery = {
|
||||
id: 'saved-ai',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
],
|
||||
},
|
||||
} as unknown as Query;
|
||||
mockFromPerses.mockReturnValue(savedAiQuery);
|
||||
setup({ savedQueries: SAVED_QUERIES });
|
||||
|
||||
const [seeded] = mockUseShareBuilderUrl.mock.calls[0];
|
||||
expect(seeded.defaultValue).toBe(savedAiQuery);
|
||||
});
|
||||
|
||||
it('serializes the live AI query, not the seed', () => {
|
||||
// Input-sensitive so the envelope compare reads dirty and names what was serialized.
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
query?.id === 'ai' ? CONVERTED_QUERIES : SAVED_QUERIES,
|
||||
);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery: aiQuery }));
|
||||
const { result } = setup({ savedQueries: SAVED_QUERIES });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('still saves a ClickHouse query while an AI query sits in the builder slot', () => {
|
||||
const clickHouseQuery = {
|
||||
...aiQuery,
|
||||
id: 'ch',
|
||||
queryType: 'clickhouse_sql',
|
||||
} as unknown as Query;
|
||||
// Input-sensitive so the envelope compare reads dirty and the normal path runs.
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
query?.id === 'ch' ? CONVERTED_QUERIES : SAVED_QUERIES,
|
||||
);
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: clickHouseQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_QUERIES });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,11 @@ import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../../Panels/capabilities';
|
||||
import { resolveQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
|
||||
import { usePanelKindAndQueryModeSwitch } from '../usePanelKindAndQueryModeSwitch';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
@@ -18,7 +18,10 @@ jest.mock('lib/query/panelQuery', () => ({
|
||||
handleQueryChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/capabilities', () => ({
|
||||
resolveQueryType: jest.fn(),
|
||||
resolveQueryMode: jest.fn(),
|
||||
getSupportedSignals: jest.fn(() => ['metrics']),
|
||||
getQueryPanelDefinition: jest.requireActual('../../../Panels/capabilities')
|
||||
.getQueryPanelDefinition,
|
||||
// Real predicate: these specs use real (query) kinds and the static path is
|
||||
// exercised through its own cases below.
|
||||
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
|
||||
@@ -36,7 +39,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
|
||||
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
@@ -79,14 +82,27 @@ const tableSpec = makeSpec(
|
||||
);
|
||||
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
|
||||
|
||||
function fakeQuery(
|
||||
id: string,
|
||||
queryType: string,
|
||||
queryData: Record<string, unknown>[] = [],
|
||||
): Query {
|
||||
return { id, queryType, builder: { queryData } } as unknown as Query;
|
||||
}
|
||||
|
||||
function builderState(currentQuery: Query): {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: jest.Mock;
|
||||
updateAllQueriesOperators: jest.Mock;
|
||||
} {
|
||||
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
|
||||
return {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn((query: Query) => query),
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelTypeSwitch', () => {
|
||||
describe('usePanelKindAndQueryModeSwitch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
|
||||
@@ -95,16 +111,16 @@ describe('usePanelTypeSwitch', () => {
|
||||
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
|
||||
// The guard owns coercion (tested in capabilities.test.ts); here it always
|
||||
// resolves to Query Builder so the coerced type flows into handleQueryChange.
|
||||
mockResolveQueryType.mockReturnValue('builder');
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
});
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
const state = builderState(fakeQuery('q', 'builder'));
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
@@ -118,12 +134,12 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = fakeQuery('table-current', 'builder');
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
@@ -146,7 +162,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('ts-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -155,7 +171,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -174,7 +190,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('keeps one query and drops the formulas when switching to a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('ts-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -186,7 +202,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -204,7 +220,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('swaps a percentile spatial aggregation for count when switching to a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('ts-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -222,7 +238,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -242,7 +258,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('swaps count back for a percentile when a histogram leaves a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('heatmap-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -263,7 +279,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.HEATMAP,
|
||||
setSpec,
|
||||
@@ -283,7 +299,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('leaves a non-histogram metric on sum when it leaves a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('heatmap-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -304,7 +320,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.HEATMAP,
|
||||
setSpec,
|
||||
@@ -318,11 +334,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
const promQuery = fakeQuery('prom', 'promql');
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -331,7 +347,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// The hook asks the guard to resolve the active query type against the new kind…
|
||||
expect(mockResolveQueryType).toHaveBeenCalledWith(
|
||||
expect(mockResolveQueryMode).toHaveBeenCalledWith(
|
||||
'signoz/ListPanel',
|
||||
'promql',
|
||||
);
|
||||
@@ -340,16 +356,224 @@ describe('usePanelTypeSwitch', () => {
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
it('keeps the AI tag when the new kind supports AI', () => {
|
||||
const setSpec = jest.fn();
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
|
||||
mockResolveQueryMode.mockReturnValue('builder_ai_query');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
|
||||
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).builder).toBe(aiQuery.builder);
|
||||
expect((queryArg as Query).builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the AI tag when the new kind does not support AI', () => {
|
||||
const setSpec = jest.fn();
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
const state = builderState(aiQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
mockHandleQueryChange.mockImplementation((_type, query) => query);
|
||||
// The guard coerces to Query Builder; the tag must not survive it.
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
|
||||
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
expect(redirected.builder.queryData[0].dataSource).toBe('traces');
|
||||
});
|
||||
|
||||
it('stays on the AI tab when returning to a kind last left on Query Builder', () => {
|
||||
const setSpec = jest.fn();
|
||||
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
let state = builderState(qbQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockHandleQueryChange.mockImplementation((_type, query) => query);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
// Leave Table on Query Builder — its stash holds the QB query.
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// On List the user moves to the AI tab, then returns to Table.
|
||||
state = builderState(aiQuery);
|
||||
mockResolveQueryMode.mockReturnValue('builder_ai_query');
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
const restored = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
// The kind's own display spec still comes back; only the query follows the tab.
|
||||
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restored.queries).toBe(CONVERTED);
|
||||
|
||||
// Table's Query Builder query waited behind the AI tab — the tab switch gives it back.
|
||||
act(() => result.current.onChangeQueryMode('builder'));
|
||||
const backToQB = state.redirectWithQueryBuilderData.mock.calls[1][0] as Query;
|
||||
expect(backToQB.builder).toBe(qbQuery.builder);
|
||||
});
|
||||
|
||||
describe('both builder tabs, per kind', () => {
|
||||
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
|
||||
// Table gets a Query Builder query and an AI one; the user leaves it on AI for List.
|
||||
function leaveTableOnAI(): {
|
||||
result: { current: ReturnType<typeof usePanelKindAndQueryModeSwitch> };
|
||||
rerender: (props: {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
}) => void;
|
||||
setSpec: jest.Mock;
|
||||
setState: (query: Query) => ReturnType<typeof builderState>;
|
||||
} {
|
||||
const setSpec = jest.fn();
|
||||
let state = builderState(qbQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockHandleQueryChange.mockImplementation((_type, query) => query);
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
const setState = (query: Query): ReturnType<typeof builderState> => {
|
||||
state = builderState(query);
|
||||
return state;
|
||||
};
|
||||
|
||||
act(() => result.current.onChangeQueryMode('builder_ai_query'));
|
||||
setState(aiQuery);
|
||||
rerender({ spec: tableSpec, panelType: PANEL_TYPES.TABLE });
|
||||
mockResolveQueryMode.mockReturnValue('builder_ai_query');
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
return { result, rerender, setSpec, setState };
|
||||
}
|
||||
|
||||
it('rebuilds the parked query for the new kind on a first visit', () => {
|
||||
const { result } = leaveTableOnAI();
|
||||
|
||||
// Table's Query Builder query was rebuilt for List, and is one tab away there.
|
||||
expect(mockHandleQueryChange).toHaveBeenCalledWith(
|
||||
'list',
|
||||
expect.objectContaining({ builder: qbQuery.builder }),
|
||||
PANEL_TYPES.TABLE,
|
||||
);
|
||||
act(() => result.current.onChangeQueryMode('builder'));
|
||||
const { redirectWithQueryBuilderData } =
|
||||
mockUseQueryBuilder.mock.results[
|
||||
mockUseQueryBuilder.mock.results.length - 1
|
||||
].value;
|
||||
const backToQB = redirectWithQueryBuilderData.mock.calls[
|
||||
redirectWithQueryBuilderData.mock.calls.length - 1
|
||||
][0] as Query;
|
||||
expect(backToQB.builder.queryData[0].dataSource).toBe('logs');
|
||||
expect(backToQB.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("brings back both tabs' queries when revisiting a kind", () => {
|
||||
const { result, rerender, setSpec, setState } = leaveTableOnAI();
|
||||
|
||||
// Back to Table from List, still on the AI tab.
|
||||
const state = setState(
|
||||
fakeQuery('list-ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]),
|
||||
);
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(restored.builder).toBe(aiQuery.builder);
|
||||
const restoredSpec = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restoredSpec.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restoredSpec.queries).toBe(TABLE_QUERIES);
|
||||
|
||||
// Table's Query Builder query is one tab away.
|
||||
act(() => result.current.onChangeQueryMode('builder'));
|
||||
const backToQB = state.redirectWithQueryBuilderData.mock
|
||||
.calls[1][0] as Query;
|
||||
expect(backToQB.builder).toBe(qbQuery.builder);
|
||||
});
|
||||
|
||||
it('swaps in the Query Builder query when revisiting a kind left on AI', () => {
|
||||
const { result, rerender, setSpec, setState } = leaveTableOnAI();
|
||||
|
||||
// On List the user is back on Query Builder, then returns to Table.
|
||||
const state = setState(
|
||||
fakeQuery('list-qb', 'builder', [{ dataSource: 'logs' }]),
|
||||
);
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(restored.builder).toBe(qbQuery.builder);
|
||||
// The stashed preview queries were the AI query's; they're rebuilt for the swap.
|
||||
const restoredSpec = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restoredSpec.queries).toBe(CONVERTED);
|
||||
|
||||
// Table's AI query is one tab away.
|
||||
act(() => result.current.onChangeQueryMode('builder_ai_query'));
|
||||
const backToAI = state.redirectWithQueryBuilderData.mock
|
||||
.calls[1][0] as Query;
|
||||
expect(backToAI.builder).toBe(aiQuery.builder);
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = fakeQuery('table-current', 'builder');
|
||||
const listQuery = fakeQuery('list-current', 'builder');
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelTypeSwitch({ ...props, setSpec }),
|
||||
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import {
|
||||
getSupportedSignals,
|
||||
requireQueryPanelDefinition,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
@@ -91,7 +94,7 @@ export function usePanelEditSession({
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = toPanelType(panelKind);
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
const defaultSignal = getSupportedSignals(panelKind)[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import {
|
||||
type BuilderVariant,
|
||||
type BuildersByVariant,
|
||||
type OrderByPayload,
|
||||
type Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
getQueryPanelDefinition,
|
||||
isStaticPanelKind,
|
||||
resolveQueryMode,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { getQueryModeSignals } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
|
||||
import {
|
||||
getBuilderVariant,
|
||||
getQueryMode,
|
||||
toBuilderVariant,
|
||||
withoutAIQueryTag,
|
||||
} from 'lib/query/queryMode';
|
||||
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
import {
|
||||
withPercentileHistogramAggregation,
|
||||
withSingleHeatmapQuery,
|
||||
} from '../utils/heatmapQuery';
|
||||
import { useQueryModeChange } from './useQueryModeChange';
|
||||
|
||||
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
|
||||
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
];
|
||||
|
||||
function withDefaultListOrder(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((qd) =>
|
||||
qd.orderBy && qd.orderBy.length > 0
|
||||
? qd
|
||||
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
query: Query;
|
||||
/** Both builder variants' queries as the kind was left. */
|
||||
builders: BuildersByVariant;
|
||||
}
|
||||
|
||||
interface UsePanelKindAndQueryModeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelKindAndQueryModeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
/** Switch authoring tab; shares the off-screen builder stash with the kind switch. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
* It also owns the tab switch, stashing the off-screen builder variant per kind.
|
||||
*/
|
||||
export function usePanelKindAndQueryModeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelKindAndQueryModeSwitchArgs): UsePanelKindAndQueryModeSwitchApi {
|
||||
const {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
updateAllQueriesOperators,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
// Builder queries for the tabs not on screen; the live one is `currentQuery.builder`.
|
||||
const parkedBuilders = useRef<BuildersByVariant>({});
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangeQueryMode = useQueryModeChange({
|
||||
panelType,
|
||||
supportedQueryModesKindMap:
|
||||
getQueryPanelDefinition(spec.plugin.kind as PanelKind)
|
||||
?.supportedQueryModesKindMap ?? {},
|
||||
parkedBuilders,
|
||||
});
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
|
||||
from: oldKind,
|
||||
to: newKind,
|
||||
});
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries,
|
||||
query,
|
||||
builders: {
|
||||
...parkedBuilders.current,
|
||||
[getBuilderVariant(query.builder)]: query.builder,
|
||||
},
|
||||
});
|
||||
|
||||
const newPanelType = toPanelType(newKind);
|
||||
const targetMode = resolveQueryMode(newKind, getQueryMode(query));
|
||||
const targetVariant = toBuilderVariant(targetMode);
|
||||
// An AI query is a builder query carrying the tag, so it rides on `builder`.
|
||||
const targetQueryType =
|
||||
targetMode === QueryMode.AI_QUERY_BUILDER
|
||||
? QueryMode.QUERY_BUILDER
|
||||
: targetMode;
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
// `createDefaultPanel`). The surrounding spec stays fully typed.
|
||||
const buildSpec = (
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesPanelSpecDTO => ({
|
||||
...currentSpec,
|
||||
plugin: {
|
||||
...currentSpec.plugin,
|
||||
kind: newKind,
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
});
|
||||
const rebuildForNewKind = (source: Query): Query => {
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...source, queryType: targetQueryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const ordered =
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
return newKind === 'signoz/HeatmapPanel'
|
||||
? withSingleHeatmapQuery(ordered)
|
||||
: withPercentileHistogramAggregation(ordered);
|
||||
};
|
||||
// Tab unused on this kind: AI gets a fresh seed, Query Builder reuses `source` untagged.
|
||||
const seedTargetTab = (source: Query): Query =>
|
||||
targetMode === QueryMode.AI_QUERY_BUILDER
|
||||
? {
|
||||
...source,
|
||||
builder: seedBuilderForMode({
|
||||
mode: targetMode,
|
||||
defaultSignal: getQueryModeSignals(
|
||||
getQueryPanelDefinition(newKind)?.supportedQueryModesKindMap,
|
||||
QueryMode.QUERY_BUILDER,
|
||||
)[0],
|
||||
panelType: newPanelType,
|
||||
updateAllQueriesOperators,
|
||||
}),
|
||||
}
|
||||
: withoutAIQueryTag(source);
|
||||
// Target tab's query comes from the stash; the rest stay parked.
|
||||
const unparkTargetTab = (
|
||||
source: Query,
|
||||
builders: BuildersByVariant,
|
||||
): { query: Query; parked: BuildersByVariant } => {
|
||||
const stored = targetVariant ? builders[targetVariant] : source.builder;
|
||||
const next = stored
|
||||
? { ...source, builder: stored }
|
||||
: seedTargetTab(source);
|
||||
const parked: BuildersByVariant = { ...builders };
|
||||
delete parked[getBuilderVariant(next.builder)];
|
||||
return { query: next, parked };
|
||||
};
|
||||
|
||||
// Revisit → restore the stash in the active mode; a static kind has no builder to re-seed.
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
if (isStaticPanelKind(newKind)) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
return;
|
||||
}
|
||||
const restored = unparkTargetTab(cached.query, cached.builders);
|
||||
parkedBuilders.current = restored.parked;
|
||||
// The stashed preview queries belong to the stashed query, not a swapped-in one.
|
||||
setSpec(
|
||||
buildSpec(
|
||||
cached.pluginSpec,
|
||||
restored.query.builder === cached.query.builder
|
||||
? cached.queries
|
||||
: toPerses(restored.query, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData({
|
||||
...restored.query,
|
||||
queryType: targetQueryType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit to a static kind → fresh spec from its sections, queries
|
||||
// emptied (the API accepts nothing else), and the query builder left as-is:
|
||||
// the stash above keeps the old kind's query for the return trip.
|
||||
if (isStaticPanelKind(newKind)) {
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → rebuild both builder tabs' queries for the new panel type.
|
||||
const transformed = rebuildForNewKind(query);
|
||||
const rebuiltBuilders: BuildersByVariant = {};
|
||||
(Object.keys(parkedBuilders.current) as BuilderVariant[]).forEach(
|
||||
(variant) => {
|
||||
const parked = parkedBuilders.current[variant];
|
||||
if (parked) {
|
||||
rebuiltBuilders[variant] = rebuildForNewKind({
|
||||
...query,
|
||||
builder: parked,
|
||||
}).builder;
|
||||
}
|
||||
},
|
||||
);
|
||||
rebuiltBuilders[getBuilderVariant(transformed.builder)] =
|
||||
transformed.builder;
|
||||
const picked = unparkTargetTab(transformed, rebuiltBuilders);
|
||||
parkedBuilders.current = picked.parked;
|
||||
const nextQuery = picked.query;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(nextQuery, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData, updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
return { onChangePanelKind, onChangeQueryMode };
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ATTRIBUTE_TYPES, type PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
OrderByPayload,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type {
|
||||
MetricAggregation,
|
||||
SpaceAggregation,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
|
||||
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
|
||||
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
];
|
||||
|
||||
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
|
||||
'p50',
|
||||
'p75',
|
||||
'p90',
|
||||
'p95',
|
||||
'p99',
|
||||
]);
|
||||
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
|
||||
|
||||
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
|
||||
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
|
||||
|
||||
function withDefaultListOrder(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((qd) =>
|
||||
qd.orderBy && qd.orderBy.length > 0
|
||||
? qd
|
||||
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
|
||||
* query and refuses a formula over the cells. The per-kind cache restores whatever is
|
||||
* dropped here if the panel switches back.
|
||||
*
|
||||
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
|
||||
* so a percentile draws the grid a count already draws, and the statement builder sums
|
||||
* the `le` counts either way.
|
||||
*/
|
||||
function withSingleHeatmapQuery(query: Query): Query {
|
||||
const [first] = query.builder.queryData;
|
||||
if (!first) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...first,
|
||||
spaceAggregation: withoutPercentile(first.spaceAggregation),
|
||||
aggregations: first.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutPercentile(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
|
||||
function withoutPercentile<T extends string | undefined>(
|
||||
spaceAggregation: T,
|
||||
): T {
|
||||
return (
|
||||
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
|
||||
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
|
||||
* so a count carried out of a heatmap would sit in the selector with no option behind
|
||||
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
|
||||
*/
|
||||
function withPercentileHistogramAggregation(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((queryData) => {
|
||||
if (
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
return queryData;
|
||||
}
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
spaceAggregation: withoutCount(queryData.spaceAggregation),
|
||||
aggregations: queryData.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutCount(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
|
||||
return (
|
||||
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
builderQuery: Query;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
*/
|
||||
export function usePanelTypeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
|
||||
from: oldKind,
|
||||
to: newKind,
|
||||
});
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries,
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = toPanelType(newKind);
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
// `createDefaultPanel`). The surrounding spec stays fully typed.
|
||||
const buildSpec = (
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesPanelSpecDTO => ({
|
||||
...currentSpec,
|
||||
plugin: {
|
||||
...currentSpec.plugin,
|
||||
kind: newKind,
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
});
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path). A static
|
||||
// kind's stash carries `queries: []` and its builder query is untouched —
|
||||
// there is no builder to re-seed for it.
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
if (!isStaticPanelKind(newKind)) {
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit to a static kind → fresh spec from its sections, queries
|
||||
// emptied (the API accepts nothing else), and the query builder left as-is:
|
||||
// the stash above keeps the old kind's query for the return trip.
|
||||
if (isStaticPanelKind(newKind)) {
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → coerce the query type if the new kind disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const queryType = resolveQueryType(newKind, query.queryType);
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
let nextQuery =
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
nextQuery =
|
||||
newKind === 'signoz/HeatmapPanel'
|
||||
? withSingleHeatmapQuery(nextQuery)
|
||||
: withPercentileHistogramAggregation(nextQuery);
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(nextQuery, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return { onChangePanelKind };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type MutableRefObject, useCallback } from 'react';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import {
|
||||
getQueryModeSignals,
|
||||
type SupportedQueryModesKindMap,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
|
||||
import {
|
||||
type BuildersByVariant,
|
||||
type Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getBuilderVariant, toBuilderVariant } from 'lib/query/queryMode';
|
||||
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
|
||||
|
||||
interface UseQueryModeChangeArgs {
|
||||
panelType: PANEL_TYPES;
|
||||
supportedQueryModesKindMap: SupportedQueryModesKindMap;
|
||||
/** Builder queries for the variant not on screen. */
|
||||
parkedBuilders: MutableRefObject<BuildersByVariant>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab switching. Query Builder and AI share `currentQuery.builder`, so crossing between
|
||||
* them swaps in the parked query, seeding a first visit; ClickHouse and PromQL pass through.
|
||||
*/
|
||||
export function useQueryModeChange({
|
||||
panelType,
|
||||
supportedQueryModesKindMap,
|
||||
parkedBuilders,
|
||||
}: UseQueryModeChangeArgs): (key: string) => void {
|
||||
const {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
updateAllQueriesOperators,
|
||||
} = useQueryBuilder();
|
||||
|
||||
return useCallback(
|
||||
(key: string): void => {
|
||||
const targetMode = key as QueryMode;
|
||||
const targetVariant = toBuilderVariant(targetMode);
|
||||
const heldVariant = getBuilderVariant(currentQuery.builder);
|
||||
|
||||
const seedFor = (mode: QueryMode): Query['builder'] =>
|
||||
seedBuilderForMode({
|
||||
mode,
|
||||
defaultSignal: getQueryModeSignals(
|
||||
supportedQueryModesKindMap,
|
||||
QueryMode.QUERY_BUILDER,
|
||||
)[0],
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
});
|
||||
|
||||
// Keyed on what `builder` holds, not the active tab: ClickHouse → QB must not reseed.
|
||||
let { builder } = currentQuery;
|
||||
if (targetVariant && targetVariant !== heldVariant) {
|
||||
parkedBuilders.current[heldVariant] = currentQuery.builder;
|
||||
builder = parkedBuilders.current[targetVariant] ?? seedFor(targetMode);
|
||||
delete parkedBuilders.current[targetVariant];
|
||||
}
|
||||
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
// An AI query is a builder query carrying the AI tag; its query type stays `builder`.
|
||||
queryType:
|
||||
targetMode === QueryMode.AI_QUERY_BUILDER
|
||||
? QueryMode.QUERY_BUILDER
|
||||
: targetMode,
|
||||
builder,
|
||||
});
|
||||
},
|
||||
[
|
||||
currentQuery,
|
||||
panelType,
|
||||
parkedBuilders,
|
||||
redirectWithQueryBuilderData,
|
||||
supportedQueryModesKindMap,
|
||||
updateAllQueriesOperators,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { NewPanelTarget } from '../patchOps';
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { usePanelKindAndQueryModeSwitch } from './hooks/usePanelKindAndQueryModeSwitch';
|
||||
|
||||
export interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
@@ -40,11 +40,12 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: toPanelType(panelKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
const { onChangePanelKind, onChangeQueryMode } =
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: toPanelType(panelKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
@@ -65,6 +66,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type {
|
||||
MetricAggregation,
|
||||
SpaceAggregation,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
|
||||
'p50',
|
||||
'p75',
|
||||
'p90',
|
||||
'p95',
|
||||
'p99',
|
||||
]);
|
||||
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
|
||||
|
||||
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
|
||||
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
|
||||
|
||||
/**
|
||||
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
|
||||
* query and refuses a formula over the cells. The per-kind cache restores whatever is
|
||||
* dropped here if the panel switches back.
|
||||
*
|
||||
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
|
||||
* so a percentile draws the grid a count already draws, and the statement builder sums
|
||||
* the `le` counts either way.
|
||||
*/
|
||||
export function withSingleHeatmapQuery(query: Query): Query {
|
||||
const [first] = query.builder.queryData;
|
||||
if (!first) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...first,
|
||||
spaceAggregation: withoutPercentile(first.spaceAggregation),
|
||||
aggregations: first.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutPercentile(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
|
||||
function withoutPercentile<T extends string | undefined>(
|
||||
spaceAggregation: T,
|
||||
): T {
|
||||
return (
|
||||
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
|
||||
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
|
||||
* so a count carried out of a heatmap would sit in the selector with no option behind
|
||||
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
|
||||
*/
|
||||
export function withPercentileHistogramAggregation(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((queryData) => {
|
||||
if (
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
return queryData;
|
||||
}
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
spaceAggregation: withoutCount(queryData.spaceAggregation),
|
||||
aggregations: queryData.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutCount(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
|
||||
return (
|
||||
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
|
||||
import { getPanelDefinition, isPanelKindSupported } from '../registry';
|
||||
@@ -11,12 +12,13 @@ import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getQueryPanelDefinition,
|
||||
requireQueryPanelDefinition,
|
||||
getSupportedQueryModes,
|
||||
getSupportedQueryTypes,
|
||||
getSupportedSignals,
|
||||
isPanelCombinationValid,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
resolveQueryType,
|
||||
resolveQueryMode,
|
||||
} from '../capabilities';
|
||||
import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
@@ -38,6 +40,48 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
const EXPECTED_QUERY_MODES: Record<PanelKind, QueryMode[]> = {
|
||||
'signoz/TimeSeriesPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/BarChartPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/AreaChartPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/NumberPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/HistogramPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE, QueryMode.AI_QUERY_BUILDER],
|
||||
'signoz/ListPanel': [QUERY_BUILDER, QueryMode.AI_QUERY_BUILDER],
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/TimeSeriesPanel': [metrics, logs, traces],
|
||||
'signoz/BarChartPanel': [metrics, logs, traces],
|
||||
@@ -223,6 +267,15 @@ describe('panel capabilities guard', () => {
|
||||
expect(isSignalSupported('signoz/ListPanel', logs)).toBe(true);
|
||||
expect(isSignalSupported('signoz/ListPanel', traces)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a specific mode when one is given', () => {
|
||||
expect(
|
||||
getSupportedSignals('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
|
||||
).toStrictEqual([traces]);
|
||||
expect(
|
||||
getSupportedSignals('signoz/TablePanel', QueryMode.CLICKHOUSE),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPanelCombinationValid', () => {
|
||||
@@ -271,18 +324,36 @@ describe('panel capabilities guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueryType', () => {
|
||||
it('keeps a supported query type', () => {
|
||||
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
|
||||
expect(resolveQueryType('signoz/ListPanel', QUERY_BUILDER)).toBe(
|
||||
QUERY_BUILDER,
|
||||
describe('getSupportedQueryModes', () => {
|
||||
it.each(Object.entries(EXPECTED_QUERY_MODES))(
|
||||
'%s supports %j',
|
||||
(kind, modes) => {
|
||||
expect(getSupportedQueryModes(kind as PanelKind)).toStrictEqual(modes);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps AI out of the query types', () => {
|
||||
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces an unsupported query type to the first supported one', () => {
|
||||
describe('resolveQueryMode', () => {
|
||||
it('keeps a supported mode', () => {
|
||||
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
|
||||
expect(
|
||||
resolveQueryMode('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
|
||||
).toBe(QueryMode.AI_QUERY_BUILDER);
|
||||
expect(
|
||||
resolveQueryMode('signoz/ListPanel', QueryMode.AI_QUERY_BUILDER),
|
||||
).toBe(QueryMode.AI_QUERY_BUILDER);
|
||||
});
|
||||
|
||||
it('coerces an unsupported mode to the first supported one', () => {
|
||||
// PromQL → List has no PromQL, falls back to its first (and only) type.
|
||||
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
|
||||
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
|
||||
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
|
||||
expect(resolveQueryMode('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user