Compare commits

...

13 Commits

Author SHA1 Message Date
Nikhil Soni
a80efc5218 test(integration): assert span attributes return flat, not nested
Pins that the native read yields flat dotted keys (db is not a nested key),
so no Go-side flattening is needed after dropping flattenJSONPaths.

Assisted-by: Claude Opus 4.8
2026-10-14 20:39:56 +05:30
Nikhil Soni
ecc2219f9c refactor(querier): reuse the already-flat attributes bag directly
The attributes JSON column decodes to flat dotted keys (ValuesByPath), so
mergeSpanAttributeColumns merges the legacy maps straight into that map
instead of flattening via the old flattenJSONPaths util. Drops the util and
the extra copy; test inputs are flattened to match the real shape.

Assisted-by: Claude Opus 4.8
2026-10-14 20:27:18 +05:30
Nikhil Soni
de4691ad0a refactor(spantypes): read trace attributes as native chcol.JSON
StorableSpan.AttributesJSON moves from the removed JSONValue to chcol.JSON.
threadAttributes flattens via ValuesByPath (keeping a scalar-and-object key
as two dotted keys); rawAttribute walks the nested document decoded from
MarshalJSON so FormatMessages still sees raw structured values.

Assisted-by: Claude Opus 4.8
2026-10-14 19:39:36 +05:30
Nikhil Soni
c8fa90bab0 refactor(telemetrystore): read JSON columns natively via chcol.JSON
Drop the read-as-string setting and the WrapRows/JSONValue machinery; read
JSON columns as the driver's native chcol.JSON. The span attributes bag
decodes flat (ValuesByPath) so a key stored as both a scalar and an object
keeps both dotted keys; other JSON columns decode nested (NestedMap). Adds
integration tests for both the flat (traces) and nested (logs body) cases.

Assisted-by: Claude Opus 4.8
2026-10-14 19:30:38 +05:30
Aditya Singh
aaec7c89c5 feat(saved-views): hide the old saved views ui behind the flag (#13028)
#### Description
- with the saved views flag on, the old views ui is hidden.. bottom
explorer bar on logs / traces / metrics / meter, the Views tabs on those
pages and the All Views link on the home widget. views are in the
explorer header and panel now.
- views shortcuts and the go to views palette entries removed, not
behind the flag since the flag turns on next.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71

#### Additional Information
- meter never had create alert / add to dashboard in its bar (hidden for
meter), so hiding it there loses nothing.. the bottom bar commit note
saying otherwise is wrong.
- old views urls still open the page with an empty pane when the flag is
on.. routes go away in the cleanup PR.
- flag off is same as main apart from the shortcuts.
2026-10-14 13:21:17 +00:00
Aditya Singh
57c193ec8f feat(saved-views): all views panel in the quick filters sidebar (#13022)
#### Description
- real all views panel in the sidebar.. views split into created by me /
created by others, search by name, `↺` on the open view to clear it.
empty state has a create view button, same modal as the header.
- row `...` menu with copy link, edit view details and delete. copy link
copies the whole explorer link, a link with only the viewKey does not
open the view (same in the v1 bar).
- hover card on a view like the trace waterfall one.. filter per query
with items and expression combined, coloured with the query search
theme, metric line for metrics and meter, last updated.
`@lezer/highlight` added for the colouring, same version already in the
tree.
- explorer columns for a view now come from the view itself, not the
views list.. if the list call failed the explorer fell back to default
columns and the header went amber with no way to discard. header shows a
red failed to load state when the view itself does not load.
- unsaved changes check maps the stored view through the same mapper as
the page.. older views kept fields we never write (`reduceTo` on a
graph) and were always amber.
- qf settings drawer matched to the panel, sidebar tooltips wait 500ms,
meter explorer gets saved views, metrics explorer tab loses the extra
padding around the sidebar.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71

#### Screen recording



https://github.com/user-attachments/assets/f882b5b7-c108-49f3-b62e-132b0db3a60f



#### Additional Information
- behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- make default, duplicate and add to shortcuts not in this PR.. default
needs a backend api.
- qf settings drawer is a shared component, every qf page gets the new
header and search.
- browser pass on logs / traces / metrics / meter in progress.
2026-10-14 13:20:20 +00:00
Tushar Vats
da556abac1 feat(logparsingpipeline): order user pipelines around signozlogsnormalizer (#13106)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Opamp places user log pipelines around the first
`signozlogsnormalizer` in the logs pipeline, per its `opamp.position`:
the normalizer runs after them by default (`after`), before them with
`before`. Other processors keep their places.
- Without the normalizer, processor ordering is unchanged.
- The `use_json_body`-prepended normalize operator is dropped when the
normalizer already runs ahead of user pipelines (`before`); otherwise
both are kept.
- Removes the `json_body_dual_ingestion` flag: the normalize operator no
longer stashes the original body, and isn't appended after user
pipelines when only that flag is on.

| `use_json_body` | `opamp.position` | Logs pipeline | Normalize
operator |
|---|---|---|---|
| off | no normalizer | memory_limiter → user → custom → batch
(unchanged) | — |
| off | `after` (default) | … → user → normalizer → … | — |
| off | `before` | … → normalizer → user → … | — |
| on | no normalizer | memory_limiter → normalize op → user → custom →
batch | prepended |
| on | `after` (default) | … → normalize op → user → normalizer → … |
prepended (both normalize) |
| on | `before` | … → normalizer → user → … | dropped |

#### Additional Information

- Depends on SigNoz/signoz-otel-collector#953 for the `opamp.position`
config.
- The pipelines preview still prepends the operator under
`use_json_body`, since it can't see per-collector `opamp.position`.
- Configs still setting `json_body_dual_ingestion` keep working; the
unknown flag is skipped with a warning.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-13 15:32:50 +00:00
Tushar Vats
cc8440248b chore(claude): run py-fmt and py-lint after python changes (#13116)
#### Description

- Adds a `pytest.md` rule: run `make py-fmt` and `make py-lint` after
changing `.py` files, matching what integration CI enforces.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-13 13:30:17 +00:00
Nityananda Gohain
e84ddfb61e chore: delete unwanted models from pricing db (#13079)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Cleaning out models which are now not synced by zeus.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/402


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Please check issue for more details
2026-10-13 12:13:17 +00:00
Nityananda Gohain
dc9fda2916 feat(tracedetail): normalise gen_ai messages in thread spans (#13003)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
#### Description

- Thread spans now carry `formatted_input`, `formatted_output`,
`formatter` and `formatter_warnings`, with the messages in the OTel
GenAI shape; the `genai` types mirror the semconv schema.
- Tool spans are thread rows too, gated on `gen_ai.tool.name`, since
they carry no input or output messages.
- One normalizer added: the semconv passthrough plus the OpenAI chat
format; a bare string becomes `text`, anything else `generic`, both with
a warning. Other formats follow in separate PRs.
- Unknown keys are dropped from the formatted view, the raw attribute on
the span keeps them; object attributes are read from the JSON column
only.
- Formatter tests use the message attributes as the collector stored
them for the SigNoz/scripts captures (litellm, langchain, OpenInference,
bifrost), not hand-written samples.


<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266


#### Additional information.
Please note that functions in formatter.go and openaichat.go are AI
generated, and I have tested them through tests.

- Converters are per message format, not per SDK. Covered now: SDKs
writing semconv (litellm, langchain, openllmetry, portkey, traceloop LLM
spans) and the OpenAI chat format (bifrost, openrouter input,
OpenInference raw OpenAI calls). Not yet: tool
spans, OpenInference flattened messages, OpenAI Responses items,
openrouter's completion object, Vercel, traceloop legacy prompt keys,
raw Anthropic and Gemini. The full table is in the design doc.
- OpenAPI spec and the generated frontend client are in a separate
commit.

<!--Please delete paragraphs that you did not use before submitting.-->

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:43:34 +00:00
Naman Verma
670677bae2 feat: add ai builder query plugin kind (#12833)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Adds a new query plugin kind `signoz/AIBuilderQuery`. Its corresponding
spec is equivalent to `qb.QueryBuilderQuery[qb.TraceAggregation]`

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/334

#### Screenshots / Screen Recordings




https://github.com/user-attachments/assets/3388abc1-f079-464c-b660-7b65ef54cb68



<!--Anything reviewers should keep in mind while reviewing -->

#### Additional Information

Will need to build UI changes on top of this

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-13 11:05:23 +00:00
Abhi kumar
e4d66c5413 feat(dashboard): support variables in panel title (#12988)
#### Description

- Panel titles resolve dashboard variables (`$var`, `{{var}}`,
`[[var]]`) in the panel header, View modal and download filenames,
reusing the Text panel's `interpolateVariables`.
- Editor title input suggests dashboard variables after `$` (V1 parity,
#7898).

#### Issues closed by this PR

Closes SigNoz/pulse-pod#327
2026-10-13 10:49:14 +00:00
Abhi kumar
1371cb0beb fix(dashboard): stop panel type drawers flashing on open and close (#13114)
#### Description

- The "New panel" and "Change panel type" drawers flash for one frame
when they finish opening or closing.
- Cause: `@signozhq/ui`'s `DrawerWrapper` runs its slide on WAAPI via
`motion@11`, which drops the native animation before writing the final
style, so a frame paints with the stale start position.
- Passing an `onUpdate` to the drawer content makes motion drive the
slide in JS. On a live dashboard this went from 24 jumps over 8
open/close cycles to 0.

#### Additional Information

- Workaround only; the proper fix belongs in `@signozhq/ui` (separate
PR). Remove `utils/drawerSlide.ts` and its two usages once that lands.
2026-10-13 09:32:35 +00:00
200 changed files with 8445 additions and 1637 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,11 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
displayName: 'Time Series',
icon: (): null => null,
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
supportedQueryModesKindMap: {
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
clickhouse_sql: { kind: 'signal-less' },
promql: { kind: 'signal-less' },
},
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },

View File

@@ -2,26 +2,33 @@ import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Atom, Brain, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { clearPreviousQuery } from 'components/QueryBuilderV2/QueryV2/previousQuery.utils';
import TextToolTip from 'components/TextToolTip';
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { isRawRequest } from '../../Panels/types/panelCapabilities';
import {
getQueryModeSignals,
isRawRequest,
} from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import { getQueryMode, isBuilderMode } from 'lib/query/queryMode';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -29,6 +36,8 @@ interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types, the signals the
* builder may be pointed at, and how it narrows the builder's fields. */
panelDefinition: RenderableQueryPanelDefinition;
/** Switch authoring tab; owned by the host, which remembers the Query Builder query. */
onChangeQueryMode: (key: string) => void;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
@@ -48,6 +57,7 @@ interface PanelEditorQueryBuilderProps {
*/
function PanelEditorQueryBuilder({
panelDefinition,
onChangeQueryMode,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
@@ -60,18 +70,16 @@ function PanelEditorQueryBuilder({
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const { currentQuery } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
);
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
useEffect(() => {
clearPreviousQuery();
return (): void => {
clearPreviousQuery();
};
}, []);
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
@@ -88,10 +96,11 @@ function PanelEditorQueryBuilder({
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
const queryTypeComponents: Record<
QueryMode,
{ icon: ReactNode; label: string; component: ReactNode }
> = {
[QueryMode.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
@@ -99,7 +108,10 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
allowedDataSources={panelDefinition.supportedSignals}
allowedDataSources={getQueryModeSignals(
panelDefinition.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)}
showTraceOperator={!isRawQuery}
version="v3"
isRawQuery={isRawQuery}
@@ -109,12 +121,12 @@ function PanelEditorQueryBuilder({
</div>
),
},
[EQueryType.CLICKHOUSE]: {
[QueryMode.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[EQueryType.PROM]: {
[QueryMode.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
@@ -123,9 +135,33 @@ function PanelEditorQueryBuilder({
label: 'PromQL',
component: <PromQLQueryContainer />,
},
[QueryMode.AI_QUERY_BUILDER]: {
icon: <Brain size={14} />,
label: 'AI Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
showTraceOperator={false}
version="v3"
isRawQuery={isRawQuery}
config={{
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
}}
savePreviousQuery
/>
</div>
),
},
};
return supportedQueryTypes.map((queryType) => ({
const modes = Object.keys(
panelDefinition.supportedQueryModesKindMap,
) as QueryMode[];
return modes.map((queryType) => ({
key: queryType,
label: (
<div className={styles.queryTypeTab}>
@@ -134,6 +170,8 @@ function PanelEditorQueryBuilder({
</div>
),
children: queryTypeComponents[queryType].component,
// QB wants a null data source and AI pins traces: both mounted, they overwrite each other's provider config in a loop.
destroyInactiveTabPane: isBuilderMode(queryType),
}));
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
@@ -150,8 +188,8 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
activeKey={getQueryMode(currentQuery)}
onChange={onChangeQueryMode}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />

View File

@@ -8,6 +8,7 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
function QueryBuilderEditorPane({
panelDefinition,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -16,6 +17,7 @@ function QueryBuilderEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,11 +1,17 @@
import { render, screen } from '@testing-library/react';
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
toPanelType,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
import { useQueryModeChange } from '../../hooks/useQueryModeChange';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
const mockQueryBuilderV2 = jest.fn();
@@ -43,17 +49,41 @@ jest.mock('assets/Dashboard/PromQl', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
function renderBuilder(panelKind: string): void {
render(
const BUILDER_QUERY = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'traces' }] },
};
const AI_QUERY = {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
},
};
/** Stands in for the editor shell: owns the tab switch and its parked Query Builder query. */
function BuilderHost({ panelKind }: { panelKind: PanelKind }): JSX.Element {
const panelDefinition = requireQueryPanelDefinition(panelKind);
const parkedBuilders = useRef<BuildersByVariant>({});
const onChangeQueryMode = useQueryModeChange({
panelType: toPanelType(panelKind),
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
parkedBuilders,
});
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
panelDefinition={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}
onCancelQuery={jest.fn()}
/>,
/>
);
}
function renderBuilder(panelKind: string): void {
render(<BuilderHost panelKind={panelKind as PanelKind} />);
}
function lastQueryBuilderProps(): {
panelType: string;
isRawQuery: boolean;
@@ -69,12 +99,12 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: BUILDER_QUERY,
redirectWithQueryBuilderData: jest.fn(),
});
});
it('shows only the Query Builder tab for the List kind', () => {
it('shows no ClickHouse or PromQL tab for the List kind', () => {
renderBuilder('signoz/ListPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
@@ -90,12 +120,126 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all three tabs for the Time Series kind', () => {
it('shows all four tabs for the Time Series kind', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
});
it('shows the AI tab for the Table kind', () => {
renderBuilder('signoz/TablePanel');
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
});
it('shows the AI tab for the List kind', () => {
renderBuilder('signoz/ListPanel');
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
});
});
describe('PanelEditorQueryBuilder AI tab', () => {
const redirectWithQueryBuilderData = jest.fn();
const updateAllQueriesOperators = jest.fn((query: unknown) => query);
function mockBuilder(currentQuery: unknown): void {
mockUseQueryBuilder.mockReturnValue({
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
});
}
beforeEach(() => {
jest.clearAllMocks();
});
it('activates the AI tab for an AI query and pins the builder to traces', () => {
mockBuilder(AI_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
expect(
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
).toBeInTheDocument();
expect(lastQueryBuilderProps()).toMatchObject({
config: { initialDataSource: 'traces', queryVariant: 'static' },
showTraceOperator: false,
});
});
it('seeds an AI traces query when switching to the AI tab', () => {
mockBuilder(BUILDER_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('AI Query Builder'));
expect(updateAllQueriesOperators).toHaveBeenCalledWith(
expect.anything(),
'graph',
'traces',
);
const [seeded] = redirectWithQueryBuilderData.mock.calls[0];
expect(seeded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(seeded.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
});
it('keeps the builder query when returning from ClickHouse', () => {
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs', legend: 'kept' }] },
};
mockBuilder(logsQuery);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('ClickHouse Query'));
const [onClickHouse] = redirectWithQueryBuilderData.mock.calls[0];
expect(onClickHouse.queryType).toBe(EQueryType.CLICKHOUSE);
// The builder slot already holds the Query Builder query — nothing to swap.
expect(onClickHouse.builder).toBe(logsQuery.builder);
});
it('shows a fresh default query when leaving AI', () => {
mockBuilder(AI_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('Query Builder'));
const [next] = redirectWithQueryBuilderData.mock.calls[0];
expect(next.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(next.builder.queryData[0].builderQueryType).toBeUndefined();
expect(next.builder.queryData[0].dataSource).toBe('metrics');
});
it("keeps each tab's own query across Query Builder ↔ AI round trips", () => {
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs' }] },
};
const host = (): JSX.Element => (
<BuilderHost panelKind="signoz/TimeSeriesPanel" />
);
mockBuilder(logsQuery);
const { rerender } = render(host());
fireEvent.click(screen.getByText('AI Query Builder'));
const [aiQuery] = redirectWithQueryBuilderData.mock.calls[0];
// The user writes an AI query, then goes back to Query Builder.
const editedAIBuilder = {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
};
mockBuilder({ ...aiQuery, builder: editedAIBuilder });
rerender(host());
fireEvent.click(screen.getByText('Query Builder'));
const [backToQB] = redirectWithQueryBuilderData.mock.calls[1];
expect(backToQB.builder).toBe(logsQuery.builder);
mockBuilder(backToQB);
rerender(host());
fireEvent.click(screen.getByText('AI Query Builder'));
const [backToAI] = redirectWithQueryBuilderData.mock.calls[2];
expect(backToAI.builder).toBe(editedAIBuilder);
});
});
@@ -103,7 +247,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: BUILDER_QUERY,
redirectWithQueryBuilderData: jest.fn(),
});
});
@@ -144,7 +288,7 @@ describe('PanelEditorQueryBuilder signal dropdown (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: BUILDER_QUERY,
redirectWithQueryBuilderData: jest.fn(),
});
});

View File

@@ -71,6 +71,8 @@ interface QueryEditorBodyProps {
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/** Tab switch, owned by the shell alongside the kind switch it shares memory with. */
onChangeQueryMode: (key: string) => void;
}
/**
@@ -92,6 +94,7 @@ function QueryEditorBody({
panelDefinition,
onChangePanelKind,
originalPanelKind,
onChangeQueryMode,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -307,6 +310,7 @@ function QueryEditorBody({
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}

View File

@@ -60,8 +60,9 @@ jest.mock('../hooks/usePanelEditorQuerySync', () => ({
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelTypeSwitch', () => ({
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
jest.mock('../hooks/usePanelKindAndQueryModeSwitch', () => ({
usePanelKindAndQueryModeSwitch: (args: unknown): unknown =>
mockUseTypeSwitch(args),
}));
jest.mock('../hooks/usePanelEditorSave', () => ({

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
@@ -21,6 +21,8 @@ import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import { useQueryModeChange } from '../hooks/useQueryModeChange';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
@@ -82,10 +84,18 @@ function EditorRoute(): JSX.Element {
signal: TelemetrytypesSignalDTO.metrics,
savedQueries: panel.spec.queries,
});
const panelDefinition = requireQueryPanelDefinition('signoz/TimeSeriesPanel');
const parkedBuilders = useRef<BuildersByVariant>({});
const onChangeQueryMode = useQueryModeChange({
panelType: PANEL_TYPES.TIME_SERIES,
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
parkedBuilders,
});
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
panelDefinition={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
isLoadingQueries={false}
onStageRunQuery={noop}
onCancelQuery={noop}

View File

@@ -45,8 +45,16 @@ const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
const SEED_V1 = {
id: 'seed',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const STAGED_V1 = {
id: 'staged',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
function makeDraft(
queries = SAVED_QUERIES,
@@ -74,7 +82,11 @@ function builderState(
handleRunQuery: jest.Mock;
} {
return {
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
currentQuery: {
id: 'current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query,
stagedQuery: STAGED_V1,
handleRunQuery: jest.fn(),
...overrides,
@@ -396,8 +408,16 @@ describe('usePanelEditorQuerySync', () => {
const EDITED_ENVELOPES = [
{ id: 'edited-envelopes' },
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
const editedQuery = {
id: 'edited',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const unchangedQuery = {
id: 'unchanged',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
beforeEach(() => {
mockToPerses.mockImplementation((query: Query) =>
@@ -468,4 +488,66 @@ describe('usePanelEditorQuerySync', () => {
expect(result.current.isQueryDirty).toBe(false);
});
});
describe('AI queries are saved like any builder query', () => {
const aiQuery = {
id: 'ai',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
it('keeps the AI tag when seeding, so a saved AI panel reopens on the AI tab', () => {
const savedAiQuery = {
id: 'saved-ai',
queryType: 'builder',
builder: {
queryData: [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
],
},
} as unknown as Query;
mockFromPerses.mockReturnValue(savedAiQuery);
setup({ savedQueries: SAVED_QUERIES });
const [seeded] = mockUseShareBuilderUrl.mock.calls[0];
expect(seeded.defaultValue).toBe(savedAiQuery);
});
it('serializes the live AI query, not the seed', () => {
// Input-sensitive so the envelope compare reads dirty and names what was serialized.
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'ai' ? CONVERTED_QUERIES : SAVED_QUERIES,
);
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery: aiQuery }));
const { result } = setup({ savedQueries: SAVED_QUERIES });
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
});
});
it('still saves a ClickHouse query while an AI query sits in the builder slot', () => {
const clickHouseQuery = {
...aiQuery,
id: 'ch',
queryType: 'clickhouse_sql',
} as unknown as Query;
// Input-sensitive so the envelope compare reads dirty and the normal path runs.
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'ch' ? CONVERTED_QUERIES : SAVED_QUERIES,
);
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: clickHouseQuery }),
);
const { result } = setup({ savedQueries: SAVED_QUERIES });
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: CONVERTED_QUERIES,
});
});
});
});

View File

@@ -5,11 +5,11 @@ import { handleQueryChange } from 'lib/query/panelQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import { resolveQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
import { usePanelKindAndQueryModeSwitch } from '../usePanelKindAndQueryModeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -18,7 +18,10 @@ jest.mock('lib/query/panelQuery', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
resolveQueryMode: jest.fn(),
getSupportedSignals: jest.fn(() => ['metrics']),
getQueryPanelDefinition: jest.requireActual('../../../Panels/capabilities')
.getQueryPanelDefinition,
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
@@ -36,7 +39,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
@@ -79,14 +82,27 @@ const tableSpec = makeSpec(
);
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
function fakeQuery(
id: string,
queryType: string,
queryData: Record<string, unknown>[] = [],
): Query {
return { id, queryType, builder: { queryData } } as unknown as Query;
}
function builderState(currentQuery: Query): {
currentQuery: Query;
redirectWithQueryBuilderData: jest.Mock;
updateAllQueriesOperators: jest.Mock;
} {
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
return {
currentQuery,
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn((query: Query) => query),
};
}
describe('usePanelTypeSwitch', () => {
describe('usePanelKindAndQueryModeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
@@ -95,16 +111,16 @@ describe('usePanelTypeSwitch', () => {
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
// The guard owns coercion (tested in capabilities.test.ts); here it always
// resolves to Query Builder so the coerced type flows into handleQueryChange.
mockResolveQueryType.mockReturnValue('builder');
mockResolveQueryMode.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
const state = builderState(fakeQuery('q', 'builder'));
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -118,12 +134,12 @@ describe('usePanelTypeSwitch', () => {
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const tableQuery = fakeQuery('table-current', 'builder');
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -146,7 +162,7 @@ describe('usePanelTypeSwitch', () => {
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState(fakeQuery('ts-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -155,7 +171,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -174,7 +190,7 @@ describe('usePanelTypeSwitch', () => {
it('keeps one query and drops the formulas when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState(fakeQuery('ts-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -186,7 +202,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -204,7 +220,7 @@ describe('usePanelTypeSwitch', () => {
it('swaps a percentile spatial aggregation for count when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState(fakeQuery('ts-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -222,7 +238,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -242,7 +258,7 @@ describe('usePanelTypeSwitch', () => {
it('swaps count back for a percentile when a histogram leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
builderState(fakeQuery('heatmap-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -263,7 +279,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -283,7 +299,7 @@ describe('usePanelTypeSwitch', () => {
it('leaves a non-histogram metric on sum when it leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
builderState(fakeQuery('heatmap-current', 'builder')),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -304,7 +320,7 @@ describe('usePanelTypeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -318,11 +334,11 @@ describe('usePanelTypeSwitch', () => {
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
const promQuery = fakeQuery('prom', 'promql');
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelTypeSwitch({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -331,7 +347,7 @@ describe('usePanelTypeSwitch', () => {
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind…
expect(mockResolveQueryType).toHaveBeenCalledWith(
expect(mockResolveQueryMode).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
);
@@ -340,16 +356,224 @@ describe('usePanelTypeSwitch', () => {
expect((queryArg as Query).queryType).toBe('builder');
});
it('keeps the AI tag when the new kind supports AI', () => {
const setSpec = jest.fn();
const aiQuery = fakeQuery('ai', 'builder', [
{ builderQueryType: 'builder_ai_query' },
]);
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
mockResolveQueryMode.mockReturnValue('builder_ai_query');
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).builder).toBe(aiQuery.builder);
expect((queryArg as Query).builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('drops the AI tag when the new kind does not support AI', () => {
const setSpec = jest.fn();
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
// The guard coerces to Query Builder; the tag must not survive it.
mockResolveQueryMode.mockReturnValue('builder');
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
expect(redirected.builder.queryData[0].dataSource).toBe('traces');
});
it('stays on the AI tab when returning to a kind last left on Query Builder', () => {
const setSpec = jest.fn();
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
let state = builderState(qbQuery);
mockUseQueryBuilder.mockImplementation(() => state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table on Query Builder — its stash holds the QB query.
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// On List the user moves to the AI tab, then returns to Table.
state = builderState(aiQuery);
mockResolveQueryMode.mockReturnValue('builder_ai_query');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
const restored = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
// The kind's own display spec still comes back; only the query follows the tab.
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restored.queries).toBe(CONVERTED);
// Table's Query Builder query waited behind the AI tab — the tab switch gives it back.
act(() => result.current.onChangeQueryMode('builder'));
const backToQB = state.redirectWithQueryBuilderData.mock.calls[1][0] as Query;
expect(backToQB.builder).toBe(qbQuery.builder);
});
describe('both builder tabs, per kind', () => {
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
// Table gets a Query Builder query and an AI one; the user leaves it on AI for List.
function leaveTableOnAI(): {
result: { current: ReturnType<typeof usePanelKindAndQueryModeSwitch> };
rerender: (props: {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
}) => void;
setSpec: jest.Mock;
setState: (query: Query) => ReturnType<typeof builderState>;
} {
const setSpec = jest.fn();
let state = builderState(qbQuery);
mockUseQueryBuilder.mockImplementation(() => state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
const setState = (query: Query): ReturnType<typeof builderState> => {
state = builderState(query);
return state;
};
act(() => result.current.onChangeQueryMode('builder_ai_query'));
setState(aiQuery);
rerender({ spec: tableSpec, panelType: PANEL_TYPES.TABLE });
mockResolveQueryMode.mockReturnValue('builder_ai_query');
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
return { result, rerender, setSpec, setState };
}
it('rebuilds the parked query for the new kind on a first visit', () => {
const { result } = leaveTableOnAI();
// Table's Query Builder query was rebuilt for List, and is one tab away there.
expect(mockHandleQueryChange).toHaveBeenCalledWith(
'list',
expect.objectContaining({ builder: qbQuery.builder }),
PANEL_TYPES.TABLE,
);
act(() => result.current.onChangeQueryMode('builder'));
const { redirectWithQueryBuilderData } =
mockUseQueryBuilder.mock.results[
mockUseQueryBuilder.mock.results.length - 1
].value;
const backToQB = redirectWithQueryBuilderData.mock.calls[
redirectWithQueryBuilderData.mock.calls.length - 1
][0] as Query;
expect(backToQB.builder.queryData[0].dataSource).toBe('logs');
expect(backToQB.builder.queryData[0].builderQueryType).toBeUndefined();
});
it("brings back both tabs' queries when revisiting a kind", () => {
const { result, rerender, setSpec, setState } = leaveTableOnAI();
// Back to Table from List, still on the AI tab.
const state = setState(
fakeQuery('list-ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]),
);
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(restored.builder).toBe(aiQuery.builder);
const restoredSpec = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restoredSpec.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restoredSpec.queries).toBe(TABLE_QUERIES);
// Table's Query Builder query is one tab away.
act(() => result.current.onChangeQueryMode('builder'));
const backToQB = state.redirectWithQueryBuilderData.mock
.calls[1][0] as Query;
expect(backToQB.builder).toBe(qbQuery.builder);
});
it('swaps in the Query Builder query when revisiting a kind left on AI', () => {
const { result, rerender, setSpec, setState } = leaveTableOnAI();
// On List the user is back on Query Builder, then returns to Table.
const state = setState(
fakeQuery('list-qb', 'builder', [{ dataSource: 'logs' }]),
);
mockResolveQueryMode.mockReturnValue('builder');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(restored.builder).toBe(qbQuery.builder);
// The stashed preview queries were the AI query's; they're rebuilt for the swap.
const restoredSpec = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restoredSpec.queries).toBe(CONVERTED);
// Table's AI query is one tab away.
act(() => result.current.onChangeQueryMode('builder_ai_query'));
const backToAI = state.redirectWithQueryBuilderData.mock
.calls[1][0] as Query;
expect(backToAI.builder).toBe(aiQuery.builder);
});
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
const tableQuery = fakeQuery('table-current', 'builder');
const listQuery = fakeQuery('list-current', 'builder');
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelTypeSwitch({ ...props, setSpec }),
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);

View File

@@ -4,7 +4,10 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import {
getSupportedSignals,
requireQueryPanelDefinition,
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
@@ -91,7 +94,7 @@ export function usePanelEditSession({
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelType = toPanelType(panelKind);
const defaultSignal = panelDefinition.supportedSignals[0];
const defaultSignal = getSupportedSignals(panelKind)[0];
const query = usePanelQuery({
panel: draft,

View File

@@ -0,0 +1,289 @@
import { useCallback, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'lib/query/panelQuery';
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import {
type BuilderVariant,
type BuildersByVariant,
type OrderByPayload,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import {
getQueryPanelDefinition,
isStaticPanelKind,
resolveQueryMode,
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getQueryModeSignals } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
getBuilderVariant,
getQueryMode,
toBuilderVariant,
withoutAIQueryTag,
} from 'lib/query/queryMode';
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
import {
withPercentileHistogramAggregation,
withSingleHeatmapQuery,
} from '../utils/heatmapQuery';
import { useQueryModeChange } from './useQueryModeChange';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
function withDefaultListOrder(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) =>
qd.orderBy && qd.orderBy.length > 0
? qd
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
),
},
};
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[];
query: Query;
/** Both builder variants' queries as the kind was left. */
builders: BuildersByVariant;
}
interface UsePanelKindAndQueryModeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelKindAndQueryModeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
/** Switch authoring tab; shares the off-screen builder stash with the kind switch. */
onChangeQueryMode: (key: string) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
* It also owns the tab switch, stashing the off-screen builder variant per kind.
*/
export function usePanelKindAndQueryModeSwitch({
spec,
panelType,
setSpec,
}: UsePanelKindAndQueryModeSwitchArgs): UsePanelKindAndQueryModeSwitchApi {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Builder queries for the tabs not on screen; the live one is `currentQuery.builder`.
const parkedBuilders = useRef<BuildersByVariant>({});
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangeQueryMode = useQueryModeChange({
panelType,
supportedQueryModesKindMap:
getQueryPanelDefinition(spec.plugin.kind as PanelKind)
?.supportedQueryModesKindMap ?? {},
parkedBuilders,
});
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
from: oldKind,
to: newKind,
});
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries,
query,
builders: {
...parkedBuilders.current,
[getBuilderVariant(query.builder)]: query.builder,
},
});
const newPanelType = toPanelType(newKind);
const targetMode = resolveQueryMode(newKind, getQueryMode(query));
const targetVariant = toBuilderVariant(targetMode);
// An AI query is a builder query carrying the tag, so it rides on `builder`.
const targetQueryType =
targetMode === QueryMode.AI_QUERY_BUILDER
? QueryMode.QUERY_BUILDER
: targetMode;
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
// `createDefaultPanel`). The surrounding spec stays fully typed.
const buildSpec = (
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
queries: DashboardtypesQueryDTO[],
): DashboardtypesPanelSpecDTO => ({
...currentSpec,
plugin: {
...currentSpec.plugin,
kind: newKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
});
const rebuildForNewKind = (source: Query): Query => {
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...source, queryType: targetQueryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const ordered =
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
return newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(ordered)
: withPercentileHistogramAggregation(ordered);
};
// Tab unused on this kind: AI gets a fresh seed, Query Builder reuses `source` untagged.
const seedTargetTab = (source: Query): Query =>
targetMode === QueryMode.AI_QUERY_BUILDER
? {
...source,
builder: seedBuilderForMode({
mode: targetMode,
defaultSignal: getQueryModeSignals(
getQueryPanelDefinition(newKind)?.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)[0],
panelType: newPanelType,
updateAllQueriesOperators,
}),
}
: withoutAIQueryTag(source);
// Target tab's query comes from the stash; the rest stay parked.
const unparkTargetTab = (
source: Query,
builders: BuildersByVariant,
): { query: Query; parked: BuildersByVariant } => {
const stored = targetVariant ? builders[targetVariant] : source.builder;
const next = stored
? { ...source, builder: stored }
: seedTargetTab(source);
const parked: BuildersByVariant = { ...builders };
delete parked[getBuilderVariant(next.builder)];
return { query: next, parked };
};
// Revisit → restore the stash in the active mode; a static kind has no builder to re-seed.
const cached = cacheRef.current.get(newKind);
if (cached) {
if (isStaticPanelKind(newKind)) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
return;
}
const restored = unparkTargetTab(cached.query, cached.builders);
parkedBuilders.current = restored.parked;
// The stashed preview queries belong to the stashed query, not a swapped-in one.
setSpec(
buildSpec(
cached.pluginSpec,
restored.query.builder === cached.query.builder
? cached.queries
: toPerses(restored.query, newPanelType),
),
);
redirectWithQueryBuilderData({
...restored.query,
queryType: targetQueryType,
});
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isStaticPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
return;
}
// First visit → rebuild both builder tabs' queries for the new panel type.
const transformed = rebuildForNewKind(query);
const rebuiltBuilders: BuildersByVariant = {};
(Object.keys(parkedBuilders.current) as BuilderVariant[]).forEach(
(variant) => {
const parked = parkedBuilders.current[variant];
if (parked) {
rebuiltBuilders[variant] = rebuildForNewKind({
...query,
builder: parked,
}).builder;
}
},
);
rebuiltBuilders[getBuilderVariant(transformed.builder)] =
transformed.builder;
const picked = unparkTargetTab(transformed, rebuiltBuilders);
parkedBuilders.current = picked.parked;
const nextQuery = picked.query;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(nextQuery, newPanelType),
),
);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData, updateAllQueriesOperators],
);
return { onChangePanelKind, onChangeQueryMode };
}

View File

@@ -1,290 +0,0 @@
import { useCallback, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES, type PANEL_TYPES } from 'constants/queryBuilder';
import { handleQueryChange } from 'lib/query/panelQuery';
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import type {
IBuilderQuery,
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
MetricAggregation,
SpaceAggregation,
} from 'types/api/v5/queryRange';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
'p50',
'p75',
'p90',
'p95',
'p99',
]);
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
function withDefaultListOrder(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) =>
qd.orderBy && qd.orderBy.length > 0
? qd
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
),
},
};
}
/**
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
* query and refuses a formula over the cells. The per-kind cache restores whatever is
* dropped here if the panel switches back.
*
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
* so a percentile draws the grid a count already draws, and the statement builder sums
* the `le` counts either way.
*/
function withSingleHeatmapQuery(query: Query): Query {
const [first] = query.builder.queryData;
if (!first) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: [
{
...first,
spaceAggregation: withoutPercentile(first.spaceAggregation),
aggregations: first.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutPercentile(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
},
],
queryFormulas: [],
},
};
}
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
function withoutPercentile<T extends string | undefined>(
spaceAggregation: T,
): T {
return (
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/**
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
* so a count carried out of a heatmap would sit in the selector with no option behind
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
*/
function withPercentileHistogramAggregation(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((queryData) => {
if (
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
) {
return queryData;
}
return {
...queryData,
spaceAggregation: withoutCount(queryData.spaceAggregation),
aggregations: queryData.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutCount(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
};
}),
},
};
}
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
return (
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[];
builderQuery: Query;
}
interface UsePanelTypeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelTypeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
}
/**
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
* renderer, config sections, query-builder tabs and request type for free; this hook adds
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
*/
export function usePanelTypeSwitch({
spec,
panelType,
setSpec,
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
specRef.current = spec;
const queryRef = useRef(currentQuery);
queryRef.current = currentQuery;
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
const oldKind = currentSpec.plugin.kind as PanelKind;
if (newKind === oldKind) {
return;
}
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
from: oldKind,
to: newKind,
});
const query = queryRef.current;
cacheRef.current.set(oldKind, {
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries,
builderQuery: query,
});
const newPanelType = toPanelType(newKind);
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
// `createDefaultPanel`). The surrounding spec stays fully typed.
const buildSpec = (
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
queries: DashboardtypesQueryDTO[],
): DashboardtypesPanelSpecDTO => ({
...currentSpec,
plugin: {
...currentSpec.plugin,
kind: newKind,
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
});
// Revisit → restore the stash verbatim (the reversibility path). A static
// kind's stash carries `queries: []` and its builder query is untouched —
// there is no builder to re-seed for it.
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
if (!isStaticPanelKind(newKind)) {
redirectWithQueryBuilderData(cached.builderQuery);
}
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isStaticPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
return;
}
// First visit → coerce the query type if the new kind disallows it, then
// rebuild the builder query for the new type.
const queryType = resolveQueryType(newKind, query.queryType);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
let nextQuery =
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
nextQuery =
newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(nextQuery)
: withPercentileHistogramAggregation(nextQuery);
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
toPerses(nextQuery, newPanelType),
),
);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData],
);
return { onChangePanelKind };
}

View File

@@ -0,0 +1,83 @@
import { type MutableRefObject, useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
getQueryModeSignals,
type SupportedQueryModesKindMap,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
type BuildersByVariant,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderVariant, toBuilderVariant } from 'lib/query/queryMode';
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
interface UseQueryModeChangeArgs {
panelType: PANEL_TYPES;
supportedQueryModesKindMap: SupportedQueryModesKindMap;
/** Builder queries for the variant not on screen. */
parkedBuilders: MutableRefObject<BuildersByVariant>;
}
/**
* Tab switching. Query Builder and AI share `currentQuery.builder`, so crossing between
* them swaps in the parked query, seeding a first visit; ClickHouse and PromQL pass through.
*/
export function useQueryModeChange({
panelType,
supportedQueryModesKindMap,
parkedBuilders,
}: UseQueryModeChangeArgs): (key: string) => void {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
return useCallback(
(key: string): void => {
const targetMode = key as QueryMode;
const targetVariant = toBuilderVariant(targetMode);
const heldVariant = getBuilderVariant(currentQuery.builder);
const seedFor = (mode: QueryMode): Query['builder'] =>
seedBuilderForMode({
mode,
defaultSignal: getQueryModeSignals(
supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)[0],
panelType,
updateAllQueriesOperators,
});
// Keyed on what `builder` holds, not the active tab: ClickHouse → QB must not reseed.
let { builder } = currentQuery;
if (targetVariant && targetVariant !== heldVariant) {
parkedBuilders.current[heldVariant] = currentQuery.builder;
builder = parkedBuilders.current[targetVariant] ?? seedFor(targetMode);
delete parkedBuilders.current[targetVariant];
}
redirectWithQueryBuilderData({
...currentQuery,
// An AI query is a builder query carrying the AI tag; its query type stays `builder`.
queryType:
targetMode === QueryMode.AI_QUERY_BUILDER
? QueryMode.QUERY_BUILDER
: targetMode,
builder,
});
},
[
currentQuery,
panelType,
parkedBuilders,
redirectWithQueryBuilderData,
supportedQueryModesKindMap,
updateAllQueriesOperators,
],
);
}

View File

@@ -6,7 +6,7 @@ import type { NewPanelTarget } from '../patchOps';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { usePanelKindAndQueryModeSwitch } from './hooks/usePanelKindAndQueryModeSwitch';
export interface PanelEditorContainerProps {
dashboardId: string;
@@ -40,11 +40,12 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const panelDefinition = getPanelDefinition(panelKind);
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
const { onChangePanelKind, onChangeQueryMode } =
usePanelKindAndQueryModeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
if (panelDefinition.mode === 'static') {
return (
@@ -65,6 +66,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
onChangeQueryMode={onChangeQueryMode}
/>
);
}

View File

@@ -0,0 +1,114 @@
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
MetricAggregation,
SpaceAggregation,
} from 'types/api/v5/queryRange';
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
'p50',
'p75',
'p90',
'p95',
'p99',
]);
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
/**
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
* query and refuses a formula over the cells. The per-kind cache restores whatever is
* dropped here if the panel switches back.
*
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
* so a percentile draws the grid a count already draws, and the statement builder sums
* the `le` counts either way.
*/
export function withSingleHeatmapQuery(query: Query): Query {
const [first] = query.builder.queryData;
if (!first) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: [
{
...first,
spaceAggregation: withoutPercentile(first.spaceAggregation),
aggregations: first.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutPercentile(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
},
],
queryFormulas: [],
},
};
}
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
function withoutPercentile<T extends string | undefined>(
spaceAggregation: T,
): T {
return (
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/**
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
* so a count carried out of a heatmap would sit in the selector with no option behind
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
*/
export function withPercentileHistogramAggregation(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((queryData) => {
if (
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
) {
return queryData;
}
return {
...queryData,
spaceAggregation: withoutCount(queryData.spaceAggregation),
aggregations: queryData.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutCount(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
};
}),
},
};
}
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
return (
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}

View File

@@ -3,6 +3,7 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
import { getPanelDefinition, isPanelKindSupported } from '../registry';
@@ -11,12 +12,13 @@ import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryModes,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
resolveQueryType,
resolveQueryMode,
} from '../capabilities';
import type { PanelKind } from '../types/panelKind';
@@ -38,6 +40,48 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TextPanel': [],
};
const EXPECTED_QUERY_MODES: Record<PanelKind, QueryMode[]> = {
'signoz/TimeSeriesPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/BarChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/AreaChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/NumberPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/HistogramPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
QueryMode.AI_QUERY_BUILDER,
],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE, QueryMode.AI_QUERY_BUILDER],
'signoz/ListPanel': [QUERY_BUILDER, QueryMode.AI_QUERY_BUILDER],
'signoz/TextPanel': [],
};
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/TimeSeriesPanel': [metrics, logs, traces],
'signoz/BarChartPanel': [metrics, logs, traces],
@@ -223,6 +267,15 @@ describe('panel capabilities guard', () => {
expect(isSignalSupported('signoz/ListPanel', logs)).toBe(true);
expect(isSignalSupported('signoz/ListPanel', traces)).toBe(true);
});
it('reads a specific mode when one is given', () => {
expect(
getSupportedSignals('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
).toStrictEqual([traces]);
expect(
getSupportedSignals('signoz/TablePanel', QueryMode.CLICKHOUSE),
).toStrictEqual([]);
});
});
describe('isPanelCombinationValid', () => {
@@ -271,18 +324,36 @@ describe('panel capabilities guard', () => {
});
});
describe('resolveQueryType', () => {
it('keeps a supported query type', () => {
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(resolveQueryType('signoz/ListPanel', QUERY_BUILDER)).toBe(
QUERY_BUILDER,
describe('getSupportedQueryModes', () => {
it.each(Object.entries(EXPECTED_QUERY_MODES))(
'%s supports %j',
(kind, modes) => {
expect(getSupportedQueryModes(kind as PanelKind)).toStrictEqual(modes);
},
);
it('keeps AI out of the query types', () => {
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
QueryMode.AI_QUERY_BUILDER,
);
});
});
it('coerces an unsupported query type to the first supported one', () => {
describe('resolveQueryMode', () => {
it('keeps a supported mode', () => {
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(
resolveQueryMode('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
).toBe(QueryMode.AI_QUERY_BUILDER);
expect(
resolveQueryMode('signoz/ListPanel', QueryMode.AI_QUERY_BUILDER),
).toBe(QueryMode.AI_QUERY_BUILDER);
});
it('coerces an unsupported mode to the first supported one', () => {
// PromQL → List has no PromQL, falls back to its first (and only) type.
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryMode('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
});
});
});

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