Compare commits

..

3 Commits

Author SHA1 Message Date
Naman Verma
2a9246bfe3 chore: dummy commit to trigger review 2026-10-13 11:59:19 +05:30
Naman Verma
e59afbec38 chore: better code comments 2026-10-13 11:30:46 +05:30
Naman Verma
b1e2164739 fix: make related logs/traces link carry the relevant query from a rule 2026-10-13 11:16:59 +05:30
276 changed files with 3024 additions and 12716 deletions

View File

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

View File

@@ -1,219 +1,5 @@
components:
schemas:
AiobservabilitytypesBlobPart:
properties:
content:
type: string
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
required:
- type
- modality
- content
type: object
AiobservabilitytypesChatMessage:
properties:
name:
nullable: true
type: string
parts:
$ref: '#/components/schemas/AiobservabilitytypesParts'
role:
type: string
required:
- role
- parts
type: object
AiobservabilitytypesCompactionPart:
properties:
content:
nullable: true
type: string
id:
nullable: true
type: string
type:
type: string
required:
- type
type: object
AiobservabilitytypesFilePart:
properties:
file_id:
type: string
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
required:
- type
- modality
- file_id
type: object
AiobservabilitytypesGenericPart:
additionalProperties: {}
type: object
AiobservabilitytypesGenericServerToolCall:
additionalProperties: {}
type: object
AiobservabilitytypesGenericServerToolCallResponse:
additionalProperties: {}
type: object
AiobservabilitytypesInputMessages:
items:
$ref: '#/components/schemas/AiobservabilitytypesChatMessage'
type: array
AiobservabilitytypesOutputMessage:
properties:
finish_reason:
nullable: true
type: string
name:
nullable: true
type: string
parts:
$ref: '#/components/schemas/AiobservabilitytypesParts'
role:
type: string
required:
- role
- parts
type: object
AiobservabilitytypesOutputMessages:
items:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessage'
type: array
AiobservabilitytypesPart:
discriminator:
mapping:
blob: '#/components/schemas/AiobservabilitytypesBlobPart'
compaction: '#/components/schemas/AiobservabilitytypesCompactionPart'
file: '#/components/schemas/AiobservabilitytypesFilePart'
reasoning: '#/components/schemas/AiobservabilitytypesReasoningPart'
server_tool_call: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
server_tool_call_response: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
text: '#/components/schemas/AiobservabilitytypesTextPart'
tool_call: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
tool_call_response: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
uri: '#/components/schemas/AiobservabilitytypesUriPart'
propertyName: type
oneOf:
- $ref: '#/components/schemas/AiobservabilitytypesTextPart'
- $ref: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
- $ref: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
- $ref: '#/components/schemas/AiobservabilitytypesBlobPart'
- $ref: '#/components/schemas/AiobservabilitytypesFilePart'
- $ref: '#/components/schemas/AiobservabilitytypesUriPart'
- $ref: '#/components/schemas/AiobservabilitytypesReasoningPart'
- $ref: '#/components/schemas/AiobservabilitytypesCompactionPart'
- $ref: '#/components/schemas/AiobservabilitytypesGenericPart'
type: object
AiobservabilitytypesParts:
items:
$ref: '#/components/schemas/AiobservabilitytypesPart'
type: array
AiobservabilitytypesReasoningPart:
properties:
content:
type: string
type:
type: string
required:
- type
- content
type: object
AiobservabilitytypesServerToolCallPart:
properties:
id:
nullable: true
type: string
name:
type: string
server_tool_call:
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCall'
type:
type: string
required:
- type
- name
- server_tool_call
type: object
AiobservabilitytypesServerToolCallResponsePart:
properties:
id:
nullable: true
type: string
server_tool_call_response:
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCallResponse'
type:
type: string
required:
- type
- server_tool_call_response
type: object
AiobservabilitytypesTextPart:
properties:
content:
type: string
type:
type: string
required:
- type
- content
type: object
AiobservabilitytypesToolCallRequestPart:
properties:
arguments: {}
id:
nullable: true
type: string
name:
type: string
type:
type: string
required:
- type
- name
type: object
AiobservabilitytypesToolCallResponsePart:
properties:
id:
nullable: true
type: string
response:
nullable: true
type:
type: string
required:
- type
- response
type: object
AiobservabilitytypesUriPart:
properties:
mime_type:
nullable: true
type: string
modality:
type: string
type:
type: string
uri:
type: string
required:
- type
- modality
- uri
type: object
AlertmanagertypesChannel:
properties:
createdAt:
@@ -3581,69 +3367,6 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAIBuilderQuerySpec:
properties:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
type: boolean
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
legend:
type: string
limit:
type: integer
limitBy:
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
name:
type: string
offset:
type: integer
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
@@ -4697,7 +4420,6 @@ components:
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -4707,7 +4429,6 @@ components:
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
@@ -4717,25 +4438,12 @@ components:
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/AIBuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
properties:
kind:
enum:
- signoz/AIBuilderQuery
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
properties:
kind:
@@ -10517,14 +10225,6 @@ components:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
formattedInput:
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
formattedOutput:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
formatterWarnings:
items:
type: string
type: array
has_error:
type: boolean
kind_string:
@@ -10567,9 +10267,6 @@ components:
- attributes
- events
- references
- formattedInput
- formattedOutput
- formatterWarnings
type: object
SpantypesTraceAISummary:
properties:
@@ -10650,14 +10347,6 @@ components:
flags:
minimum: 0
type: integer
formattedInput:
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
formattedOutput:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
formatterWarnings:
items:
type: string
type: array
has_children:
type: boolean
has_error:
@@ -16445,12 +16134,9 @@ paths:
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the gen_ai spans of a trace, model calls, tool calls and
agent spans, in timestamp order. The messages a span carries are decoded into
formattedInput and formattedOutput, with formatterWarnings holding what the
converter could not resolve; a span without messages has both empty. Pass
nextCursor as after or prevCursor as before to page, or spanId to open the
page around a span.
description: Returns the spans carrying gen_ai input or output messages in timestamp
order. Pass nextCursor as after or prevCursor as before to page, or spanId
to open the page around a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.
@@ -28594,9 +28280,7 @@ paths:
deprecated: false
description: Returns the waterfall view of spans including all spans if total
spans are under a limit, a max count otherwise. Aggregations are dropped compared
to v3. A span carrying gen_ai input or output messages also has them decoded
into formattedInput and formattedOutput, with formatterWarnings holding what
the converter could not resolve.
to v3
operationId: GetWaterfallV4
parameters:
- in: path

View File

@@ -20,7 +20,6 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [Provider](provider.md) - Dependency injection and provider patterns
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
- [Service](service.md) - Managed service lifecycle with `factory.Service`
- [Span Mapper](spanmapper.md) - Provenance of the shipped span mapper definitions and what is left unmapped
- [SQL](sql.md) - Database and SQL patterns
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`

View File

@@ -1,62 +0,0 @@
# Span Mapper
Where each source key in the shipped definitions under `pkg/modules/spanmapper/implspanmapper/fs/definitions/` comes from. A group runs on spans with an attribute key containing one of its condition substrings; sources are tried by priority and the first present wins; an existing target is never overwritten. Bump `version` on every change.
## gen_ai.llm
Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` from the response, so a failed call has no model key).
| Source | Emitted by |
|---|---|
| `llm.model_name`, `llm.provider`, `llm.system`, `llm.token_count.prompt`, `llm.token_count.completion`, `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write`, `llm.token_count.completion_details.reasoning`, `llm.finish_reason`, `input.value`, `output.value`, `embedding.model_name`, `reranker.model_name`, `openinference.span.kind` | OpenInference |
| `llm.request.model`, `llm.response.model`, `llm.vendor`, `llm.usage.prompt_tokens`, `llm.usage.completion_tokens`, `llm.response.finish_reason`, `llm.response.stop_reason` | OpenLLMetry before the `gen_ai` rename |
| `gen_ai.system`, `gen_ai.usage.prompt_tokens`, `gen_ai.usage.completion_tokens`, `gen_ai.usage.cache_read_input_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.reasoning_tokens`, `gen_ai.prompt`, `gen_ai.completion`, `llm.request.type`, `traceloop.span.kind` | OpenLLMetry, LiteLLM, CrewAI, Mistral, LangSmith, OpenRouter gateway |
| `gen_ai.usage.cache_write.input_tokens` | OTel semconv draft spelling, Codex |
| `gen_ai.response.finish_reason`, `gen_ai.usage.prompt_token_details.cached_read_tokens`, `gen_ai.usage.completion_token_details.reasoning_tokens` | Bifrost gateway |
| `gen_ai.usage.input_tokens.cached` | OpenRouter gateway |
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
| `session.id` | OpenInference, Langfuse, Claude Code |
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |
## gen_ai.tool
Condition: `tool.name` (OpenInference), `ai.toolCall.` (Vercel, Mastra), `tool_call_args`, `tool_response` (Google ADK), `tool_use_id` (Claude Code, opentelemetry-hooks). Not `tool_name`: Codex records it on the model call span.
| Source | Emitted by |
|---|---|
| `tool.name`, `tool.id`, `tool.description`, `input.value`, `output.value`, `openinference.span.kind` | OpenInference |
| `ai.toolCall.name`, `ai.toolCall.id`, `ai.toolCall.args`, `ai.toolCall.result` | Vercel AI SDK, Mastra |
| `traceloop.entity.input`, `traceloop.entity.output`, `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
| `gcp.vertex.agent.tool_call_args`, `gcp.vertex.agent.tool_response` | Google ADK |
| `tool_name` | Claude Code `claude_code.tool` spans |
| `gen_ai.client.tool_name`, `gen_ai.client.tool_use_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
## gen_ai.agent
Condition: `agent`.
| Source | Emitted by |
|---|---|
| `agent.name`, `agent.id`, `agent.description`, `openinference.span.kind` | OpenInference; `agent.id` and `agent.description` seen in no capture |
| `agent_name`, `final_result` | Pydantic AI |
| `crewai.agent.role`, `crewai.agent.id` | CrewAI |
| `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
| `agent_id` | Claude Code |
## Coverage of documented integrations
Every integration under [signoz.io/docs/llm-observability](https://signoz.io/docs/llm-observability/) was checked by looking at the instrumentation its page installs and the keys that instrumentation writes.
- Most pages install OpenInference or OpenLLMetry, or an SDK that writes OTel GenAI semconv itself (OpenAI, OpenAI Agents, Pydantic AI, AutoGen, Microsoft Agent Framework, Semantic Kernel, Copilot Chat, LiteLLM, OpenRouter). Their keys are the rows above.
- Three write their own keys and have their own rows: Claude Code and Claude Agent SDK, Cursor through opentelemetry-hooks, vLLM (response model only).
- Codex cannot be priced by renaming: `model` sits on the sampling function spans, `gen_ai.usage.*` on the child `handle_responses` span, `conversation.id` on the `codex.sampling` phase span, never together on one span.
To check a new integration, capture a trace and look for a model, usage, tool or messages key that no row above lists.
## Left unmapped on purpose
LiteLLM `llm.openai.messages` and `llm.openai.choices` (debug echo of the parent span's messages, Python repr). Total tokens and vendor cost keys (no target; the pricing processor owns cost). `ai.response.msToFirstChunk` and `gen_ai.latency.time_to_first_token` (milliseconds and seconds under one target).

View File

@@ -41,12 +41,6 @@ These hold for every page. The per-pattern sections below only add to them.
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
them, not the surrounding content.
8. **Expose the selector.** A role is scoped to a resource by pasting its selector into the role editor, so every
resource a role can be scoped to shows that selector with a copy control on its detail view or drawer. The selector
is whatever the editor accepts for the resource (dashboard ID, service account ID, role name, ...), listed per
resource in [permissions.config.ts](../src/container/RolesSettings/permissions.config.ts). It is already known from
the row or URL, so it stays visible and copyable without `read`. Reference: the ID field in
[ServiceAccountDrawer/OverviewTab.tsx](../src/components/ServiceAccountDrawer/OverviewTab.tsx).
## Page patterns
@@ -142,8 +136,6 @@ Use the existing components. Create a new one only if none fit.
- [ ] Am I gating a shared component rather than a page or section?
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
the user cannot read them.
- [ ] Does the page expose the resource's selector with a copy control (rule 8)?
- The expected format is the `selectorPlaceholder` for the resource in `permissions.config.ts`.
## Testing

View File

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

View File

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

View File

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

View File

@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
};
/**
* Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (
@@ -475,7 +475,7 @@ export const useGetFlamegraph = <
return useMutation(getGetFlamegraphMutationOptions(options));
};
/**
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3. A span carrying gen_ai input or output messages also has them decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve.
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
* @summary Get waterfall view for a trace
*/
export const getWaterfallV4 = (

View File

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

View File

@@ -10,13 +10,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
import {
QueryBuilderField,
type QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
import { BUILT_IN_PLACEHOLDERS } from './constants';
import './MetricsAggregateSection.styles.scss';
@@ -25,18 +18,15 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
index,
version,
panelType,
fieldsConfig,
signalSource = '',
}: {
query: IBuilderQuery;
index: number;
version: string;
panelType: PANEL_TYPES | null;
fieldsConfig?: QueryBuilderFieldsConfig;
signalSource: string;
}): JSX.Element {
const { setAggregationOptions } = useQueryBuilderV2Context();
const placeholderOf = (key: keyof typeof BUILT_IN_PLACEHOLDERS): string =>
resolveQueryBuilderField(key, fieldsConfig).placeholder ??
BUILT_IN_PLACEHOLDERS[key];
const {
operators,
spaceAggregationOptions,
@@ -164,7 +154,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<InputWithLabel
onChange={handleChangeAggregateEvery}
label="Seconds"
placeholder={placeholderOf(QueryBuilderField.StepInterval)}
placeholder="Auto"
labelAfter
initialValue={query?.stepInterval ?? null}
/>
@@ -216,7 +206,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
signalSource={signalSource}
/>
</div>
</div>
@@ -253,7 +243,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
disabled={!queryAggregation.metricName}
query={query}
onChange={handleChangeGroupByKeys}
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
signalSource={signalSource}
/>
</div>
</div>
@@ -287,7 +277,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<InputWithLabel
onChange={handleChangeAggregateEvery}
label="Seconds"
placeholder={placeholderOf(QueryBuilderField.StepInterval)}
placeholder="Auto"
labelAfter
initialValue={query?.stepInterval ?? null}
className="histogram-every-input"

View File

@@ -1,9 +0,0 @@
import { QueryBuilderField } from '../../queryBuilderFields.types';
import { STEP_INTERVAL_PLACEHOLDER } from '../../queryBuilderFields.utils';
import { ADD_ON_DEFINITIONS } from '../QueryAddOns/constants';
export const BUILT_IN_PLACEHOLDERS = {
[QueryBuilderField.StepInterval]: STEP_INTERVAL_PLACEHOLDER,
[QueryBuilderField.GroupBy]:
ADD_ON_DEFINITIONS[QueryBuilderField.GroupBy].placeholder,
} as const;

View File

@@ -88,12 +88,10 @@ function HavingFilter({
onClose,
onChange,
queryData,
placeholder = 'Type Having query like count() > 10 ...',
}: {
onClose: () => void;
onChange: (value: string) => void;
queryData: IBuilderQuery;
placeholder?: string;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const { getAggregationOptions } = useQueryBuilderV2Context();
@@ -347,7 +345,7 @@ function HavingFilter({
},
]),
]}
placeholder={placeholder}
placeholder="Type Having query like count() > 10 ..."
basicSetup={{
lineNumbers: false,
autocompletion: true,

View File

@@ -343,29 +343,8 @@
font-size: 12px !important;
}
// Not select search inputs: they would grow the select past 36px.
.input-with-label input {
input {
min-height: 36px;
}
.input-with-label,
.periscope-input-with-label {
min-height: 36px;
.label {
min-width: 80px;
}
// `ghost` strips the border.
.close-btn.periscope-btn {
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
}
}
.periscope-input-with-label .input .ant-select-selection-placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
font-size: 12px;
}
}
}

View File

@@ -1,17 +1,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'antd';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { ChevronUp } from '@signozhq/icons';
import {
BarChart,
ChevronUp,
ExternalLink,
Grid3X3,
ScrollText,
} from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
@@ -29,20 +34,162 @@ import {
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
import {
ADD_ON_DEFINITIONS,
BASE_ADD_ONS,
BUCKET_OPTIONS,
HISTOGRAM_ATTRIBUTE_TYPES,
type InputAddOnField,
REDUCE_TO,
} from './constants';
import TooltipContent from './TooltipContent';
import type { AddOn } from './types';
import { buildDefaultLegendFromGroupBy, hasValue } from './utils';
import { buildDefaultLegendFromGroupBy } from './utils';
import './QueryAddOns.styles.scss';
interface AddOn {
icon: React.ReactNode;
label: string;
key: QueryBuilderField;
description?: string;
docLink?: string;
}
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
type NonAddOnField =
| QueryBuilderField.Aggregation
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
// Omit rather than Partial, so a field added to the enum has to be placed on one side.
const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
Record<QueryBuilderField, string>,
NonAddOnField
> = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.Having]: 'having.expression',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
[QueryBuilderField.BucketOptions]: 'bucketOptions',
};
const ADD_ONS: AddOn[] = [
{
icon: <BarChart size={14} />,
label: 'Group By',
key: QueryBuilderField.GroupBy,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
},
{
icon: <ScrollText size={14} />,
label: 'Having',
key: QueryBuilderField.Having,
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
},
{
icon: <ScrollText size={14} />,
label: 'Order By',
key: QueryBuilderField.OrderBy,
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
},
{
icon: <ScrollText size={14} />,
label: 'Limit',
key: QueryBuilderField.Limit,
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
},
{
icon: <ScrollText size={14} />,
label: 'Legend format',
key: QueryBuilderField.Legend,
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
},
];
const REDUCE_TO: AddOn = {
icon: <ScrollText size={14} />,
label: 'Reduce to',
key: QueryBuilderField.ReduceTo,
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
// against, and no other panel type sends one. A histogram brings its own buckets.
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const BUCKET_OPTIONS: AddOn = {
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
key: QueryBuilderField.BucketOptions,
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
// Custom tooltip content component
function TooltipContent({
label,
description,
docLink,
}: {
label: string;
description?: string;
docLink?: string;
}): JSX.Element {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
maxWidth: '300px',
}}
>
<strong style={{ fontSize: '14px' }}>{label}</strong>
{description && (
<span style={{ fontSize: '12px', lineHeight: '1.5' }}>{description}</span>
)}
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: '#4096ff',
fontSize: '12px',
marginTop: '4px',
}}
>
Learn more
<ExternalLink size={12} />
</a>
)}
</div>
);
}
function QueryAddOns({
query,
version,
@@ -80,16 +227,12 @@ function QueryAddOns({
let addOns: AddOn[];
if (panelType === PANEL_TYPES.VALUE) {
addOns = BASE_ADD_ONS.filter(
(addOn) => addOn.key === QueryBuilderField.Legend,
);
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
} else if (query.dataSource === DataSource.METRICS) {
// Group by for metrics is offered by MetricsAggregateSection instead.
addOns = BASE_ADD_ONS.filter(
(addOn) => addOn.key !== QueryBuilderField.GroupBy,
);
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
} else {
addOns = [...BASE_ADD_ONS];
addOns = [...ADD_ONS];
}
if (showReduceTo) {
@@ -145,12 +288,6 @@ function QueryAddOns({
[resolvedFields],
);
const placeholderOf = useCallback(
(key: InputAddOnField): string =>
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
[resolvedFields],
);
const isDisabled = useCallback(
(key: QueryBuilderField): boolean =>
Boolean(resolvedFields.get(key)?.disabled),
@@ -169,6 +306,12 @@ function QueryAddOns({
if (!initializedRef.current || hasAvailabilityItemsChanged) {
initializedRef.current = true;
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key as QueryBuilderField),
);
// Initial seeding from query values on mount. A disabled field never opens.
setSelectedViews(
offeredAddOns.filter((addOn) => {
@@ -176,7 +319,7 @@ function QueryAddOns({
return (
resolved?.pinned ||
(hasValue(get(query, addOn.queryPath)) && !resolved?.disabled)
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
);
}),
);
@@ -305,7 +448,7 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
<div className="add-on-content" data-testid="group-by-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Group By"
@@ -313,13 +456,13 @@ function QueryAddOns({
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Group By
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<GroupByFilter
disabled={
@@ -328,7 +471,6 @@ function QueryAddOns({
}
query={query}
onChange={handleChangeGroupByKeys}
placeholder={placeholderOf(QueryBuilderField.GroupBy)}
/>
</div>
{!isPinned(QueryBuilderField.GroupBy) && (
@@ -344,7 +486,7 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
<div className="add-on-content" data-testid="having-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Having"
@@ -352,19 +494,18 @@ function QueryAddOns({
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Having
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<HavingFilter
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
onChange={handleChangeHaving}
queryData={query}
placeholder={placeholderOf(QueryBuilderField.Having)}
/>
</div>
</div>
@@ -377,7 +518,7 @@ function QueryAddOns({
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
placeholder="Enter limit"
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>
@@ -386,7 +527,7 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
<div className="add-on-content" data-testid="order-by-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Order By"
@@ -394,13 +535,13 @@ function QueryAddOns({
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Order By
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<OrderByFilter
entityVersion={version}
@@ -408,7 +549,6 @@ function QueryAddOns({
onChange={handleChangeOrderByKeys}
isRawQuery={isRawQuery}
isNewQueryV2
placeholder={placeholderOf(QueryBuilderField.OrderBy)}
/>
</div>
{!isPinned(QueryBuilderField.OrderBy) && (
@@ -426,7 +566,7 @@ function QueryAddOns({
showReduceTo && (
<div className="add-on-content" data-testid="reduce-to-content">
<div className="periscope-input-with-label">
<TooltipSimple
<Tooltip
title={
<TooltipContent
label="Reduce to"
@@ -434,13 +574,13 @@ function QueryAddOns({
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Reduce to
</div>
</TooltipSimple>
</Tooltip>
<div className="input">
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
</div>
@@ -460,7 +600,7 @@ function QueryAddOns({
<div className="add-on-content" data-testid="legend-format-content">
<InputWithLabel
label="Legend format"
placeholder={placeholderOf(QueryBuilderField.Legend)}
placeholder="Write legend format"
onChange={handleChangeQueryLegend}
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
@@ -507,7 +647,7 @@ function QueryAddOns({
return {
value: addOn.key,
label: (
<TooltipSimple
<Tooltip
title={
<TooltipContent
label={addOn.label}
@@ -515,8 +655,8 @@ function QueryAddOns({
docLink={resolved?.disabled ? undefined : addOn.docLink}
/>
}
arrow
delayDuration={500}
placement="top"
mouseEnterDelay={0.5}
>
<span
className={cx('add-on-tab-title', {
@@ -528,7 +668,7 @@ function QueryAddOns({
{addOn.icon}
{addOn.label}
</span>
</TooltipSimple>
</Tooltip>
),
};
})}

View File

@@ -1,24 +0,0 @@
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 300px;
}
.label {
font-size: var(--font-size-sm);
}
.description {
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
}
.docLink {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
color: var(--accent-primary);
font-size: var(--font-size-xs);
}

View File

@@ -1,33 +0,0 @@
import { ExternalLink } from '@signozhq/icons';
import styles from './TooltipContent.module.scss';
// Custom tooltip content component
export default function TooltipContent({
label,
description,
docLink,
}: {
label: string;
description?: string;
docLink?: string;
}): JSX.Element {
return (
<div className={styles.content}>
<strong className={styles.label}>{label}</strong>
{description && <span className={styles.description}>{description}</span>}
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
className={styles.docLink}
>
Learn more
<ExternalLink size={12} />
</a>
)}
</div>
);
}

View File

@@ -1,115 +0,0 @@
import { BarChart, Grid3X3, ScrollText } from '@signozhq/icons';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { QueryBuilderField } from '../../queryBuilderFields.types';
import type { AddOn, AddOnDefinition, AddOnField } from './types';
// A Record, so a field added to the enum has to be placed here or in `NonAddOnField`.
export const ADD_ON_DEFINITIONS = {
[QueryBuilderField.GroupBy]: {
input: true,
icon: <BarChart size={14} />,
label: 'Group By',
queryPath: 'groupBy',
placeholder: 'Everything (no breakdown)',
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
},
[QueryBuilderField.Having]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Having',
queryPath: 'having.expression',
placeholder: 'Type Having query like count() > 10 ...',
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
},
[QueryBuilderField.OrderBy]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Order By',
queryPath: 'orderBy',
placeholder: 'Select fields to order by',
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
},
[QueryBuilderField.Limit]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Limit',
queryPath: 'limit',
placeholder: 'Enter limit',
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
},
[QueryBuilderField.Legend]: {
input: true,
icon: <ScrollText size={14} />,
label: 'Legend format',
queryPath: 'legend',
placeholder: 'Write legend format',
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
},
[QueryBuilderField.ReduceTo]: {
input: false,
icon: <ScrollText size={14} />,
label: 'Reduce to',
queryPath: 'reduceTo',
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
},
[QueryBuilderField.BucketOptions]: {
input: false,
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
queryPath: 'bucketOptions',
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
},
} as const satisfies Record<AddOnField, AddOnDefinition>;
export type InputAddOnField = {
[K in AddOnField]: (typeof ADD_ON_DEFINITIONS)[K] extends { input: true }
? K
: never;
}[AddOnField];
const toAddOn = (key: AddOnField): AddOn => ({
key,
...ADD_ON_DEFINITIONS[key],
});
export const BASE_ADD_ONS = (
[
QueryBuilderField.GroupBy,
QueryBuilderField.Having,
QueryBuilderField.OrderBy,
QueryBuilderField.Limit,
QueryBuilderField.Legend,
] as const satisfies readonly AddOnField[]
).map(toAddOn);
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
// against, and no other panel type sends one. A histogram brings its own buckets.
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
export const BUCKET_OPTIONS = toAddOn(QueryBuilderField.BucketOptions);

View File

@@ -1,27 +0,0 @@
import type { QueryBuilderField } from '../../queryBuilderFields.types';
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
export type NonAddOnField =
| QueryBuilderField.Aggregation
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;
export interface AddOnSpec {
icon: React.ReactNode;
label: string;
/** Where the add-on's value lives on the query; a value there opens it on mount. */
queryPath: string;
description?: string;
docLink?: string;
}
/** A text field has to name what leaving it empty means. */
export type AddOnDefinition =
| (AddOnSpec & { input: true; placeholder: string })
| (AddOnSpec & { input: false });
export type AddOn = AddOnDefinition & { key: AddOnField };

View File

@@ -14,6 +14,3 @@ export const buildDefaultLegendFromGroupBy = (
return segments.join(', ');
};
export const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);

View File

@@ -12,11 +12,7 @@ import {
QueryBuilderField,
QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import {
resolveQueryBuilderField,
type ResolvedQueryBuilderField,
STEP_INTERVAL_PLACEHOLDER,
} from '../../queryBuilderFields.utils';
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
import QueryAggregationSelect from './QueryAggregationSelect';
@@ -37,9 +33,9 @@ function QueryAggregationOptions({
queryData: IBuilderQuery | IBuilderTraceOperator;
fieldsConfig?: QueryBuilderFieldsConfig;
}): JSX.Element {
const stepInterval = useMemo<ResolvedQueryBuilderField>(() => {
const stepInterval = useMemo(() => {
if (panelType === PANEL_TYPES.VALUE) {
return { hidden: true, disabled: false, pinned: false };
return { hidden: true, disabled: false, reason: undefined };
}
const isNonMetricSource =
@@ -49,7 +45,7 @@ function QueryAggregationOptions({
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, pinned: false };
return { hidden: true, disabled: false, reason: undefined };
}
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
@@ -109,7 +105,7 @@ function QueryAggregationOptions({
initialValue={queryData?.stepInterval ? queryData?.stepInterval : null}
className="query-aggregation-interval-input"
label="Seconds"
placeholder={stepInterval.placeholder ?? STEP_INTERVAL_PLACEHOLDER}
placeholder="Auto"
type="number"
onChange={handleAggregationIntervalChange}
disabled={stepInterval.disabled}

View File

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

View File

@@ -9,7 +9,6 @@ import {
} from 'tests/test-utils';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
@@ -35,10 +34,9 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: ({ onChange, placeholder }: any): JSX.Element => (
GroupByFilter: ({ onChange }: any): JSX.Element => (
<button
data-testid="groupby"
data-placeholder={placeholder}
onClick={(): void => onChange(['service.name'])}
>
GroupByFilter
@@ -47,10 +45,9 @@ jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
}));
jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
OrderByFilter: ({ onChange, placeholder }: any): JSX.Element => (
OrderByFilter: ({ onChange }: any): JSX.Element => (
<button
data-testid="orderby"
data-placeholder={placeholder}
onClick={(): void => onChange([{ columnName: 'duration', order: 'desc' }])}
>
OrderByFilter
@@ -60,11 +57,10 @@ jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
__esModule: true,
default: ({ onChange, onClose, placeholder }: any): JSX.Element => (
default: ({ onChange, onClose }: any): JSX.Element => (
<div>
<button
data-testid="having-change"
data-placeholder={placeholder}
onClick={(): void => onChange('p99 > 500')}
>
HavingFilter
@@ -173,55 +169,6 @@ describe('QueryAddOns', () => {
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it.each([
[QueryBuilderField.Limit, 'input-Limit', 'placeholder'],
[QueryBuilderField.Legend, 'input-Legend format', 'placeholder'],
[QueryBuilderField.GroupBy, 'groupby', 'data-placeholder'],
[QueryBuilderField.Having, 'having-change', 'data-placeholder'],
[QueryBuilderField.OrderBy, 'orderby', 'data-placeholder'],
])('shows the configured %s placeholder', (field, inputTestId, attribute) => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
fieldsConfig={{
[field]: { state: 'pinned', placeholder: 'Default X' },
}}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId(inputTestId)).toHaveAttribute(
attribute,
'Default X',
);
});
it('keeps the built-in placeholder without a rule', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId('query-add-on-limit'));
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -1,47 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
fieldsConfig={{
[QueryBuilderField.StepInterval]: {
state: 'pinned',
placeholder: 'Default 60',
},
}}
/>,
);
expect(screen.getByPlaceholderText('Default 60')).toBeInTheDocument();
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});

View File

@@ -57,14 +57,6 @@ describe('resolveQueryBuilderField', () => {
expect(resolved.hidden).toBe(false);
});
it('carries the placeholder on a pinned field', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'pinned', placeholder: 'Default 10' },
});
expect(resolved.placeholder).toBe('Default 10');
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },

View File

@@ -1,149 +0,0 @@
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
QueryBuilderField,
type SyncedQueryBuilderField,
} from '../queryBuilderFields.types';
import {
snapshotQueries,
syncQueryBuilderFields,
} from '../syncQueryBuilderFields';
const key = (name: string): BaseAutocompleteData =>
({ key: name }) as BaseAutocompleteData;
function query(
queryName: string,
{
groupBy = [],
limit = null,
dataSource = 'traces',
}: { groupBy?: string[]; limit?: number | null; dataSource?: string } = {},
): IBuilderQuery {
return {
queryName,
groupBy: groupBy.map(key),
limit,
dataSource,
} as IBuilderQuery;
}
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
describe('syncQueryBuilderFields', () => {
it('copies an edited field to the other queries', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('gives a new query the existing values', () => {
const previous = snapshotQueries([query('A', { groupBy: ['service.name'] })]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it("gives a query switched to another signal the others' values, not its reset ones", () => {
const previous = snapshotQueries([
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['service.name'], dataSource: 'metrics' }),
]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('leaves values that already differ until one is edited', () => {
const queries = [query('A', { groupBy: ['host.name'] }), query('B')];
expect(
syncQueryBuilderFields(queries, snapshotQueries(queries), GROUP_BY),
).toStrictEqual([]);
});
it('gives a new query the first value without touching differing ones', () => {
const saved = [
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['host.name'] }),
];
expect(
syncQueryBuilderFields(
[...saved, query('C')],
snapshotQueries(saved),
GROUP_BY,
),
).toStrictEqual([
{ index: 2, query: query('C', { groupBy: ['service.name'] }) },
]);
});
it('copies an edit to every query even when one was added', () => {
const saved = [
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['host.name'] }),
];
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['k8s.pod.name'] }), saved[1], query('C')],
snapshotQueries(saved),
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['k8s.pod.name'] }) },
{ index: 2, query: query('C', { groupBy: ['k8s.pod.name'] }) },
]);
});
it('syncs each field from its own source', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B', { limit: 50 })],
previous,
[QueryBuilderField.GroupBy, QueryBuilderField.Limit],
),
).toStrictEqual([
{ index: 0, query: query('A', { groupBy: ['service.name'], limit: 50 }) },
{ index: 1, query: query('B', { groupBy: ['service.name'], limit: 50 }) },
]);
});
it('leaves fields it is not asked to sync', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { limit: 50 }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([]);
});
});

View File

@@ -1,64 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
QueryBuilderField,
type SyncedQueryBuilderField,
} from '../queryBuilderFields.types';
import { useSyncQueryBuilderFields } from '../useSyncQueryBuilderFields';
const mockHandleSetQueryData = jest.fn();
let mockQueries: IBuilderQuery[] = [];
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: { builder: { queryData: mockQueries } },
handleSetQueryData: mockHandleSetQueryData,
}),
}));
const query = (queryName: string, groupBy: string[]): IBuilderQuery =>
({
queryName,
groupBy: groupBy.map((name) => ({ key: name }) as BaseAutocompleteData),
dataSource: 'traces',
}) as IBuilderQuery;
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
function setup(
fields: SyncedQueryBuilderField[] | undefined,
initial: IBuilderQuery[],
): (next: IBuilderQuery[]) => void {
mockQueries = initial;
const { rerender } = renderHook(() => useSyncQueryBuilderFields(fields));
return (next): void => {
mockQueries = next;
rerender();
};
}
describe('useSyncQueryBuilderFields', () => {
beforeEach(() => mockHandleSetQueryData.mockClear());
it('writes the synced value into the other queries', () => {
const update = setup(GROUP_BY, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).toHaveBeenCalledTimes(1);
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
1,
query('B', ['service.name']),
);
});
it('does nothing when the kind syncs no fields', () => {
const update = setup(undefined, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
});
});

View File

@@ -22,23 +22,11 @@ export enum QueryBuilderField {
AdditionalQueries = 'additional_queries',
}
/** Per-query builder fields that can be kept equal across a panel's queries. */
export type SyncedQueryBuilderField =
| QueryBuilderField.GroupBy
| QueryBuilderField.OrderBy
| QueryBuilderField.Having
| QueryBuilderField.Limit
| QueryBuilderField.StepInterval
| QueryBuilderField.ReduceTo;
/**
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
* `placeholder` on `pinned` names the value an empty field falls back to.
*/
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
export type QueryBuilderFieldRule =
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned'; placeholder?: string };
| { state: 'pinned' };
/**
* A caller's narrowing of the builder's surface. The builder works out which fields suit

View File

@@ -10,7 +10,6 @@ export interface ResolvedQueryBuilderField {
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
placeholder?: string;
}
const AVAILABLE: ResolvedQueryBuilderField = {
@@ -19,8 +18,6 @@ const AVAILABLE: ResolvedQueryBuilderField = {
pinned: false,
};
export const STEP_INTERVAL_PLACEHOLDER = 'Auto';
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'hidden':
@@ -44,10 +41,8 @@ export function resolveQueryBuilderField(
config?: QueryBuilderFieldsConfig,
): ResolvedQueryBuilderField {
const rule = config?.[field];
const resolved = rule ? fromRule(rule) : AVAILABLE;
const placeholder = rule?.state === 'pinned' ? rule.placeholder : undefined;
return placeholder ? { ...resolved, placeholder } : resolved;
return rule ? fromRule(rule) : AVAILABLE;
}
/**

View File

@@ -1,82 +0,0 @@
import { isEqual } from 'lodash-es';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
QueryBuilderField,
type SyncedQueryBuilderField,
} from './queryBuilderFields.types';
const QUERY_PROPERTY = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Having]: 'having',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.StepInterval]: 'stepInterval',
[QueryBuilderField.ReduceTo]: 'reduceTo',
} as const satisfies Record<SyncedQueryBuilderField, keyof IBuilderQuery>;
export function snapshotQueries(
queries: IBuilderQuery[],
): Map<string, IBuilderQuery> {
return new Map(queries.map((query) => [query.queryName, query]));
}
/**
* The query each field should be copied from: the one whose value just changed
* (to all), else a settled one (to newcomers only) when a query was added or
* switched signal. A signal switch reloads that query's own state, so it is
* never the source.
*/
function findSource(
property: keyof IBuilderQuery,
settled: IBuilderQuery[],
hasNewcomer: boolean,
previous: Map<string, IBuilderQuery>,
): { source: IBuilderQuery; newcomersOnly: boolean } | undefined {
const changed = settled.find(
(query) =>
!isEqual(previous.get(query.queryName)?.[property], query[property]),
);
if (changed) {
return { source: changed, newcomersOnly: false };
}
return hasNewcomer && settled.length > 0
? { source: settled[0], newcomersOnly: true }
: undefined;
}
export function syncQueryBuilderFields(
queries: IBuilderQuery[],
previous: Map<string, IBuilderQuery>,
fields: SyncedQueryBuilderField[],
): { index: number; query: IBuilderQuery }[] {
const settled = queries.filter((query) => {
const before = previous.get(query.queryName);
return (
before?.dataSource === query.dataSource && before.source === query.source
);
});
const hasNewcomer = settled.length < queries.length;
const sources = fields
.map((field) => QUERY_PROPERTY[field])
.map((property) => ({
property,
found: findSource(property, settled, hasNewcomer, previous),
}));
return queries.flatMap((query, index) => {
const isSettled = settled.includes(query);
const patch = sources.reduce<Partial<IBuilderQuery>>(
(acc, { property, found }) =>
found &&
!(found.newcomersOnly && isSettled) &&
!isEqual(query[property], found.source[property])
? { ...acc, [property]: found.source[property] }
: acc,
{},
);
return Object.keys(patch).length > 0
? [{ index, query: { ...query, ...patch } }]
: [];
});
}

View File

@@ -1,37 +0,0 @@
import { useEffect, useRef } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from './queryBuilderFields.types';
import {
snapshotQueries,
syncQueryBuilderFields,
} from './syncQueryBuilderFields';
const NO_FIELDS: SyncedQueryBuilderField[] = [];
/**
* Keeps `fields` equal across builder queries: an edit to one query copies to
* the rest, and a query added or switched to another signal takes the others'
* values. Values that already differ when this starts are left alone until one
* of them is edited.
*/
export function useSyncQueryBuilderFields(
fields: SyncedQueryBuilderField[] = NO_FIELDS,
): void {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const queries = currentQuery.builder.queryData;
const previousRef = useRef<Map<string, IBuilderQuery>>();
const enabled = fields.length > 0;
useEffect(() => {
const previous = previousRef.current;
previousRef.current = enabled ? snapshotQueries(queries) : undefined;
if (!enabled || !previous || queries.length < 2) {
return;
}
syncQueryBuilderFields(queries, previous, fields).forEach(
({ index, query }) => handleSetQueryData(index, query),
);
}, [enabled, fields, queries, handleSetQueryData]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ export type OrderByFilterProps = {
isRawQuery?: boolean;
entityVersion?: string;
isNewQueryV2?: boolean;
placeholder?: string;
};
export type OrderByFilterValue = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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