mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 14:40:41 +01:00
Compare commits
9 Commits
feat/ai-tr
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb75b80df | ||
|
|
1c6464189c | ||
|
|
ee731733d0 | ||
|
|
34afabd7f7 | ||
|
|
c459702daf | ||
|
|
5acab42d52 | ||
|
|
b0afb26fad | ||
|
|
81e89f3f95 | ||
|
|
7105e0f40a |
@@ -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.
|
||||
|
||||
@@ -3702,6 +3702,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4444,6 +4451,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4459,6 +4467,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4472,6 +4481,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4557,6 +4567,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4636,6 +4658,16 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4824,6 +4856,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -4937,6 +5067,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
@@ -10517,11 +10652,13 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
formattedInput:
|
||||
formatted_input:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formattedOutput:
|
||||
formatted_output:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatterWarnings:
|
||||
formatter:
|
||||
type: string
|
||||
formatter_warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
@@ -10567,9 +10704,10 @@ components:
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
- formattedInput
|
||||
- formattedOutput
|
||||
- formatterWarnings
|
||||
- formatted_input
|
||||
- formatted_output
|
||||
- formatter
|
||||
- formatter_warnings
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
@@ -10650,14 +10788,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 +16575,12 @@ 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, or
|
||||
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
|
||||
are decoded into formatted_input and formatted_output, with formatter naming
|
||||
the converter and formatter_warnings what it could not resolve. Pass nextCursor
|
||||
as after or prevCursor as before to page, or spanId to open the page around
|
||||
a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
@@ -28594,9 +28724,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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -11789,12 +11789,16 @@ export interface SpantypesThreadSpanDTO {
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
formattedInput: AiobservabilitytypesInputMessagesDTO;
|
||||
formattedOutput: AiobservabilitytypesOutputMessagesDTO;
|
||||
formatted_input: AiobservabilitytypesInputMessagesDTO;
|
||||
formatted_output: AiobservabilitytypesOutputMessagesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
formatter: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatterWarnings: string[];
|
||||
formatter_warnings: string[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -11910,12 +11914,6 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
* @minimum 0
|
||||
*/
|
||||
flags?: number;
|
||||
formattedInput?: AiobservabilitytypesInputMessagesDTO;
|
||||
formattedOutput?: AiobservabilitytypesOutputMessagesDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatterWarnings?: string[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -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, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' | ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const SIDEBAR_TOOLTIP_DELAY_MS = 500;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface QuickFilterChangeEventData {
|
||||
|
||||
export interface IQuickFiltersProps {
|
||||
config: IQuickFiltersConfig[];
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
handleFilterVisibilityChange: () => void;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
|
||||
@@ -15,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
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
73
frontend/src/container/ExplorerOrderBy/index.tsx
Normal file
73
frontend/src/container/ExplorerOrderBy/index.tsx
Normal 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);
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -5,5 +5,5 @@ export type GroupByFilterProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (values: BaseAutocompleteData[]) => void;
|
||||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
signalSource?: string;
|
||||
};
|
||||
|
||||
@@ -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)' : ''}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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--');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -9,7 +9,6 @@ export type OrderByFilterProps = {
|
||||
isRawQuery?: boolean;
|
||||
entityVersion?: string;
|
||||
isNewQueryV2?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type OrderByFilterValue = {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }]),
|
||||
]);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { useTraceActions } from '../useTraceActions';
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
const mockNotifications = {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockQueryClient = {
|
||||
fetchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock the hooks and dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({ notifications: mockNotifications }),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
|
||||
}));
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): any => mockQueryClient,
|
||||
}));
|
||||
|
||||
// Mock the API response for getAggregateKeys
|
||||
const mockAggregateKeysResponse = {
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.method',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
|
||||
});
|
||||
|
||||
describe('useTraceActions Hook User Flow Tests', () => {
|
||||
it('should handle copy field name action', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test copying field name
|
||||
result.current.onCopyFieldName('http.method');
|
||||
|
||||
// Verify clipboard and notification
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('http.method');
|
||||
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||
message: 'Field name copied to clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle copy field value action', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test copying field value
|
||||
result.current.onCopyFieldValue('GET');
|
||||
|
||||
// Verify clipboard and notification
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('GET');
|
||||
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||
message: 'Field value copied to clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle copy field value with quoted strings', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test copying quoted field value (should remain as-is since hook doesn't strip quotes)
|
||||
result.current.onCopyFieldValue('"quoted_value"');
|
||||
|
||||
// Verify clipboard contains the exact value passed
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"quoted_value"');
|
||||
expect(mockNotifications.success).toHaveBeenCalledWith({
|
||||
message: 'Field value copied to clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle group by attribute action', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test group by action
|
||||
await result.current.onGroupByAttribute('http.method');
|
||||
|
||||
// Verify navigation with groupBy applied
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
groupBy: expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'http.method' }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle filter actions with proper query building', async () => {
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test inclusive filter
|
||||
await result.current.onAddToQuery('http.method', 'GET', '=');
|
||||
|
||||
// Verify navigation to traces explorer with inclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Reset mock and test exclusive filter
|
||||
mockRedirectWithQueryBuilderData.mockClear();
|
||||
await result.current.onAddToQuery('http.method', 'POST', '!=');
|
||||
|
||||
// Verify navigation to traces explorer with exclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '!=',
|
||||
value: 'POST',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// Mock API to throw error
|
||||
mockQueryClient.fetchQuery.mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const { result } = renderHook(() => useTraceActions());
|
||||
|
||||
// Test that error is handled properly
|
||||
await result.current.onAddToQuery('http.method', 'GET', '=');
|
||||
|
||||
// Verify error notification is shown
|
||||
await waitFor(() => {
|
||||
expect(mockNotifications.error).toHaveBeenCalledWith({
|
||||
message: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user