Compare commits

..

14 Commits

Author SHA1 Message Date
Naman Verma
d1a382945c fix: read Bearer/bearer/BEARER properly in v2 for webhook notification channels (#12890)
<!--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

Webhook notification channels with a bearer token authorisation work in
v1 with all three spellings `Bearer/bearer/BEARER`, but v2 API was not
accepting anything other than `Bearer`. This PR changes the conversion
from receiver -> gettable flow.

Also, error messages are made better in 2 places.
2026-09-17 11:57:11 +00:00
Nikhil Soni
59af5e0367 refactor(telemetrystore): drop app-side bulk-filtering override (#12871)
#### Description

- Stop managing `secondary_indices_enable_bulk_filtering` from the app.
It was hardcoded to `false` in the query hook as a workaround for
[ClickHouse#82283](https://github.com/ClickHouse/ClickHouse/issues/82283)
(`CANNOT_READ_ALL_DATA` with SET-type skip indexes).
- That bug is fixed
([ClickHouse#87817](https://github.com/ClickHouse/ClickHouse/pull/87817),
backported to 25.7/25.8/25.9) and prod runs 25.12. Verified on a local
25.12.5 container against `signoz_index_v3` that the crash no longer
reproduces with bulk filtering enabled.
- Removes the hook override plus the now-unused config field and
`example.yaml` entry. The setting reverts to being controlled
server-side via ClickHouse profiles (the charts change tracked in the
same issue).

#### Issues closed by this PR

Closes SigNoz/engineering-pod#5915

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-17 09:40:26 +00:00
Pandey
8286e787b2 fix(tracefunnel): quote step names in slow and error trace queries (#12886)
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

- `#12593` moved the n-step trace-funnel query builders onto
`clickhousesql.StringLiteral`, but the two-step `slow-traces` and
`error-traces` builders still interpolated `service_name`/`span_name`
into the SQL string literal raw.
- Route those four values through the same helper, so every funnel query
builder quotes step names consistently.

#### Additional Information

- No behaviour change for ordinary names; the
`slow-traces`/`error-traces` funnel queries now handle names containing
a quote the same way the rest of the module already does.
2026-09-17 07:01:21 +00:00
Gaurav Tewari
e504c4081e feat/add support for fetch AI fieldKeys in order by and options (#12859)
<!--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 **Order by** and **Options** (column picker) to the AI
observability trace view.
- Both get their field list from one shared hook,
`useFieldKeysSuggestion`. The hook only fetches the keys. It does not
add or change anything.
- `FieldsSelector` and `ListViewOrderBy` now take one prop,
`useFieldApis`. It holds the API params and the static fields.
- Static fields are the ones the API never sends back, like `timestamp`
and `last_activity_time`. The component adds them to the list on its
own.

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

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

Stacked on #12846. Please review and merge that one first.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-17 05:10:20 +00:00
Nikhil Soni
0fe45e7114 feat(traces-qb): read the list-view attributes bag from the JSON column (#12749)
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

Stacked on #12715. Makes the list-view ("all fields", empty
`selectFields`) attributes bag read from the `attributes` JSON column,
evolution-aware, so the bag stays complete once we stop dual-writing the
legacy maps.

- The empty-`selectFields` list path was a static, map-only column list
(`attributes_string/number/bool`). It now picks attribute homes by
column evolution for the query window: legacy maps before the JSON
rollout, the `attributes` JSON column after it, both across it — via a
new `MetadataStore.GetColumnEvolutions` and
`tracestelemetryschema.BulkAttributeColumns`, mirroring the per-key
selection.
- `mergeSpanAttributeColumns` merges the natively-decoded JSON column
(relies on #12555's `JSONValue` scan — no `toJSONString`, no second
unmarshal) into the flat bag: nested paths flatten back to dotted keys
and win over the legacy maps on collision, so the response shape is
identical across the rollout with a single decode and no re-marshal.
- Per-key group-by/aggregation is unchanged — it already resolves
JSON-only after the rollout — so those queries keep working once map
dual-write stops.

Follow-up (out of scope, tracked by #12555's known gaps): the
waterfall/flamegraph struct readers need the same `JSONValue` type
before span attributes can go JSON-only; `resources_string` moves to the
`resource` JSON column later.

#### Additional Information

Tests: bulk select across before/after/straddle windows; group-by after
rollout reads JSON not the map; consume flatten/merge (nested
flattening, type preservation, JSON-wins-on-collision).

Assisted-by: Claude Opus 4.8
2026-09-16 13:31:23 +00:00
Gaurav Tewari
35356fc602 refactor(qb): fetch field keys and values through the generated clients (#12846)
<!--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

- Query search now reads key/value suggestions from the generated
`/fields/keys` and `/fields/values` clients, behind two thin fetchers in
`api/querySuggestions` that pick the AI-observability variant for
`builder_ai_query`.
- Drops `QuerySearch/fieldSuggestions.ts` and its test, which
hand-rolled that same routing on top of the old clients.
- The fetchers return the response as the API shapes it — no
normalisation layer in between.

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/2315741f-7a2d-4d46-84ba-dfd2958f3b44



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



Scoped to the query builder. The other callers of the legacy suggestion
hooks — options menu, quick filters, fields selector — are untouched and
move over separately. also we should mention this in
SigNoz/engineering-pod#5289.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-16 13:09:54 +00:00
Swapnil Nakade
e1acfc94ba feat: adding dashboard delete unsafe v2 method (#12876)
<!--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
- Adding Dashboard's DeleteUnsafeV2 method
- Using this method in cloud integration dashboard clean up as well

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


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
https://signoz-team.slack.com/archives/C08UBUBV68Z/p1789459747045369
2026-09-16 10:20:22 +00:00
Pandey
57268e50b4 fix: mark v2 notification channel secrets as password format (#12873)
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

- Tag the secret fields of the v2 notification channel specs with
`format:"password"` so the OpenAPI schema hints UIs to obscure them.
- Credentials: webhook `password`/`bearerToken`, PagerDuty `routingKey`,
Opsgenie/JSM Ops `apiKey`, Jira `apiToken`, incident.io `token`.
- Token-bearing URLs the code already models as `SecretURL`: Slack
`apiUrl`, webhook `url`, MS Teams/Google Chat `webhookUrl`.
- Regenerate `docs/api/openapi.yml` and the frontend client (separate
commits).
2026-09-15 17:32:42 +00:00
Nityananda Gohain
4933a2740c feat(authz): integrate authz in quick filters (#12791)
#### Description
- Registers `quick-filter` as a metaresource (list / read / update) and
adds the Quick Filters panel in roles settings.
- Quick filters sidebar on logs, traces, exceptions, api monitoring and
meter is gated on `quick-filter:read`.. denied shows the inline callout,
header stays.
- Settings gear is gated on read + update.. shows disabled with the
denial tooltip instead of being hidden. The old `user.role === ADMIN`
check is gone, roles decide now.
- Static config pages (infra) are not gated since they dont use the qf
apis.
- Removed the old "Edit your quick filters" announcement from the gear.

#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/5946
Closes https://github.com/SigNoz/engineering-pod/issues/6038

#### Screenshot/ recordings
Showing up in permissions per role
<img width="3346" height="1282" alt="image"
src="https://github.com/user-attachments/assets/0a1409bd-1b16-46c2-8186-a77be7dd3083"
/>



https://github.com/user-attachments/assets/38f1f718-89ef-4f49-b1ac-4385ca431d04

---------

Co-authored-by: aks07 <adityasinghssj1@gmail.com>
2026-09-15 16:49:18 +00:00
Vinicius Lourenço
2481e3d5f4 chore(audit): bump packages to address audit reports (#12847)
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

Most of the changes were safe except the one for orval.

One of the fixes introduced in orval, by
https://github.com/orval-labs/orval/pull/3605, basically address a
potential issue for react query v5 and react compiler, so that's why we
have this new helper introduced.

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

Closes https://github.com/SigNoz/engineering-pod/issues/6058
2026-09-15 14:27:17 +00:00
Manika Malhotra
099e81202d chore(onboarding): update onboarding hint text (#12868)
<!--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

Changes onboarding hint text

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

Closes https://github.com/SigNoz/growth-pod/issues/1382 

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before:

<img width="671" height="745" alt="Screenshot 2026-09-15 at 17 01 43"
src="https://github.com/user-attachments/assets/29f1759f-42d0-4382-816b-a6c53f94808c"
/>


After:
<img width="691" height="751" alt="Screenshot 2026-09-15 at 17 00 33"
src="https://github.com/user-attachments/assets/d07ca372-b62d-497d-9c9e-24252c96ddcf"
/>


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

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-15 12:15:34 +00:00
Srikanth Chekuri
9c886be120 chore(querybuilder): compile every signal through one storage contract (#12802)
The semantic convention family as first call citizen revealed that the
current state of the query builder needs a bit refactoring for long term
maintenance.

The `FieldMapper` and `ConditionBuilder` are now one abstraction
`Storage`.

A storage now answers
- what the compiler cannot know i.e one read per field key (the bare
SQL, the membership present or absent, what an absent row reads, and
whether the read keeps its type or filters only).
- the fallback for a key metadata does not report
- its traits
- and one Condition compilation part.

And we introduce a new type to use in the system, `Resolved`

```
// Resolved is what resolution produces for one key: its meanings, and how
// they came to be. It is the only thing the compilers receive. Compile it
// with the operator and value it was resolved with.
type Resolved struct {
	Key    *telemetrytypes.TelemetryFieldKey
	Fields []*telemetrytypes.LogicalField
	// FromFallback: the fields came from the storage's fallback, not from
	// metadata matches.
	FromFallback bool
	// Ambiguous: the matches held several interpretations.
	Ambiguous bool
	// Skipped: the storage contributes nothing for this key.
	Skipped  bool
	Warnings []string
}
```

The prepared SQL has no changes, where it changed, it specifically made
the expression better by removing the redundant part.

- The prepared SQL remains identical with this refactoring
- No changes to integration tests


Assisted-by: Claude Fable 5.1
2026-09-15 11:36:06 +00:00
Abhi kumar
861e5f75cf fix(dashboards): restore all series when the isolated legend row is clicked (#12864)
#### Description

- Clicking the legend row of the only series still showing now restores
every series, so the isolating click undoes itself — hiding that last
series is refused, which left no way back to all from the row itself.
- With more than one series showing, the row click is unchanged: isolate
when everything is showing, hide/show otherwise.
- Applies to the pie legend and to Enter/Space too — same row component,
same handler.

#### Additional Information

The legend test file moved to `components/Legend/__tests__/`, next to
the component it covers.
2026-09-15 10:07:15 +00:00
Abhi kumar
755cca13cf feat(dashboards): revamp panel chart legend interaction (#12838)
#### Description

- The legend's two undiscoverable click targets are replaced by one
explicit set. A row click depends on what's on screen — isolate when
everything is showing, move the isolation while one series is showing,
hide/show that series otherwise — and the marker stays a separate target
so a single series can still be excluded in one click.
- Hover-revealed row actions: `+` to show a hidden series alongside the
current ones, a crosshair to isolate (or restore all when it's already
alone), and copy for the full untruncated series name. Their reveal is
pure CSS.
- Hovering a row lifts that series on the chart and dims the rest; the
dim is dropped as soon as the highlighted series is hidden, which
previously left every remaining series faded and read as an isolation.
- At least one series always stays visible, so a panel can no longer be
emptied.
- The bottom legend reserves exactly the one or two rows it shows — the
old estimate was built from a 40px row while rows are 28px with a 2px
gap, so two rows never quite fit. The right-side legend keeps its search
box and gains a `Showing N of M series` readout.
- Tooltip series rows now use the same marker and mono type as the
legend.

<img width="1961" height="637" alt="image"
src="https://github.com/user-attachments/assets/07c78273-dcd7-4c60-b3fc-7014a76aa693"
/>
<img width="425" height="154" alt="image"
src="https://github.com/user-attachments/assets/2ffba264-0067-4de2-b8b7-cf8d09c305be"
/>


https://github.com/user-attachments/assets/d5514736-2980-49b9-b62a-0dca8156c960



#### Additional Information

- All of this lives in the shared `lib/uPlotV2` legend, so it lands on
V1 dashboard panels, V2 panels, the alert chart preview, infra
monitoring and Pie at once. The logs/traces explorer still uses the
older `lib/uPlotLib` legend and is untouched.
- `Legend` is now presentational only; visibility lives with each chart
type — `PlotContext` for uPlot, `usePieInteractions` for the donut —
which is what lets both share one row component.
- `LegendRow` is memoised: the chart's cursor focus updates the focused
series on every pointer move over the plot, which re-runs the list's row
renderer.
- Legend hover no longer sets React state (it was re-rendering the whole
legend per row), and the legend dropped a `ResizeObserver` that only
existed to centre a single row.

Closes https://github.com/SigNoz/pulse-pod/issues/340
2026-09-15 07:12:09 +00:00
226 changed files with 9013 additions and 6498 deletions

View File

@@ -103,6 +103,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -202,7 +202,6 @@ telemetrystore:
max_bytes_to_read: 0
max_result_rows: 0
ignore_data_skipping_indices: ""
secondary_indices_enable_bulk_filtering: false
##################### Prometheus #####################
prometheus:

View File

@@ -197,6 +197,7 @@ components:
title:
type: string
webhookUrl:
format: password
type: string
required:
- webhookUrl
@@ -215,6 +216,7 @@ components:
title:
type: string
token:
format: password
type: string
url:
type: string
@@ -225,6 +227,7 @@ components:
AlertmanagertypesChannelJSMOpsConfig:
properties:
apiKey:
format: password
type: string
description:
type: string
@@ -243,6 +246,7 @@ components:
AlertmanagertypesChannelJiraConfig:
properties:
apiToken:
format: password
type: string
customFields:
additionalProperties: {}
@@ -317,6 +321,7 @@ components:
title:
type: string
webhookUrl:
format: password
type: string
required:
- webhookUrl
@@ -324,6 +329,7 @@ components:
AlertmanagertypesChannelOpsgenieConfig:
properties:
apiKey:
format: password
type: string
apiUrl:
type: string
@@ -364,6 +370,7 @@ components:
group:
type: string
routingKey:
format: password
type: string
sendResolved:
nullable: true
@@ -380,6 +387,7 @@ components:
AlertmanagertypesChannelSlackConfig:
properties:
apiUrl:
format: password
type: string
channel:
type: string
@@ -396,13 +404,16 @@ components:
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
format: password
type: string
password:
format: password
type: string
sendResolved:
nullable: true
type: boolean
url:
format: password
type: string
username:
type: string
@@ -3199,53 +3210,6 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
fillOpacity:
$ref: '#/components/schemas/DashboardtypesFillOpacity'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesAreaChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
type: object
DashboardtypesAreaChartVisualization:
properties:
fillSpans:
type: boolean
stack:
$ref: '#/components/schemas/DashboardtypesStackMode'
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesAreaFillMode:
enum:
- solid
- gradient
type: string
DashboardtypesAxes:
properties:
isLogScale:
@@ -3477,11 +3441,6 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -3944,7 +3903,6 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -3957,7 +3915,6 @@ components:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
@@ -3969,7 +3926,6 @@ components:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
@@ -3977,18 +3933,6 @@ components:
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
kind:
enum:
- signoz/AreaChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
@@ -4319,12 +4263,6 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object

View File

@@ -54,50 +54,93 @@ The `fieldContexts` map includes aliases (`tag` -> `attribute`, `spanfield` -> `
## The Abstraction Stack
The query pipeline is built from four interfaces that compose vertically. Each layer has a single responsibility. Each layer depends only on the layers below it. This layering is intentional and must be preserved.
The query pipeline has three layers. The generic layer is written one time, in `pkg/querybuilder`. A storage is written one time per signal. The statement builders compose them. Each layer depends only on the layer below it. This layering is intentional and must be preserved.
```
StatementBuilder <- Orchestrates everything into executable SQL
├── AggExprRewriter <- Rewrites aggregation expressions (maps field refs to columns)
├── ConditionBuilder <- Builds WHERE predicates (field + operator + value -> SQL)
└── FieldMapper <- Maps TelemetryFieldKey -> ClickHouse column expression
StatementBuilder <- Composes one query into executable SQL
├── AggExprRewriter <- Rewrites aggregation expressions through the generic layer
├── filter visitor <- Parses the filter expression and compiles it one term at a time
└── querybuilder (generic) <- Resolution, the filter condition, the column expression
└── Storage <- What one signal's tables can answer about one field key
```
### FieldMapper
### Storage
**Contract:** Given a `TelemetryFieldKey`, return a ClickHouse column expression that yields the value for that field when used in a SELECT.
**Contract:** `qbtypes.Storage` in `pkg/types/querybuildertypes/querybuildertypesv5/qb.go`. One implementation per signal: traces, logs, metrics, audit, rule state history, the resource fingerprint sub-query, and the related-values metadata.
**Principle:** This is the *only* place where field-to-column translation happens. No other layer should contain knowledge of how fields map to storage. If you need a column expression, go through the FieldMapper.
A storage answers four questions and nothing else:
**Why:** The user says `http.request.method`. ClickHouse might store it as `attributes_string['http.request.method']`, or as a materialized column `` `attribute_string_http$$request$$method` ``, or via a JSON access path in a body column. This variation is entirely contained within the FieldMapper. Everything above it is storage-agnostic.
- `Read(key)`: one call for one field key. It returns the bare SQL read, with no alias, no guard, and no cast. It returns the membership test (`Presence`), which binds no args so it can sit inside guards, and its negation in the storage's own form (`Absence`). It returns what a row without the key reads (`Absent`, below). It tells whether the group by, order by, and aggregation cast must leave the native type alone (`KeepType`: time columns, metrics labels). It tells whether the key filters but cannot be selected (`FilterOnly`: the legacy string body). It honors the materialization and the evolutions the key carries.
- `Fallback(key, operator, value)`: the field keys that could hold a key metadata does not report: column aliases, the type variants of a map read, body paths, and virtual keys that compile to structural predicates (a span search scope, a full-text search over a scope).
- `Traits()`: the storage's part in the resource fingerprint split, whether it supports body functions, what it does with an unknown key, and which contexts mean "this signal's own record".
- `Compile`: the one override, for a storage with its own condition language. Logs have the body JSON language. The resource fingerprint has index hints. The related values have a polarity form. Metrics have String-typed labels. Every other storage returns `querybuilder.SharedCondition`.
### ConditionBuilder
**Principle:** A storage describes its field keys. It never decides a guard, an ambiguity, a warning, or the shape of a fold. Those decisions are derived one time, in the generic layer, from those descriptions.
**Contract:** Given a field key, an operator, and a value, produce a valid SQL predicate for a WHERE clause.
### Absent
**Dependency:** Uses FieldMapper for the left-hand side of the condition.
`Read` returns the field's `Absent`: what a row without the field reads. It is a property of the read, not of the column. `resource.x::String` reads the empty string for an absent row. The multi-era fold `multiIf(..., NULL)` reads NULL. A table column always reads a real value. Every guard derives from it:
**Principle:** The ConditionBuilder owns all the complexity of operator semantics, i.e type casting, array operators (`hasAny`/`hasAll` vs `=`), existence checks, and negative operator behavior. This complexity must not leak upward into the StatementBuilder.
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|---|---|---|---|---|---|
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
### AggExprRewriter
### The generic layer
**Contract:** Given a user-facing aggregation expression like `sum(duration_nano)`, resolve field references within it and produce valid ClickHouse SQL.
`pkg/querybuilder` needs two inputs, made one time per request:
**Dependency:** Uses FieldMapper to resolve field names within expressions.
- The metadata keys: `keys := metadataStore.GetKeysMulti(...)`, the field keys the metadata store reports for the query's names, as `map[name][]*TelemetryFieldKey`.
- `q := querybuilder.NewQueryInfo(ctx, orgID, fl, signal, metric, startNs, endNs)`: the time range every read needs, the signal and the queried metric that family admission needs, and the query-path flags (`FamiliesOn`, `BodyJSONOn`). The flags are evaluated one time.
**Principle:** Aggregation expressions are user-authored strings that contain field references. The rewriter parses them, identifies field references, resolves each through the FieldMapper, and reassembles the expression.
The functions, from the outside in:
### StatementBuilder
| Function | Does |
|---|---|
| `PrepareWhereClause(query, opts)` | The filter visitor. Parses the filter grammar and compiles each term through `RejectsBodyFunction`, `Resolve`, and `Condition`. Returns the WHERE clause, the warnings, and the cost-guard flag. |
| `NewAggExprRewriter(settings, fullTextColumn, storage, fl, signal)` | Parses an aggregation expression such as `sum(duration_nano)` and resolves each field reference through `ResolveColumn`. |
| `ResolveColumn(ctx, q, storage, key, target, metadata)` | `Resolve` with `FilterOperatorUnknown`, then `Column`. Every select field, order by, group by, and aggregation argument calls it. |
| `Resolve(ctx, q, storage, key, operator, value, metadata)` | One requested key to its meanings in this storage (see "A resolved key"). |
| `Condition(ctx, q, storage, resolved, dropResourceFields, operator, value, sb)` | A resolved key to the conditions of one filter term: the split narrows the fields, and each field compiles through `storage.Compile`. |
| `Conditions(...)` | `RejectsBodyFunction`, `Resolve`, and `Condition` in one call, for callers outside the visitor: the related-values metadata, the scoped traces predicate resolver, tests. |
| `Column(ctx, q, storage, resolved, target)` | A resolved key to one bare column expression. The caller aliases. |
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
**Contract:** Given a complete `QueryBuilderQuery`, a time range, and a request type, produces an executable SQL statement.
### A resolved key
**Dependency:** Uses all three abstractions above.
`Resolve` turns one requested key into a `Resolved` value. It is the only thing the filter condition (`Condition`) and the column expression (`Column`) receive. Compile it with the operator and value it was resolved with. The operand tells the use: a nil value means a select field, group by, order by, aggregation, or presence test.
**Principle:** This is the composition layer. It does not contain field mapping logic, condition building logic, or expression rewriting logic. It orchestrates the other abstractions. If you find storage-specific logic creeping into the StatementBuilder, push it down into the appropriate abstraction.
```go
type Resolved struct {
Key *TelemetryFieldKey // the spelling the request used
Fields []*LogicalField // its meanings in this storage, one per interpretation
FromFallback bool // the fields came from the storage's Fallback, not from metadata matches
Ambiguous bool // the matches held several interpretations
Skipped bool // the storage contributes nothing for this key
Warnings []string // the warnings to surface: ambiguity, not-found
}
```
A `LogicalField` is one meaning: one name, context, and data type, backed by one or more physical members. A family (one field with several spellings) is one logical field with several members, current spelling first. Ambiguity (one name, different fields) is several logical fields.
The resolution order is the same for every storage, in the filter and in every select field, group by, order by, and aggregation:
1. **Own context.** A key under one of the storage's own contexts (`span.x`, `log.x`) matches its own context first, then a column the storage knows under that context. Only when both miss does it look up as if it had no context. `span.http.method` then corrects to the attribute. Strict contexts (`resource.`, `attribute.`, `scope.`, `body.`) are kept as written.
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation.
3. **Ambiguity.** A filter settles several interpretations by resource over attribute, with a warning. A select field, group by, order by, or aggregation keeps every interpretation in metadata order and folds them, so it shows the value wherever it is.
4. **Intrinsic column first**, bare keys only. A column every row has leads the list. Metadata can report the column, or the storage's `Fallback` can. When only `Fallback` knows the column, its key carries the data type the column reads as (`querybuilder.ColumnDataType`). A same-named metadata key of a contradicting type then drops, and a time column merges none. When metadata reports the column too, every match reads. A metadata gap degrades to the correct column, never to a corrupt metadata key.
5. **Fallback.** With no match, the storage's fallback keys for the key. When the storage ignores unknown keys (a side query whose main query owns the error), the key is `Skipped`. Otherwise a key nothing can serve is an error with suggestions. The not-found warning fires only when every fallback key is a guess, that is, none of them is always present.
`Condition` then applies the fingerprint split. `MainOfSplit` drops the resource fields the sub-query serves and keeps fallback keys. `FingerprintOfSplit` keeps resource fields only. `Condition` compiles each field, and the visitor joins the per-field conditions by the operator's polarity. `Column` reads each field through `LogicalRead`. Group by, order by, and aggregation cast the read unless it keeps its type. `Column` then guards by `Absent`, and renders one candidate bare or several as `multiIf(..., NULL)`. A filter-only candidate drops. The error surfaces only when no candidate remains.
### Invariant: No layer skipping
The StatementBuilder must not call FieldMapper directly to build conditions, it goes through the ConditionBuilder. The AggExprRewriter must not hardcode column names, it goes through the FieldMapper. Skipping layers creates hidden coupling and makes the system fragile to storage changes.
A statement builder must not spell a column or a condition. It calls `ResolveColumn` and the filter visitor. A storage must not decide a guard or an ambiguity. It declares `Absent` and answers the four questions. Skipping layers recreates the per-signal copies the contract removed.
---
@@ -119,14 +162,15 @@ Only additive/counting aggregations (`count`, `count_distinct`, `sum`, `rate`) d
**Enforcement:** `GetQueriesSupportingZeroDefault` determines which queries can default to zero. The `FormulaEvaluator` consumes this via `canDefaultZero`. Changes to aggregation handling must preserve this distinction.
### Constraint: Existence semantics differ for positive vs negative operators
### Constraint: The exists guard derives from the operator and from the field
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence. `http.method = GET` means "the field exists AND equals GET".
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) do **not** add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence for a field that reads a sentinel when absent. `http.method = GET` on a map attribute means "the field exists AND equals GET".
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) never add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
- A field that reads NULL when absent, and a table column, take no guard on any operator: the comparison already excludes the absent row, or there is no absent row.
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. This is documented in `AddDefaultExistsFilter`.
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. The operator side is declared in `AddDefaultExistsFilter`. The field side is the `Absent` a storage returns from `Read`.
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Do not add operators without considering this.
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Any new read must declare what an absent row reads. Never add a guard by hand in a storage.
### Constraint: Post-processing functions operate on result sets, not in SQL
@@ -188,11 +232,11 @@ The `MetadataStore` interface provides runtime field discovery and type resoluti
The same name can map to multiple `TelemetryFieldKey` variants (different contexts, different types). The metadata store returns *all* variants. Resolution to a single field happens during query building, using the query's signal and any explicit context/type hints from the user.
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field.
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field. `querybuilder.Resolve` is where the variants settle: it returns every interpretation as a `LogicalField`, marks the result `Ambiguous`, and carries the warning.
### Principle: Materialized fields are a performance optimization, not a semantic distinction
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the FieldMapper to generate a simpler column expression. The user should never need to know whether a field is materialized.
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the storage's `Read` to generate a simpler column expression. The user should never need to know whether a field is materialized.
### Principle: JSON body fields require access plans
@@ -203,14 +247,14 @@ Fields inside JSON body columns (`body.response.errors[].code`) need pre-compute
## Summary of Inviolable Rules
1. **User-facing types never contain ClickHouse column names or SQL fragments.**
2. **Field-to-column translation only happens in FieldMapper.**
2. **Field-to-column translation only happens in a Storage (`Read`, `Fallback`).**
3. **Normalization happens once at the API boundary, never deeper.**
4. **Historical aliases in fieldContexts and fieldDataTypes must not be removed.**
5. **Formula evaluation stays in Go — do not push it into ClickHouse JOINs.**
6. **Zero-defaulting is aggregation-type-dependent — do not universally default to zero.**
7. **Positive operators imply existence, negative operators do not.**
7. **The exists guard derives from `AddDefaultExistsFilter` and `Absent`. Positive operators guard sentinel reads. Negative operators never guard.**
8. **Post-processing functions operate on Go result sets, not in SQL.**
9. **All user-facing types reject unknown JSON fields with suggestions.**
10. **Validation rules are gated by request type.**
11. **Query names must be unique within a composite query.**
12. **The four-layer abstraction stack (FieldMapper -> ConditionBuilder -> AggExprRewriter -> StatementBuilder) must not be bypassed or flattened.**
12. **The three-layer abstraction stack (Storage -> querybuilder generic layer -> StatementBuilder) must not be bypassed or flattened. A storage describes its field keys. The generic layer decides.**

View File

@@ -583,7 +583,7 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
return err
}
if err := module.dashboardModule.DeleteUnsafe(ctx, orgID, dashID); err != nil {
if err := module.dashboardModule.DeleteUnsafeV2(ctx, orgID, dashID); err != nil {
return err
}
}

View File

@@ -297,6 +297,15 @@ func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer
})
}
func (module *module) DeleteUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.pkgDashboardModule.DeleteUnsafeV2(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}

View File

@@ -28,8 +28,10 @@ export default defineConfig({
clean: true,
override: {
query: {
useQuery: true,
useMutation: true,
// Leave useQuery/useMutation unset. Orval's verb tiebreak
// (`if (verb === GET && isMutation) isQuery = false`) inverts every
// operation when both are forced to true: GET becomes a mutation and
// the write verbs become queries.
useInvalidate: true,
signal: true,
useOperationIdAsQueryKey: false,

View File

@@ -78,7 +78,7 @@
"crypto-js": "4.2.0",
"d3-hierarchy": "3.1.2",
"dayjs": "^1.10.7",
"dompurify": "3.4.12",
"dompurify": "3.4.15",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
@@ -118,7 +118,7 @@
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.30.3",
"react-router-dom-v5-compat": "6.30.6",
"react-syntax-highlighter": "15.5.0",
"react-use": "^17.3.2",
"react-virtuoso": "4.0.3",
@@ -200,7 +200,7 @@
"json-schema-to-typescript": "^15.0.4",
"lint-staged": "^17.0.4",
"msw": "1.3.2",
"orval": "8.9.1",
"orval": "8.22.0",
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"oxlint-tsgolint": "0.23.0",
@@ -209,10 +209,10 @@
"react-resizable": "3.0.4",
"redux-mock-store": "1.5.4",
"sass": "1.97.3",
"sharp": "0.35.0",
"sharp": "0.35.4",
"storybook": "10.5.9",
"stylelint": "17.7.0",
"svgo": "4.0.2",
"stylelint": "17.15.0",
"svgo": "4.1.0",
"ts-jest": "29.4.9",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
@@ -232,26 +232,5 @@
"*.(scss|css)": [
"stylelint"
]
},
"overrides": {
"@babel/core@<=7.29.0": ">=7.29.6 <8",
"@istanbuljs/load-nyc-config>js-yaml": ">=4.3.1 <5",
"cookie@<0.7.0": ">=0.7.1 <1",
"dompurify@<=3.4.10": ">=3.4.11 <4",
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
"js-cookie@<=3.0.5": ">=3.0.7 <4",
"js-yaml@>=4.0.0 <=4.1.1": ">=4.2.0 <5",
"prismjs@<1.30.0": ">=1.30.0 <2",
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2",
"brace-expansion@<1.1.18": ">=1.1.18 <2",
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3",
"brace-expansion@>=5.0.0 <5.0.9": ">=5.0.9 <6",
"fast-uri@<3.1.5": ">=3.1.5 <4",
"immutable@<5.1.8": ">=5.1.8 <6",
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
"less@<4.5.0": ">=4.5.0 <5",
"nanoid@<3.3.18": ">=3.3.18 <4"
}
}

1047
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,11 @@ overrides:
'@babel/core@<=7.29.0': '>=7.29.6 <8'
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.1 <5'
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.2 <5'
# via: msw@1.3.2 (devDep) > @mswjs/interceptors@0.17.10 (@xmldom/xmldom ^0.8.3)
# remove: upgrade msw to >=2 (drops the 0.8.x interceptor). Do NOT open the cap:
# 0.9.x is ESM-only and changes the DOMParser error contract
'@xmldom/xmldom@<0.8.15': '>=0.8.15 <0.9'
# via: babel-plugin-istanbul > test-exclude@6 > minimatch@3.1.5 (^1.1.7); also glob@7
# remove: blocked — babel-plugin-istanbul pins test-exclude@6, which pins minimatch@3
brace-expansion@<1.1.18: '>=1.1.18 <2'
@@ -23,32 +27,39 @@ overrides:
# via: eslint-plugin-sonarjs@4.0.2 (minimatch ^10.2.4) > minimatch@10.2.5 (^5.0.5)
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'
# via: direct devDep @babel/core > @babel/helper-compilation-targets (browserslist ^4.24.0)
# remove: blocked, the range is open so the floor is what pulls the fix.
# Also lifts baseline-browser-mapping, which browserslist@4.28.7 floors at ^2.11.20
browserslist@<4.28.7: '>=4.28.7 <5'
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
cookie@<0.7.0: '>=0.7.1 <1'
# via: direct dep dompurify 3.4.0; @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
# via: @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
# remove: bump direct dep to 3.4.11; @grafana/data (latest 13.1.0) and
# monaco-editor (latest 0.55.1) still pin vulnerable versions — blocked
dompurify@<=3.4.10: '>=3.4.11 <4'
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); orval@8.9.1 (^0.27.4); ts-jest@29.4.9 (~0.27.4)
# remove: blocked. The direct dep is already on 3.4.15, but @grafana/data
# (latest 13.1.0) and monaco-editor (latest 0.55.1) still pin vulnerable versions
dompurify@<=3.4.12: '>=3.4.13 <4'
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); ts-jest@29.4.9 (~0.27.4)
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
# via: @commitlint/cli > @commitlint/config-validator > ajv@8 (fast-uri ^3.0.1)
# remove: blocked — ajv@8 caps fast-uri at ^3, and only 3.1.5 carries the fix
fast-uri@<3.1.5: '>=3.1.5 <4'
# remove: blocked — ajv@8 caps fast-uri at ^3, and only 3.1.6 carries the fix
fast-uri@<3.1.6: '>=3.1.6 <4'
# via: direct dep posthog-js@1.298.0 (fflate ^0.4.8)
# remove: blocked, posthog-js 1.430.2 (latest) is still on ^0.4.8
fflate@<0.4.9: '>=0.4.9 <0.5'
# via: direct devDep sass@1.97.3 (immutable ^5.0.2)
# remove: blocked — plain sass bumps stay within ^5, so the floor is what pulls 5.1.8
immutable@<5.1.8: '>=5.1.8 <6'
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
js-cookie@<=3.0.5: '>=3.0.7 <4'
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
'js-yaml@>=4.0.0 <4.3.1': '>=4.3.1 <5'
# via: json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0);
# @commitlint/cli > @commitlint/load > cosmiconfig@9 (^4.1.0)
# remove: blocked, both consumers cap js-yaml at ^4 and only 4.3.2 carries the fix
'js-yaml@>=4.0.0 <4.3.2': '>=4.3.2 <5'
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
@@ -59,10 +70,15 @@ overrides:
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'
# via: direct dep react-router-dom-v5-compat@6.30.3 (react-router 6.30.3 exact)
# remove: bump react-router-dom-v5-compat to 6.30.4. Do NOT open the cap:
# react-router >=7 requires React 19 and breaks the app-wide CompatRouter
react-router@>=6.7.0 <6.30.4: '>=6.30.4 <7'
# via: typescript-plugin-css-modules@5.2.0 > postcss-modules-local-by-default@4.2.0
# (postcss-selector-parser ^7.0.0)
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
# via: @signozhq/ui > nuqs@2 (react-router ^6.4.0 || ^7)
# remove: blocked. GHSA-wrjc-x8rr-h8h6 and the deserializeErrors advisory are patched
# only in 7.18.0. Do NOT open the cap: react-router >=7 requires React 19 and breaks
# the app-wide CompatRouter, so those two moderates stay until the app moves to React 19
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'

View File

@@ -14,15 +14,6 @@ done
echo "\n✅ Tag files renamed to index.ts"
# Format generated files
echo "\n\n---\nRunning prettier...\n"
if ! pnpm prettify src/api/generated; then
echo "Formatting failed!"
exit 1
fi
echo "\n✅ Formatting successful"
# Fix linting issues
echo "\n\n---\nRunning lint...\n"
if ! pnpm lint:generated; then
@@ -32,6 +23,15 @@ fi
echo "\n✅ Lint check successful"
# Format generated files (must run after lint: oxlint --fix writes unformatted autofixes)
echo "\n\n---\nRunning prettier...\n"
if ! pnpm prettify src/api/generated; then
echo "Formatting failed!"
exit 1
fi
echo "\n✅ Formatting successful"
# Check for type errors
echo "\n\n---\nChecking for type errors...\n"
if ! tsc --noEmit; then

View File

@@ -25,6 +25,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns the field keys the AI observability explorer can filter on, including the computed per-trace aggregates
* @summary Get AI observability field keys
@@ -111,7 +131,7 @@ export function useGetAIObservabilityFieldsKeys<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -216,7 +236,7 @@ export function useGetAIObservabilityFieldsValues<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -19,6 +19,26 @@ import type { GetAlerts200, RenderErrorResponseDTO } from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns alerts for the organization
* @summary Get alerts
@@ -77,7 +97,7 @@ export function useGetAlerts<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -32,6 +32,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists all auth domains
* @summary List all auth domains
@@ -98,7 +118,7 @@ export function useListAuthDomains<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -323,7 +343,7 @@ export const getGetAuthDomainQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
@@ -360,7 +380,7 @@ export function useGetAuthDomain<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -43,6 +43,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists all notification channels for the organization
* @summary List notification channels
@@ -109,7 +129,7 @@ export function useListChannels<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -334,7 +354,7 @@ export const getGetChannelByIDQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getChannelByID>>,
@@ -371,7 +391,7 @@ export function useGetChannelByID<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -738,7 +758,7 @@ export function useListNotificationChannels<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -967,7 +987,7 @@ export const getGetNotificationChannelQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
@@ -1005,7 +1025,7 @@ export function useGetNotificationChannel<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -51,6 +51,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* [Deprecated] This endpoint is called by the deployed agent to check in
* @deprecated
@@ -199,7 +219,7 @@ export const getListAccountsQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
enabled: cloudProvider !== null && cloudProvider !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listAccounts>>,
@@ -236,7 +256,7 @@ export function useListAccounts<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -480,7 +500,11 @@ export const getGetAccountQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && id),
enabled:
cloudProvider !== null &&
cloudProvider !== undefined &&
id !== null &&
id !== undefined,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getAccount>>, TError, TData> & {
queryKey: QueryKey;
@@ -515,7 +539,7 @@ export function useGetAccount<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -683,7 +707,11 @@ export const getListAccountServicesMetadataQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && id),
enabled:
cloudProvider !== null &&
cloudProvider !== undefined &&
id !== null &&
id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
@@ -724,7 +752,7 @@ export function useListAccountServicesMetadata<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -795,7 +823,13 @@ export const getGetAccountServiceQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && id && serviceId),
enabled:
cloudProvider !== null &&
cloudProvider !== undefined &&
id !== null &&
id !== undefined &&
serviceId !== null &&
serviceId !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAccountService>>,
@@ -835,7 +869,7 @@ export function useGetAccountService<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1099,7 +1133,7 @@ export const getGetConnectionCredentialsQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
enabled: cloudProvider !== null && cloudProvider !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getConnectionCredentials>>,
@@ -1140,7 +1174,7 @@ export function useGetConnectionCredentials<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1205,7 +1239,7 @@ export const getListServicesMetadataQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
enabled: cloudProvider !== null && cloudProvider !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1245,7 +1279,7 @@ export function useListServicesMetadata<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1313,7 +1347,11 @@ export const getGetServiceQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && serviceId),
enabled:
cloudProvider !== null &&
cloudProvider !== undefined &&
serviceId !== null &&
serviceId !== undefined,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getService>>, TError, TData> & {
queryKey: QueryKey;
@@ -1351,7 +1389,7 @@ export function useGetService<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -72,6 +72,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint deletes the public sharing config and disables the public sharing of a dashboard
* @summary Delete public dashboard
@@ -198,7 +218,7 @@ export const getGetPublicDashboardQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboard>>,
@@ -235,7 +255,7 @@ export function useGetPublicDashboard<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -500,7 +520,7 @@ export const getGetPublicDashboardDataQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardData>>,
@@ -538,7 +558,7 @@ export function useGetPublicDashboardData<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -605,7 +625,7 @@ export const getGetPublicDashboardWidgetQueryRangeQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!(id && idx),
enabled: id !== null && id !== undefined && idx !== null && idx !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardWidgetQueryRange>>,
@@ -646,7 +666,7 @@ export function useGetPublicDashboardWidgetQueryRange<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -731,7 +751,7 @@ export function useListDashboardViews<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1091,7 +1111,7 @@ export function useListDashboardsV2<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1317,7 +1337,7 @@ export const getGetDashboardV2QueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
@@ -1354,7 +1374,7 @@ export function useGetDashboardV2<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1933,7 +1953,7 @@ export const getGetSystemDashboardQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!name,
enabled: name !== null && name !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
@@ -1970,7 +1990,7 @@ export function useGetSystemDashboard<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -2035,7 +2055,7 @@ export const getGetPublicDashboardDataV2QueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
@@ -2073,7 +2093,7 @@ export function useGetPublicDashboardDataV2<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -2147,7 +2167,7 @@ export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
return {
queryKey,
queryFn,
enabled: !!(id && key),
enabled: id !== null && id !== undefined && key !== null && key !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
@@ -2190,7 +2210,7 @@ export function useGetPublicDashboardPanelQueryRangeV2<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -2295,7 +2315,7 @@ export function useListDashboardsForUserV2<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -32,6 +32,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists all planned maintenance / downtime schedules
* @summary List downtime schedules
@@ -111,7 +131,7 @@ export function useListDowntimeSchedules<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -340,7 +360,7 @@ export const getGetDowntimeScheduleByIDQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
@@ -378,7 +398,7 @@ export function useGetDowntimeScheduleByID<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -19,6 +19,26 @@ import type { GetFeatures200, RenderErrorResponseDTO } from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns the supported features and their details
* @summary Get features
@@ -85,7 +105,7 @@ export function useGetFeatures<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -25,6 +25,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns field keys
* @summary Get field keys
@@ -101,7 +121,7 @@ export function useGetFieldsKeys<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -196,7 +216,7 @@ export function useGetFieldsValues<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -48,6 +48,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns the ingestion keys for a workspace
* @summary Get ingestion keys for workspace
@@ -129,7 +149,7 @@ export function useGetIngestionKeys<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -356,7 +376,7 @@ export const getGetIngestionKeyQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!keyId,
enabled: keyId !== null && keyId !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKey>>,
@@ -393,7 +413,7 @@ export function useGetIngestionKey<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -557,7 +577,7 @@ export const getGetIngestionKeyLimitsQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!keyId,
enabled: keyId !== null && keyId !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeyLimits>>,
@@ -594,7 +614,7 @@ export function useGetIngestionKeyLimits<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -981,7 +1001,7 @@ export function useSearchIngestionKeys<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1210,7 +1230,7 @@ export const getGetIngestionLimitQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!limitId,
enabled: limitId !== null && limitId !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getIngestionLimit>>,
@@ -1247,7 +1267,7 @@ export function useGetIngestionLimit<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -22,6 +22,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns global config
* @summary Get global config
@@ -88,7 +108,7 @@ export function useGetGlobalConfig<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -26,6 +26,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* @summary Health check
*/
@@ -83,7 +103,7 @@ export function useHealthz<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -156,7 +176,7 @@ export function useLivez<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -226,7 +246,7 @@ export function useReadyz<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -48,6 +48,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
* @summary Run Infra Monitoring Setup Checks
@@ -116,7 +136,7 @@ export function useGetChecks<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -32,6 +32,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint validates the license key with the upstream server and activates the license for the organization.
* @deprecated
@@ -261,7 +281,7 @@ export function useListLicenses<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -484,7 +504,7 @@ export const getGetLicenseQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getLicense>>, TError, TData> & {
queryKey: QueryKey;
@@ -519,7 +539,7 @@ export function useGetLicense<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -683,7 +703,7 @@ export function useGetActiveLicense<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -31,6 +31,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Returns all LLM pricing rules for the authenticated org, with pagination.
* @summary List pricing rules
@@ -110,7 +130,7 @@ export function useListLLMPricingRules<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -339,7 +359,7 @@ export const getGetLLMPricingRuleQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getLLMPricingRule>>,
@@ -376,7 +396,7 @@ export function useGetLLMPricingRule<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -461,7 +481,7 @@ export function useListUnmappedLLMModels<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -28,6 +28,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoints allows complex query exporting raw data for traces and logs
* @summary Export raw data
@@ -197,7 +217,7 @@ export function useListPromotedAndIndexedPaths<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -60,6 +60,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Returns active metric volume-control (label reduction) rules.
* @summary List metric reduction rules
@@ -143,7 +163,7 @@ export function useListMetricReductionRules<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -372,7 +392,7 @@ export const getGetMetricReductionRuleByIDQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
@@ -413,7 +433,7 @@ export function useGetMetricReductionRuleByID<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -684,7 +704,7 @@ export function useGetMetricReductionRuleStats<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -770,7 +790,7 @@ export function useGetMetricReductionRuleTimeseries<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -864,7 +884,7 @@ export function useListMetrics<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -959,7 +979,7 @@ export function useGetMetricAlerts<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1057,7 +1077,7 @@ export function useGetMetricAttributes<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1155,7 +1175,7 @@ export function useGetMetricDashboards<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1253,7 +1273,7 @@ export function useGetMetricHighlights<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1434,7 +1454,7 @@ export function useGetMetricMetadata<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1605,7 +1625,7 @@ export function useGetMetricsOnboardingStatus<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1868,7 +1888,7 @@ export function useGetMetricDashboardsV2<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -26,6 +26,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns the organization I belong to
* @summary Get my organization
@@ -92,7 +112,7 @@ export function useGetMyOrganization<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -33,6 +33,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists all org preferences
* @summary List org preferences
@@ -99,7 +119,7 @@ export function useListOrgPreferences<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -163,7 +183,7 @@ export const getGetOrgPreferenceQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!name,
enabled: name !== null && name !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getOrgPreference>>,
@@ -200,7 +220,7 @@ export function useGetOrgPreference<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -385,7 +405,7 @@ export function useListUserPreferences<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -449,7 +469,7 @@ export const getGetUserPreferenceQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!name,
enabled: name !== null && name !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserPreference>>,
@@ -486,7 +506,7 @@ export function useGetUserPreference<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -30,6 +30,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus instant query
@@ -108,7 +128,7 @@ export function usePrometheusQuery<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -293,7 +313,7 @@ export function usePrometheusQueryRange<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -29,6 +29,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Returns the org's quick filters for every source, each filter as a telemetry field key.
* @summary List quick filters
@@ -95,7 +115,7 @@ export function useListQuickFilters<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -159,7 +179,7 @@ export const getGetQuickFiltersQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!source,
enabled: source !== null && source !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
@@ -196,7 +216,7 @@ export function useGetQuickFilters<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -32,6 +32,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists all roles
* @summary List roles
@@ -90,7 +110,7 @@ export function useListRoles<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -309,7 +329,7 @@ export const getGetRoleQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getRole>>, TError, TData> & {
queryKey: QueryKey;
@@ -340,7 +360,7 @@ export function useGetRole<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -32,6 +32,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists all route policies for the organization
* @summary List route policies
@@ -98,7 +118,7 @@ export function useGetAllRoutePolicies<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -325,7 +345,7 @@ export const getGetRoutePolicyByIDQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRoutePolicyByID>>,
@@ -362,7 +382,7 @@ export function useGetRoutePolicyByID<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -52,6 +52,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists all alert rules with their current evaluation state
* @summary List alert rules
@@ -110,7 +130,7 @@ export function useListRules<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -333,7 +353,7 @@ export const getGetRuleByIDQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleByID>>,
@@ -370,7 +390,7 @@ export function useGetRuleByID<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -640,7 +660,7 @@ export const getGetRuleHistoryFilterKeysQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleHistoryFilterKeys>>,
@@ -683,7 +703,7 @@ export function useGetRuleHistoryFilterKeys<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -757,7 +777,7 @@ export const getGetRuleHistoryFilterValuesQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleHistoryFilterValues>>,
@@ -800,7 +820,7 @@ export function useGetRuleHistoryFilterValues<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -874,7 +894,7 @@ export const getGetRuleHistoryOverallStatusQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleHistoryOverallStatus>>,
@@ -917,7 +937,7 @@ export function useGetRuleHistoryOverallStatus<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -990,7 +1010,7 @@ export const getGetRuleHistoryStatsQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleHistoryStats>>,
@@ -1032,7 +1052,7 @@ export function useGetRuleHistoryStats<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1105,7 +1125,7 @@ export const getGetRuleHistoryTimelineQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleHistoryTimeline>>,
@@ -1148,7 +1168,7 @@ export function useGetRuleHistoryTimeline<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1222,7 +1242,7 @@ export const getGetRuleHistoryTopContributorsQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRuleHistoryTopContributors>>,
@@ -1265,7 +1285,7 @@ export function useGetRuleHistoryTopContributors<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -33,6 +33,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Returns saved views, optionally filtered by source and name.
* @summary List saved views
@@ -109,7 +129,7 @@ export function useListSavedViews<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -333,7 +353,7 @@ export const getGetSavedViewQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSavedView>>,
@@ -370,7 +390,7 @@ export function useGetSavedView<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -47,6 +47,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint assigns a role to a service account
* @summary Create service account role
@@ -257,7 +277,7 @@ export const getGetServiceAccountRoleQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
@@ -294,7 +314,7 @@ export function useGetServiceAccountRole<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -379,7 +399,7 @@ export function useListServiceAccounts<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -607,7 +627,7 @@ export const getGetServiceAccountQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccount>>,
@@ -644,7 +664,7 @@ export function useGetServiceAccount<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -809,7 +829,7 @@ export const getListServiceAccountKeysQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listServiceAccountKeys>>,
@@ -847,7 +867,7 @@ export function useListServiceAccountKeys<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1192,7 +1212,7 @@ export const getGetServiceAccountRolesQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRoles>>,
@@ -1230,7 +1250,7 @@ export function useGetServiceAccountRoles<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1315,7 +1335,7 @@ export function useGetMyServiceAccount<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -34,6 +34,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint creates a session for a user using google callback
* @summary Create session by google callback
@@ -103,7 +123,7 @@ export function useCreateSessionByGoogleCallback<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -190,7 +210,7 @@ export function useCreateSessionByOIDCCallback<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -464,7 +484,7 @@ export function useGetSessionContext<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -43,6 +43,7 @@ export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAl
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type string
* @format password
*/
apiUrl: string;
/**
@@ -113,10 +114,12 @@ export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAl
export interface AlertmanagertypesChannelWebhookConfigDTO {
/**
* @type string
* @format password
*/
bearerToken?: string;
/**
* @type string
* @format password
*/
password?: string;
/**
@@ -125,6 +128,7 @@ export interface AlertmanagertypesChannelWebhookConfigDTO {
sendResolved?: boolean | null;
/**
* @type string
* @format password
*/
url: string;
/**
@@ -180,6 +184,7 @@ export interface AlertmanagertypesChannelPagerdutyConfigDTO {
group?: string;
/**
* @type string
* @format password
*/
routingKey: string;
/**
@@ -219,6 +224,7 @@ export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @type string
* @format password
*/
apiKey: string;
/**
@@ -278,6 +284,7 @@ export interface AlertmanagertypesChannelMSTeamsConfigDTO {
title?: string;
/**
* @type string
* @format password
*/
webhookUrl: string;
}
@@ -309,6 +316,7 @@ export interface AlertmanagertypesChannelGoogleChatConfigDTO {
title?: string;
/**
* @type string
* @format password
*/
webhookUrl: string;
}
@@ -332,6 +340,7 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @type string
* @format password
*/
apiToken: string;
/**
@@ -407,6 +416,7 @@ export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAl
export interface AlertmanagertypesChannelJSMOpsConfigDTO {
/**
* @type string
* @format password
*/
apiKey: string;
/**
@@ -466,6 +476,7 @@ export interface AlertmanagertypesChannelIncidentIOConfigDTO {
title?: string;
/**
* @type string
* @format password
*/
token: string;
/**
@@ -3220,33 +3231,30 @@ export interface CloudintegrationtypesAWSServiceConfigDTO {
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
}
export type CloudintegrationtypesAgentReportDTOAnyOfDataAnyOf = {
export type CloudintegrationtypesAgentReportDTODataAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type CloudintegrationtypesAgentReportDTOAnyOfData =
CloudintegrationtypesAgentReportDTOAnyOfDataAnyOf | null;
export type CloudintegrationtypesAgentReportDTOData =
CloudintegrationtypesAgentReportDTODataAnyOf | null;
export type CloudintegrationtypesAgentReportDTOAnyOf = {
/**
* @nullable
*/
export type CloudintegrationtypesAgentReportDTO = {
/**
* @type object,null
*/
data: CloudintegrationtypesAgentReportDTOAnyOfData;
data: CloudintegrationtypesAgentReportDTOData;
/**
* @type integer
* @format int64
*/
timestampMillis: number;
};
/**
* @nullable
*/
export type CloudintegrationtypesAgentReportDTO =
CloudintegrationtypesAgentReportDTOAnyOf | null;
} | null;
export interface CloudintegrationtypesAzureAccountConfigDTO {
/**
@@ -3445,7 +3453,10 @@ export enum CloudintegrationtypesServiceIDDTO {
cloudstorage = 'cloudstorage',
cloudsql_mysql = 'cloudsql_mysql',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
* @nullable
*/
export type CloudintegrationtypesCloudIntegrationServiceDTO = {
/**
* @type string
*/
@@ -3466,13 +3477,7 @@ export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
* @format date-time
*/
updatedAt?: string;
};
/**
* @nullable
*/
export type CloudintegrationtypesCloudIntegrationServiceDTO =
CloudintegrationtypesCloudIntegrationServiceDTOAnyOf | null;
} | null;
export interface CloudintegrationtypesCollectedLogAttributeDTO {
/**
@@ -3626,19 +3631,16 @@ export interface CloudintegrationtypesOldAWSCollectionStrategyDTO {
s3_buckets?: CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets;
}
export type CloudintegrationtypesIntegrationConfigDTOAnyOf = {
/**
* @nullable
*/
export type CloudintegrationtypesIntegrationConfigDTO = {
/**
* @type array
*/
enabled_regions: string[];
telemetry: CloudintegrationtypesOldAWSCollectionStrategyDTO;
};
/**
* @nullable
*/
export type CloudintegrationtypesIntegrationConfigDTO =
CloudintegrationtypesIntegrationConfigDTOAnyOf | null;
} | null;
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
@@ -9459,7 +9461,10 @@ export interface TelemetrystoretypesMergeTreeReadDTO {
table: string;
}
export type TelemetrystoretypesGranulesDTOAnyOf = {
/**
* @nullable
*/
export type TelemetrystoretypesGranulesDTO = {
/**
* @type integer
* @format int64
@@ -9479,13 +9484,7 @@ export type TelemetrystoretypesGranulesDTOAnyOf = {
* @format int64
*/
skipped: number;
};
/**
* @nullable
*/
export type TelemetrystoretypesGranulesDTO =
TelemetrystoretypesGranulesDTOAnyOf | null;
} | null;
export interface Querybuildertypesv5PreviewStatementDTO {
/**
@@ -10683,7 +10682,10 @@ export interface SpantypesGettableFlamegraphTraceDTO {
startTimestampMillis: number;
}
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
/**
* @nullable
*/
export type SpantypesSpanMapperGroupConditionDTO = {
/**
* @type array,null
*/
@@ -10692,13 +10694,7 @@ export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
* @type array,null
*/
resource: string[] | null;
};
/**
* @nullable
*/
export type SpantypesSpanMapperGroupConditionDTO =
SpantypesSpanMapperGroupConditionDTOAnyOf | null;
} | null;
export interface SpantypesSpanMapperGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;

View File

@@ -41,6 +41,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Returns all span attribute mapping groups for the authenticated org.
* @summary List span attribute mapping groups
@@ -120,7 +140,7 @@ export function useListSpanMapperGroups<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -449,7 +469,7 @@ export const getListSpanMappersQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!groupId,
enabled: groupId !== null && groupId !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listSpanMappers>>,
@@ -486,7 +506,7 @@ export function useListSpanMappers<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -19,6 +19,26 @@ import type { GetStats200, RenderErrorResponseDTO } from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint returns the collected stats for the organization
* @summary Get stats
@@ -77,7 +97,7 @@ export function useGetStats<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -28,6 +28,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint gets the organization's subscription along with its usage and billing details.
* @summary Get the subscription.
@@ -94,7 +114,7 @@ export function useGetSubscription<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -50,6 +50,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint is deprecated and always fails. Use GET /api/v2/users/me instead.
* @deprecated
@@ -118,7 +138,7 @@ export function useGetMyUserDeprecated<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -432,7 +452,7 @@ export const getGetUsersByRoleIDQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUsersByRoleID>>,
@@ -469,7 +489,7 @@ export function useGetUsersByRoleID<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -693,7 +713,7 @@ export const getGetUserRoleQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
@@ -730,7 +750,7 @@ export function useGetUserRole<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -807,7 +827,7 @@ export function useListUsers<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1026,7 +1046,7 @@ export const getGetUserQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData> & {
queryKey: QueryKey;
@@ -1057,7 +1077,7 @@ export function useGetUser<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1221,7 +1241,7 @@ export const getGetResetPasswordTokenQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getResetPasswordToken>>,
@@ -1258,7 +1278,7 @@ export function useGetResetPasswordToken<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1402,7 +1422,7 @@ export const getGetRolesByUserIDQueryOptions = <
return {
queryKey,
queryFn,
enabled: !!id,
enabled: id !== null && id !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getRolesByUserID>>,
@@ -1439,7 +1459,7 @@ export function useGetRolesByUserID<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**
@@ -1516,7 +1536,7 @@ export function useGetMyUser<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -27,6 +27,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint gets the host info from zeus.
* @summary Get host info from Zeus.
@@ -85,7 +105,7 @@ export function useGetHosts<
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
return withQueryKey(query, queryOptions.queryKey);
}
/**

View File

@@ -0,0 +1,74 @@
import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observability';
import { getFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { getFieldKeySuggestions } from '../getFieldKeySuggestions';
import { FieldKeysResponse } from '../types';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
}));
jest.mock('api/generated/services/fields', () => ({
getFieldsKeys: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getFieldsKeys as jest.MockedFunction<
typeof getFieldsKeys
>;
const keysResponse = (): FieldKeysResponse => ({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
});
describe('getFieldKeySuggestions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
const response = keysResponse();
mockedAIKeys.mockResolvedValue(response);
const fieldKeysConfig = { searchText: 'llm' };
const abortSignal = new AbortController().signal;
await expect(
getFieldKeySuggestions(fieldKeysConfig, 'builder_ai_query', abortSignal),
).resolves.toBe(response);
expect(mockedAIKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
expect(mockedGenericKeys).not.toHaveBeenCalled();
});
it.each<
[
'an unmarked query' | 'an explicitly generic query',
undefined | 'builder_query',
]
>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const response = keysResponse();
mockedGenericKeys.mockResolvedValue(response);
const fieldKeysConfig = {
signal: TelemetrytypesSignalDTO.traces,
searchText: 'svc',
};
const abortSignal = new AbortController().signal;
await expect(
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, abortSignal),
).resolves.toBe(response);
expect(mockedGenericKeys).toHaveBeenCalledWith(fieldKeysConfig, abortSignal);
expect(mockedAIKeys).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { getAIObservabilityFieldsValues } from 'api/generated/services/ai-observability';
import { getFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { getFieldValueSuggestions } from '../getFieldValueSuggestions';
import { FieldValuesResponse } from '../types';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/generated/services/fields', () => ({
getFieldsValues: jest.fn(),
}));
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getFieldsValues as jest.MockedFunction<
typeof getFieldsValues
>;
const valuesResponse = (): FieldValuesResponse => ({
status: 'success',
data: { complete: true, values: { stringValues: ['gpt-4o'] } },
});
describe('getFieldValueSuggestions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query, forwarding the key as name', async () => {
const response = valuesResponse();
mockedAIValues.mockResolvedValue(response);
const fieldValuesConfig = { name: 'gen_ai.request.model', searchText: 'gpt' };
const abortSignal = new AbortController().signal;
await expect(
getFieldValueSuggestions(fieldValuesConfig, 'builder_ai_query', abortSignal),
).resolves.toBe(response);
expect(mockedAIValues).toHaveBeenCalledWith(fieldValuesConfig, abortSignal);
expect(mockedGenericValues).not.toHaveBeenCalled();
});
it.each<
[
'an unmarked query' | 'an explicitly generic query',
undefined | 'builder_query',
]
>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const response = valuesResponse();
mockedGenericValues.mockResolvedValue(response);
const fieldValuesConfig = {
signal: TelemetrytypesSignalDTO.traces,
name: 'service.name',
searchText: 'front',
};
const abortSignal = new AbortController().signal;
await expect(
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, abortSignal),
).resolves.toBe(response);
expect(mockedGenericValues).toHaveBeenCalledWith(
fieldValuesConfig,
abortSignal,
);
expect(mockedAIValues).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,14 @@
import { getAIObservabilityFieldsKeys } from 'api/generated/services/ai-observability';
import { getFieldsKeys } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldKeysConfig, FieldKeysResponse } from './types';
export const getFieldKeySuggestions = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
abortSignal?: AbortSignal,
): Promise<FieldKeysResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsKeys(fieldKeysConfig, abortSignal)
: getFieldsKeys(fieldKeysConfig, abortSignal);

View File

@@ -0,0 +1,14 @@
import { getAIObservabilityFieldsValues } from 'api/generated/services/ai-observability';
import { getFieldsValues } from 'api/generated/services/fields';
import type { BuilderQueryType } from 'types/api/v5/queryRange';
import { FieldValuesConfig, FieldValuesResponse } from './types';
export const getFieldValueSuggestions = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
abortSignal?: AbortSignal,
): Promise<FieldValuesResponse> =>
builderQueryType === 'builder_ai_query'
? getAIObservabilityFieldsValues(fieldValuesConfig, abortSignal)
: getFieldsValues(fieldValuesConfig, abortSignal);

View File

@@ -0,0 +1,31 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,
'signal' | 'searchText'
>;
export type FieldKeysResponse =
| GetFieldsKeys200
| GetAIObservabilityFieldsKeys200;
export type FieldValuesResponse =
| GetFieldsValues200
| GetAIObservabilityFieldsValues200;

View File

@@ -20,7 +20,6 @@ export const Logout = async (): Promise<void> => {
deleteLocalStorageKey(LOCALSTORAGE.LOGGED_IN_USER_NAME);
deleteLocalStorageKey(LOCALSTORAGE.CHAT_SUPPORT);
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
window.dispatchEvent(new CustomEvent('LOGOUT'));
history.push(ROUTES.LOGIN);
};

View File

@@ -6,7 +6,8 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
@@ -31,6 +32,9 @@ interface FieldsSelectorProps {
// Lets users add a free-typed field which
// does not show up in the suggestions
allowCustomFields?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
width?: number;
height?: number;
defaultPosition?: { x: number; y: number };
@@ -50,6 +54,9 @@ function FieldsSelectorContent({
maxFields,
requiredFields,
allowCustomFields,
fieldKeysConfig,
builderQueryType,
extraFields,
width = DEFAULT_PANEL_WIDTH,
height,
defaultPosition,
@@ -158,6 +165,9 @@ function FieldsSelectorContent({
onAdd={handleAdd}
isAtLimit={isAtLimit}
allowCustomFields={allowCustomFields}
fieldKeysConfig={fieldKeysConfig}
builderQueryType={builderQueryType}
extraFields={extraFields}
/>
{hasUnsavedChanges && (

View File

@@ -3,18 +3,22 @@ import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
BuilderQueryType,
FieldContext,
SignalType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import { mergeExtraFields } from 'utils/extraFields';
import styles from './FieldsSelector.module.scss';
const EMPTY_EXTRA_FIELDS: TelemetryFieldKey[] = [];
interface OtherFieldsProps {
signal: DataSource;
debouncedInputValue: string;
@@ -22,6 +26,9 @@ interface OtherFieldsProps {
onAdd: (field: TelemetryFieldKey) => void;
isAtLimit: boolean;
allowCustomFields?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
}
function OtherFields({
@@ -31,26 +38,26 @@ function OtherFields({
onAdd,
isAtLimit,
allowCustomFields,
fieldKeysConfig,
builderQueryType,
extraFields = EMPTY_EXTRA_FIELDS,
}: OtherFieldsProps): JSX.Element {
const { data, isFetching } = useGetQueryKeySuggestions(
const { data: fetchedFields, isFetching } = useFieldKeysSuggestion(
{
signal,
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[signal],
searchText: debouncedInputValue,
},
{
queryKey: [
REACT_QUERY_KEY.GET_FIELDS_SELECTOR_SUGGESTIONS,
signal,
debouncedInputValue,
],
enabled: true,
},
builderQueryType,
);
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
const search = debouncedInputValue.trim().toLowerCase();
// Normalize: synthesize `key` once so downstream reads can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
const suggestions: TelemetryFieldKey[] = mergeExtraFields(
extraFields.filter((field) => field.name.toLowerCase().includes(search)),
fetchedFields ?? [],
).map((attr) => ({
...attr,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
signal: attr.signal as SignalType,
@@ -87,7 +94,13 @@ function OtherFields({
key: buildCompositeKey(typed, ''),
};
return [customField, ...available];
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
}, [
extraFields,
fetchedFields,
addedFields,
allowCustomFields,
debouncedInputValue,
]);
if (isFetching) {
return (

View File

@@ -1,11 +1,17 @@
import { act, fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
useFieldKeysSuggestion: jest.fn(() => ({
data: undefined,
isFetching: false,
isFetched: true,
})),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
@@ -21,22 +27,15 @@ jest.mock('periscope/components/FloatingPanel', () => ({
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
isFetching: false,
isFetched: true,
});
};

View File

@@ -1,29 +1,30 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import OtherFields from '../OtherFields';
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('hooks/querySuggestions/useFieldKeysSuggestion', () => ({
useFieldKeysSuggestion: jest.fn(() => ({
data: undefined,
isFetching: false,
isFetched: true,
})),
}));
const mockSuggestions = (names: string[]): void => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
},
},
},
},
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: names.map((name) => ({
name,
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
})),
isFetching: false,
isFetched: true,
});
};
@@ -82,7 +83,6 @@ describe('OtherFields — custom (free-typed) option', () => {
mockSuggestions(['orderId']);
renderOtherFields({ debouncedInputValue: 'orderid' });
// the real suggestion shows, the lowercased custom name does not
expect(screen.getByText('orderId')).toBeInTheDocument();
expect(screen.queryByText('orderid')).not.toBeInTheDocument();
});
@@ -116,10 +116,126 @@ describe('OtherFields — custom (free-typed) option', () => {
it('shows the custom option at the field limit but hides its Add button', () => {
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true });
// same as every other row at the limit: name shown, no Add button
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /add/i }),
).not.toBeInTheDocument();
});
});
describe('OtherFields — field keys config', () => {
const pool: TelemetryFieldKey[] = [
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'llm_call_count', fieldContext: 'trace', fieldDataType: 'float64' },
];
const fieldKeysConfig: FieldKeysConfigProp = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
};
const builderQueryType: BuilderQueryType = 'builder_ai_query';
const mockPool = (fields: TelemetryFieldKey[]): void => {
(useFieldKeysSuggestion as jest.Mock).mockReturnValue({
data: fields,
isFetching: false,
isFetched: true,
});
};
beforeEach(() => {
mockPool(pool);
});
it('lists the pool it is handed', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
});
expect(screen.getByText('total_tokens')).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('forwards the fetch params and search to the shared keys hook', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
debouncedInputValue: 'llm',
});
expect(useFieldKeysSuggestion).toHaveBeenCalledWith(
{
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
searchText: 'llm',
},
builderQueryType,
);
});
it('lists extra fields the keys endpoint never returns', () => {
mockPool([{ name: 'total_tokens' } as TelemetryFieldKey]);
renderOtherFields({
fieldKeysConfig,
builderQueryType,
extraFields: [{ name: 'last_activity_time' } as TelemetryFieldKey],
allowCustomFields: false,
});
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
expect(screen.getByText('total_tokens')).toBeInTheDocument();
});
it('filters extra fields by search text', () => {
mockPool([]);
renderOtherFields({
fieldKeysConfig,
builderQueryType,
extraFields: [
{ name: 'last_activity_time' } as TelemetryFieldKey,
{ name: 'timestamp' } as TelemetryFieldKey,
],
debouncedInputValue: 'activity',
allowCustomFields: false,
});
expect(screen.getByText('last_activity_time')).toBeInTheDocument();
expect(screen.queryByText('timestamp')).not.toBeInTheDocument();
});
it('keeps a fetched key whose name does not contain the search text', () => {
mockPool([
{ name: 'service.name', fieldContext: 'resource' } as TelemetryFieldKey,
]);
renderOtherFields({
debouncedInputValue: 'resource.service',
allowCustomFields: false,
});
expect(screen.getByText('service.name')).toBeInTheDocument();
});
it('omits pool fields that are already added', () => {
renderOtherFields({
fieldKeysConfig,
builderQueryType,
allowCustomFields: false,
addedFields: [
{
name: 'total_tokens',
fieldContext: 'trace',
fieldDataType: 'float64',
key: 'trace:total_tokens:float64',
},
],
});
expect(screen.queryByText('total_tokens')).not.toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
});

View File

@@ -1,16 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { Select, Spin } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import './ListViewOrderBy.styles.scss';
const DEFAULT_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'timestamp' } as TelemetryFieldKey,
];
interface ListViewOrderByProps {
value: string;
onChange: (value: string) => void;
dataSource: DataSource;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
}
// Loader component for the dropdown when loading or no results
@@ -26,6 +33,9 @@ function ListViewOrderBy({
value,
onChange,
dataSource,
fieldKeysConfig,
builderQueryType,
extraFields = DEFAULT_EXTRA_FIELDS,
}: ListViewOrderByProps): JSX.Element {
const [searchInput, setSearchInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
@@ -34,17 +44,14 @@ function ListViewOrderBy({
>([]);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fetch key suggestions based on debounced input
const { data, isLoading } = useQuery({
queryKey: ['orderByKeySuggestions', dataSource, debouncedInput],
queryFn: async () => {
const response = await getKeySuggestions({
signal: dataSource,
searchText: debouncedInput,
});
return response.data;
const { data, isLoading } = useFieldKeysSuggestion(
{
...fieldKeysConfig,
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
searchText: debouncedInput,
},
});
builderQueryType,
);
useEffect(
() => (): void => {
@@ -55,24 +62,24 @@ function ListViewOrderBy({
[],
);
const extraKeysSignature = extraFields.map((field) => field.name).join(',');
// Update options when API data changes
useEffect(() => {
const rawKeys: QueryKeyDataSuggestionsProps[] = data?.data?.keys
? Object.values(data.data?.keys).flat()
: [];
const keyNames = (data ?? []).map((field) => field.name);
const search = searchInput.trim().toLowerCase();
const extraMatches = extraKeysSignature
.split(',')
.filter((key) => key.length > 0 && key.toLowerCase().includes(search));
const uniqueKeys = [...new Set([...extraMatches, ...keyNames])];
const keyNames = rawKeys.map((key) => key.name);
const uniqueKeys = [
...new Set(searchInput ? keyNames : ['timestamp', ...keyNames]),
];
const updatedOptions = uniqueKeys.flatMap((key) => [
{ label: `${key} (desc)`, value: `${key}:desc` },
{ label: `${key} (asc)`, value: `${key}:asc` },
]);
setSelectOptions(updatedOptions);
}, [data, searchInput]);
setSelectOptions(
uniqueKeys.flatMap((key) => [
{ label: `${key} (desc)`, value: `${key}:desc` },
{ label: `${key} (asc)`, value: `${key}:asc` },
]),
);
}, [data, searchInput, extraKeysSignature]);
// Handle search input with debounce
const handleSearch = (input: string): void => {

View File

@@ -0,0 +1,169 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { ENVIRONMENT } from 'constants/env';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_FIELD_KEYS,
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from 'container/LLMObservability/Explorer/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import ListViewOrderBy from '../ListViewOrderBy';
const seenAI: URLSearchParams[] = [];
const seenGeneric: URLSearchParams[] = [];
const mockAIKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
},
),
);
};
const mockGenericKeys = (names: string[]): void => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (req, res, ctx) => {
seenGeneric.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
}),
);
};
const openDropdown = (): void => {
fireEvent.mouseDown(screen.getByRole('combobox'));
};
const getOptionLabels = (): string[] =>
Array.from(document.querySelectorAll('.ant-select-item-option-content')).map(
(node) => node.textContent ?? '',
);
describe('ListViewOrderBy', () => {
beforeEach(() => {
seenAI.length = 0;
seenGeneric.length = 0;
});
it('reads the ai_observability trace context for an AI query', async () => {
mockAIKeys(['total_tokens']);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
await waitFor(() => {
expect(seenAI).toHaveLength(1);
});
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
expect(seenGeneric).toHaveLength(0);
});
it('offers the extra keys alongside the ones the endpoint reports', async () => {
mockAIKeys(['total_tokens']);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
openDropdown();
await waitFor(() => {
expect(getOptionLabels()).toContain('total_tokens (desc)');
});
expect(getOptionLabels()).toContain('last_activity_time (asc)');
});
it('keeps a matching extra key while searching', async () => {
mockAIKeys([]);
render(
<ListViewOrderBy
value="last_activity_time:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>,
);
await waitFor(() => {
expect(seenAI.length).toBeGreaterThan(0);
});
openDropdown();
fireEvent.change(screen.getByRole('combobox'), {
target: { value: 'activity' },
});
await waitFor(() => {
expect(getOptionLabels()).toContain('last_activity_time (desc)');
});
});
it('defaults to timestamp and the generic endpoint', async () => {
mockGenericKeys(['service.name']);
render(
<ListViewOrderBy
value="timestamp:desc"
onChange={jest.fn()}
dataSource={DataSource.TRACES}
/>,
);
await waitFor(() => {
expect(seenGeneric).toHaveLength(1);
});
expect(seenGeneric[0]?.get('signal')).toBe(DataSource.TRACES);
expect(seenGeneric[0]?.get('searchText')).toBe('');
openDropdown();
await waitFor(() => {
expect(getOptionLabels()).toContain('timestamp (desc)');
});
});
});

View File

@@ -36,7 +36,7 @@ import {
} from 'types/antlrQueryTypes';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
getCurrentValueIndexAtCursor,
getQueryContextAtCursor,
@@ -45,6 +45,13 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type {
TelemetrytypesGettableFieldKeysDTOKeysAnyOf,
TelemetrytypesSourceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import type { SignalType } from 'types/api/v5/queryRange';
import {
@@ -52,12 +59,6 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -265,8 +266,10 @@ function QuerySearch({
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
const generateOptions = (
keys: TelemetrytypesGettableFieldKeysDTOKeysAnyOf,
): any[] =>
Object.values(keys).flatMap((items: TelemetrytypesTelemetryFieldKeyDTO[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -319,17 +322,19 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
metricNamespace,
});
const response = await getFieldKeySuggestions(
{
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
source: signalSource as TelemetrytypesSourceDTO,
metricNamespace,
},
queryData.builderQueryType,
);
if (response.data.data) {
const { keys } = response.data.data;
if (response.data.keys) {
const { keys } = response.data;
const options = generateOptions(keys);
// Deduplicate by full variant identity (name + context + data type), NOT by
// label. deduping by label removes varient which is not expected. If we need
@@ -497,21 +502,23 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
key,
searchText: sanitizedSearchText,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
const responseData = response.data as any;
const data = responseData.data || {};
const values = data.values || {};
: await getFieldValueSuggestions(
{
signal: DATA_SOURCE_TO_SIGNAL[dataSource],
name: key,
searchText: sanitizedSearchText,
source: signalSource as TelemetrytypesSourceDTO,
metricName: debouncedMetricName ?? undefined,
},
queryData.builderQueryType,
).then((response) => {
const responseData = response.data;
const responseDataValues = responseData.values;
return {
stringValues: values.stringValues || [],
numberValues: values.numberValues || [],
complete: data.complete ?? false,
stringValues: responseDataValues.stringValues ?? [],
numberValues: responseDataValues.numberValues ?? [],
complete: responseData.complete ?? false,
};
});

View File

@@ -1,215 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { DataSource } from 'types/common/queryBuilder';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
} from '../fieldSuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
const aiValuesResponse = (
values: { stringValues?: string[]; numberValues?: number[] } | null,
complete = true,
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
({
status: 'success',
data: { complete, values },
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys.data.data).toStrictEqual({
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to an empty map', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const response = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
});
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
});
it('passes the generic response through untouched', async () => {
const genericResponse = {
data: { status: 'success', data: { complete: true, keys: {} } },
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
mockedGenericKeys.mockResolvedValue(genericResponse);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBe(genericResponse);
});
});
describe('fetchFieldValuesForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIValues.mockResolvedValue(
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
);
const response = await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'gen_ai.request.model',
searchText: 'gpt',
});
expect(mockedGenericValues).not.toHaveBeenCalled();
expect(response).toStrictEqual({
data: {
data: {
complete: true,
values: { stringValues: ['gpt-4o'], numberValues: [] },
},
},
});
});
it('forwards the key as the name the endpoint expects', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'total_tokens',
searchText: '',
});
expect(mockedAIValues).toHaveBeenCalledWith({
name: 'total_tokens',
searchText: '',
});
});
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
await expect(
fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'llm_call_count',
searchText: '',
}),
).resolves.toStrictEqual({
data: { data: { complete: false, values: null } },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const genericResponse = {
data: {
data: { complete: false, values: { stringValues: ['frontend'] } },
},
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
mockedGenericValues.mockResolvedValue(genericResponse);
const response = await fetchFieldValuesForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
});
expect(mockedAIValues).not.toHaveBeenCalled();
expect(mockedGenericValues).toHaveBeenCalledWith(
expect.objectContaining({
signal: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
}),
);
expect(response).toBe(genericResponse);
});
});

View File

@@ -1,111 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
export interface SuggestedFieldKeysPayload {
complete: boolean;
keys: SuggestedFieldKeysByName;
}
export interface SuggestedFieldKeysResponse {
data: { data?: SuggestedFieldKeysPayload };
}
export interface SuggestedFieldValuesPayload {
complete?: boolean;
values?: {
stringValues?: string[] | null;
numberValues?: number[] | null;
} | null;
}
export interface SuggestedFieldValuesResponse {
data: { data?: SuggestedFieldValuesPayload };
}
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
interface FetchFieldValuesParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
key: string;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
}
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return {
data: {
data: response.data
? { complete: response.data.complete, keys: response.data.keys ?? {} }
: undefined,
},
};
}
return getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
};
export const fetchFieldValuesForQuery = async ({
builderQueryType,
dataSource,
key,
searchText,
metricName,
signalSource,
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsValues({
name: key,
searchText,
});
return { data: { data: response.data } };
}
// getValueSuggestions' declared response type does not match what the endpoint returns.
return getValueSuggestions({
signal: dataSource,
key,
searchText,
signalSource,
metricName,
}) as unknown as Promise<SuggestedFieldValuesResponse>;
};

View File

@@ -1,10 +1,15 @@
import { EditorView } from '@uiw/react-codemirror';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import { initialQueriesMap } from 'constants/queryBuilder';
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import QuerySearch from '../QuerySearch/QuerySearch';
@@ -30,17 +35,25 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
};
});
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: {
data: { keys: {} as Record<string, QueryKeyDataSuggestionsProps[]> },
},
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
getFieldKeySuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: { complete: true, keys: {} },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
getFieldValueSuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: {
complete: true,
values: {
stringValues: [],
numberValues: [],
boolValues: [],
relatedValues: [],
},
},
}),
}));
@@ -68,8 +81,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
it('fetches key suggestions when typing a key (debounced)', async () => {
// Use real timers for CodeMirror integration tests
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
const mockedGetKeys = getFieldKeySuggestions as jest.MockedFunction<
typeof getFieldKeySuggestions
>;
mockedGetKeys.mockClear();
@@ -102,10 +115,22 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
it('fetches value suggestions when editing value context', async () => {
// Use real timers for CodeMirror integration tests
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
const mockedGetValues = getFieldValueSuggestions as jest.MockedFunction<
typeof getFieldValueSuggestions
>;
mockedGetValues.mockClear();
mockedGetValues.mockResolvedValueOnce({
status: 'success',
data: {
complete: true,
values: {
stringValues: ['payment-service'],
numberValues: [200],
boolValues: [],
relatedValues: [],
},
},
});
render(
<QuerySearch
@@ -129,12 +154,18 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
timeout: 2000,
});
// the string and number values off the response both reach the dropdown
await expect(
screen.findByText('payment-service'),
).resolves.toBeInTheDocument();
await expect(screen.findByText('200')).resolves.toBeInTheDocument();
});
it('fetches key suggestions on mount for LOGS', async () => {
// Use real timers for CodeMirror integration tests
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
const mockedGetKeysOnMount = getFieldKeySuggestions as jest.MockedFunction<
typeof getFieldKeySuggestions
>;
mockedGetKeysOnMount.mockClear();
@@ -153,6 +184,7 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
() =>
expect(mockedGetKeysOnMount).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.LOGS, searchText: '' }),
undefined,
),
{ timeout: 2000 },
);
@@ -357,8 +389,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
const mockedGetKeys = getFieldKeySuggestions as jest.MockedFunction<
typeof getFieldKeySuggestions
>;
mockedGetKeys.mockClear();

View File

@@ -31,15 +31,25 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: { keys: {} } },
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
getFieldKeySuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: { complete: true, keys: {} },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
getFieldValueSuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: {
complete: true,
values: {
stringValues: [],
numberValues: [],
boolValues: [],
relatedValues: [],
},
},
}),
}));

View File

@@ -1,15 +1,12 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import {
TelemetrytypesSignalDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DataSource } from 'types/common/queryBuilder';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
interface UseFieldValuesProps {
filter: IQuickFiltersConfig;
@@ -29,15 +26,6 @@ interface UseFieldValuesReturn {
isFetching: boolean;
}
export const DATA_SOURCE_TO_SIGNAL: Record<
DataSource,
TelemetrytypesSignalDTO
> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
};
const QUICK_FILTERS_SOURCE_TO_SOURCE: Partial<
Record<QuickFiltersSource, TelemetrytypesSourceDTO>
> = {

View File

@@ -95,6 +95,7 @@
gap: 10px;
width: 100%;
justify-content: flex-end;
--button-variant-link-color: var(--l1-foreground);
.divider-filter {
width: 1px;

View File

@@ -15,28 +15,30 @@ import {
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction, isNull } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
QuickFilterManagePermissions,
QuickFilterReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { USER_ROLES } from 'types/roles';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
import useFilterConfig from './hooks/useFilterConfig';
import AnnouncementTooltip from './QuickFiltersSettings/AnnouncementTooltip';
import QuickFiltersSettings from './QuickFiltersSettings/QuickFiltersSettings';
import { FiltersType, IQuickFiltersProps, QuickFiltersSource } from './types';
@@ -55,9 +57,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showQueryName = true,
useFieldApis,
} = props;
const { user } = useAppContext();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const isAdmin = user.role === USER_ROLES.ADMIN;
const [params, setParams] = useApiMonitoringParams();
const showIP = params.showIP ?? true;
@@ -68,6 +68,12 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
refetchCustomFilters,
isCustomFiltersLoading,
} = useFilterConfig({ signal, config });
const {
deniedPermissions: deniedSettingsPermissions,
isLoading: isSettingsChecking,
} = useAuthZ(QuickFilterManagePermissions, { enabled: isDynamicFilters });
const isSettingsDisabled =
isSettingsChecking || deniedSettingsPermissions.length > 0;
const {
currentQuery,
@@ -107,16 +113,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const shouldShowDropdownInListView =
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
const showAnnouncementTooltip = useMemo(() => {
const localStorageValue = getLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
);
if (!isNull(localStorageValue)) {
return !(localStorageValue === 'false');
}
return true;
}, []);
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
@@ -232,49 +228,49 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderRightActions = (): JSX.Element => (
<section className="right-actions">
<Tooltip title="Reset All">
<div className="right-action-icon-container">
<RefreshCw className="sync-icon" size="md" onClick={handleReset} />
</div>
<Button
variant="link"
color="secondary"
aria-label="Reset All"
className="right-action-icon-container"
onClick={handleReset}
prefix={<RefreshCw className="sync-icon" size="md" />}
/>
</Tooltip>
{showFilterCollapse && (
<Tooltip title="Collapse Filters">
<div className="right-action-icon-container">
<ArrowUpToLine
style={{ rotate: '270deg', cursor: 'pointer' }}
size="md"
onClick={handleFilterVisibilityChange}
/>
</div>
<Button
variant="link"
color="secondary"
aria-label="Collapse Filters"
className="right-action-icon-container"
onClick={handleFilterVisibilityChange}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
/>
</Tooltip>
)}
{isDynamicFilters && isAdmin && (
<Tooltip title="Settings">
<div
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
onClick={(): void => setIsSettingsOpen(true)}
/>
<AnnouncementTooltip
show={showAnnouncementTooltip}
position={{ top: -5, left: 15 }}
title="Edit your quick filters"
message="You can now customize and re-arrange your quick filters panel. Select the quick filters youd need and hide away the rest for faster exploration."
onClose={(): void => {
setLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
'false',
);
}}
/>
</div>
</Tooltip>
{isDynamicFilters && (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="link"
color="secondary"
aria-label="Settings"
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
/>
</Tooltip>
}
/>
)}
</section>
);
@@ -361,6 +357,21 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</>
);
const filtersSkeleton = (
<div className="quick-filters-skeleton">
{Array.from({ length: 5 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={index} />
))}
</div>
);
const filtersContent = isCustomFiltersLoading ? (
filtersSkeleton
) : (
<OverlayScrollbar>{renderContent()}</OverlayScrollbar>
);
return (
<div className="quick-filters-container">
<div className="quick-filters">
@@ -371,15 +382,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</section>
)}
{isCustomFiltersLoading ? (
<div className="quick-filters-skeleton">
{Array.from({ length: 5 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={index} />
))}
</div>
{signal ? (
<AuthZGuardContent
checks={[QuickFilterReadPermission]}
fallbackOnLoading={filtersSkeleton}
>
{filtersContent}
</AuthZGuardContent>
) : (
<OverlayScrollbar>{renderContent()}</OverlayScrollbar>
filtersContent
)}
</div>
<div className="quick-filters-settings-container">

View File

@@ -3,7 +3,6 @@ import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
@@ -12,6 +11,7 @@ import {
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
function OtherFiltersSkeleton(): JSX.Element {
return (

View File

@@ -35,10 +35,7 @@ const useFilterConfig = ({
[data],
);
const isDynamicFilters = useMemo(
() => customFilters.length > 0,
[customFilters],
);
const isDynamicFilters = !!signal;
const filterConfig = useMemo(
() => getFilterConfig(signal, customFilters, config),

View File

@@ -0,0 +1,212 @@
import { ENVIRONMENT } from 'constants/env';
import {
ApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
QuickFilterReadPermission,
QuickFilterUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/ApiMonitoring/queryParams');
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
const attributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
const NOT_AUTHORIZED_TEXT = /is not authorized to perform/i;
const FILTER_SERVICE_NAME = 'Service Name';
const SETTINGS_CONTAINER_TEST_ID = 'settings-icon-container';
beforeEach(() => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: 'Test Query',
filters: { items: [] },
},
],
},
},
lastUsedQuery: 0,
redirectWithQueryBuilderData: jest.fn(),
});
mockUseApiMonitoringParams.mockReturnValue([
{ showIP: true } as ApiMonitoringParams,
jest.fn(),
]);
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
),
rest.get(fieldsKeysURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(otherFiltersResponse)),
),
rest.get(attributeValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderWithSignal(): void {
render(
<QuickFilters
source={QuickFiltersSource.LOGS_EXPLORER}
signal={SIGNAL}
handleFilterVisibilityChange={jest.fn()}
/>,
);
}
function renderStaticConfig(): void {
render(
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={QuickFiltersConfig}
handleFilterVisibilityChange={jest.fn()}
/>,
);
}
describe('QuickFilters - AuthZ', () => {
describe('read denied', () => {
it('shows the inline denial instead of the filters, header stays', async () => {
server.use(setupAuthzDeny(QuickFilterReadPermission));
renderWithSignal();
await expect(
screen.findByText(NOT_AUTHORIZED_TEXT),
).resolves.toBeInTheDocument();
expect(screen.queryByText(FILTER_SERVICE_NAME)).not.toBeInTheDocument();
expect(screen.getByText('Filters for')).toBeInTheDocument();
const settingsTrigger = await screen.findByTestId(
SETTINGS_CONTAINER_TEST_ID,
);
await waitFor(() =>
expect(settingsTrigger).toHaveAttribute(
'data-denied-permissions',
expect.stringContaining('read'),
),
);
});
});
describe('update denied', () => {
it('renders the filters but disables the settings trigger', async () => {
server.use(setupAuthzDeny(QuickFilterUpdatePermission));
renderWithSignal();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
const settingsTrigger = await screen.findByTestId(
SETTINGS_CONTAINER_TEST_ID,
);
await waitFor(() =>
expect(settingsTrigger).toHaveAttribute(
'data-denied-permissions',
expect.stringContaining('update'),
),
);
await userEvent.click(settingsTrigger);
expect(screen.queryByText(/ADDED FILTERS/i)).not.toBeInTheDocument();
});
});
describe('all permissions granted', () => {
it('renders the filters and opens settings from the trigger', async () => {
server.use(setupAuthzAdmin());
renderWithSignal();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
const settingsTrigger = await screen.findByTestId(
SETTINGS_CONTAINER_TEST_ID,
);
expect(settingsTrigger).not.toHaveAttribute('data-denied-permissions');
await userEvent.click(settingsTrigger);
await expect(
screen.findByText(/ADDED FILTERS/i),
).resolves.toBeInTheDocument();
});
});
describe('static config pages (no signal)', () => {
it('is not gated even when every permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderStaticConfig();
await expect(
screen.findByText(FILTER_SERVICE_NAME),
).resolves.toBeInTheDocument();
expect(screen.queryByText(NOT_AUTHORIZED_TEXT)).not.toBeInTheDocument();
});
});
describe('permission check loading', () => {
it('shows the skeleton, not the filters or a denial', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
renderWithSignal();
await waitFor(() =>
// eslint-disable-next-line testing-library/no-node-access
expect(
document.querySelector('.quick-filters-skeleton'),
).toBeInTheDocument(),
);
expect(screen.queryByText(NOT_AUTHORIZED_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(FILTER_SERVICE_NAME)).not.toBeInTheDocument();
});
});
});

View File

@@ -54,6 +54,7 @@ export interface IQuickFiltersProps {
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
/** Pass to fetch quick filters for this signal; omit to use `config` as-is */
signal?: SignalType;
className?: string;
showFilterCollapse?: boolean;

View File

@@ -12,6 +12,8 @@ export enum LOCALSTORAGE {
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
@@ -31,7 +33,6 @@ export enum LOCALSTORAGE {
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
FUNNEL_STEPS = 'FUNNEL_STEPS',
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',

View File

@@ -106,8 +106,8 @@ export const REACT_QUERY_KEY = {
// Dashboard Grid Card Query Keys
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
// Fields Selector Query Keys
GET_FIELDS_SELECTOR_SUGGESTIONS: 'GET_FIELDS_SELECTOR_SUGGESTIONS',
// Field Keys Suggestion Query Keys
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
// AI Assistant Query Keys
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',

View File

@@ -1,10 +1,12 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import { FieldKeysConfigProp } from 'api/querySuggestions/types';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
@@ -14,7 +16,10 @@ function TraceExplorerControls({
totalCount,
perPageOptions,
config,
showSizeChanger = true,
fieldKeysConfig,
builderQueryType,
extraFields,
requiredFields,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
@@ -44,6 +49,10 @@ function TraceExplorerControls({
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
fieldKeysConfig={fieldKeysConfig}
builderQueryType={builderQueryType}
extraFields={extraFields}
requiredFields={requiredFields}
/>
</>
)}
@@ -57,26 +66,28 @@ function TraceExplorerControls({
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
/>
</div>
);
}
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
fieldKeysConfig?: FieldKeysConfigProp;
builderQueryType?: BuilderQueryType;
extraFields?: TelemetryFieldKey[];
requiredFields?: readonly string[];
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
config: null,
fieldKeysConfig: undefined,
builderQueryType: undefined,
extraFields: undefined,
requiredFields: undefined,
};
export default memo(TraceExplorerControls);

View File

@@ -10,6 +10,25 @@
.actionsContainer {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
}
.orderByContainer {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.orderByLabel {
color: var(--muted-foreground);
// Between --periscope-font-size-small (11px) and -base (13px), so literal.
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -3,35 +3,45 @@ import {
memo,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { getTraceLink } from '../ListView/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import TraceExplorerControls from '../Controls';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_DEFAULT_ORDER_BY,
TRACE_VIEW_FIELD_KEYS,
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from '../constants';
import { getListViewQuery } from '../explorerUtils';
import { columns, PER_PAGE_OPTIONS } from './configs';
import { PER_PAGE_OPTIONS } from './configs';
import { useTraceViewColumns } from './useTraceViewColumns';
import styles from './TracesView.module.scss';
interface TracesViewProps {
@@ -49,6 +59,16 @@ function TracesView({
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
const {
columns,
selectedFields,
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
} = useTraceViewColumns();
const {
selectedTime: globalSelectedTime,
maxTime,
@@ -60,8 +80,8 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);
const queryKey = useMemo(
@@ -73,6 +93,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
[
globalSelectedTime,
@@ -81,6 +102,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
);
@@ -142,27 +164,43 @@ function TracesView({
}
}, [isLoading, isFetching, isError, rows.length]);
const handleOrderChange = useCallback((value: string): void => {
setOrderBy(value);
}, []);
const fieldsSelectorConfig = useMemo(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
<Typography>
This tab only shows Root Spans. More details
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
{' '}
here
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<div className={styles.orderByContainer}>
<div className={styles.orderByLabel}>
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={handleOrderChange}
dataSource={DataSource.TRACES}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_ORDER_BY_EXTRA_FIELDS}
/>
</div>
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
requiredFields={requiredFields}
/>
</div>
</div>
@@ -170,10 +208,11 @@ function TracesView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading}
isLoading={isLoading || isColumnsLoading}
isFetching={isFetching}
isError={isError}
error={error}

View File

@@ -0,0 +1,219 @@
/* eslint-disable no-restricted-syntax */
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useTraceViewColumns } from '../useTraceViewColumns';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const AGGREGATE_KEYS = [
'llm_call_count',
'tool_call_count',
'distinct_tool_count',
'input_tokens',
'output_tokens',
'total_tokens',
'estimated_total_cost',
'max_llm_duration_nano',
];
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
fields.map((field) => field.name);
const columnNames = (columns: { header?: unknown }[]): string[] =>
columns.map((column) => column.header as string);
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
const seenAI: URLSearchParams[] = [];
const mockAggregateKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
);
},
),
);
};
const renderColumns = async (): Promise<
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
> => {
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
await waitFor(() => {
expect(rendered.result.current.isLoading).toBe(false);
});
return rendered;
};
describe('useTraceViewColumns', () => {
beforeEach(() => {
seenAI.length = 0;
useColumnStore.getState().tables = {};
localStorage.clear();
mockAggregateKeys(AGGREGATE_KEYS);
});
it('reads the aggregates from the trace context of the keys endpoint', async () => {
await renderColumns();
expect(seenAI).toHaveLength(1);
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
});
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'start_time',
'end_time',
'error_count',
'input',
'output',
...AGGREGATE_KEYS,
]);
});
it('selects only the default-visible columns on first render', async () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
it('keeps a newly reported aggregate hidden until it is picked', async () => {
mockAggregateKeys(['brand_new_aggregate']);
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
expect(fieldNames(result.current.selectedFields)).not.toContain(
'brand_new_aggregate',
);
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'trace_id' },
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'total_tokens',
]);
});
it('shows a column added back from the pool', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'input',
]);
});
it('keeps the trace id column even when the selection drops it', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'span_count' }]);
});
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
});
it('persists the selection order', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'total_tokens',
'trace_id',
'service.name',
]);
expect(
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
).toStrictEqual([
'trace:total_tokens:float64',
'trace_id',
'resource:service.name',
]);
});
});

View File

@@ -5,18 +5,29 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name' },
{ name: 'duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
] as TelemetryFieldKey[];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
TRACE_ID_COLUMN_ID,
]);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
enableRemove: false,
canBeHidden: false,
}),
);
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: field.name !== TRACE_ID_COLUMN_ID,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
}));

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
},
TRACE_VIEW_BUILDER_QUERY_TYPE,
);
const availableFields = useMemo(
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isFetched) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isFetched, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns],
);
return {
columns,
selectedFields,
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
};
}

View File

@@ -1,3 +1,6 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const TOOLBAR_VIEWS = {
list: {
name: 'list',
@@ -34,3 +37,29 @@ export const TOOLBAR_VIEWS = {
key: 'clickhouse',
},
};
export const TRACE_VIEW_DEFAULT_ORDER_BY = 'last_activity_time:desc';
/** Display-only: ordering or filtering on one is an error, so the keys endpoint omits them. */
export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'root_span_name' },
{ name: 'trace_duration_nano' },
{ name: 'span_count' },
{ name: 'trace_id' },
{ name: 'start_time' },
{ name: 'end_time' },
{ name: 'error_count' },
{ name: 'input' },
{ name: 'output' },
] as TelemetryFieldKey[];
export const TRACE_VIEW_FIELD_KEYS = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
} as const;
export const TRACE_VIEW_BUILDER_QUERY_TYPE = 'builder_ai_query' as const;
export const TRACE_VIEW_ORDER_BY_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'last_activity_time' } as TelemetryFieldKey,
];

View File

@@ -92,17 +92,25 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: {
data: { keys: {} },
},
jest.mock('api/querySuggestions/getFieldKeySuggestions', () => ({
getFieldKeySuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: { complete: true, keys: {} },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
jest.mock('api/querySuggestions/getFieldValueSuggestions', () => ({
getFieldValueSuggestions: jest.fn().mockResolvedValue({
status: 'success',
data: {
complete: true,
values: {
stringValues: [],
numberValues: [],
boolValues: [],
relatedValues: [],
},
},
}),
}));

View File

@@ -179,7 +179,10 @@ const setupServer = (capturedPayloads: QueryRangePayloadV5[]): void => {
),
// Add handler for the fields endpoint that's causing warnings
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, async (req, res, ctx) =>
res(ctx.status(200), ctx.json([])),
res(
ctx.status(200),
ctx.json({ status: 'success', data: { complete: true, keys: {} } }),
),
),
);
};

View File

@@ -131,7 +131,7 @@ export function AboutSigNozQuestions({
<AntdInput.TextArea
className="discover-signoz-input"
placeholder={`e.g., googling "datadog alternative", a post on r/devops, from a friend/colleague, a LinkedIn post, ChatGPT, etc.`}
placeholder="e.g., I asked ChatGPT for Datadog alternatives, searched Google for “OpenTelemetry tools,” saw a Reddit or LinkedIn post, or heard about it from a colleague."
value={discoverSignoz}
autoFocus
rows={4}

View File

@@ -204,10 +204,12 @@ describe('OnboardingQuestionaire Component', () => {
await user.click(screen.getByRole('button', { name: /next/i }));
await expect(
screen.findByPlaceholderText(/e\.g\., googling/i, {}),
screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i, {}),
).resolves.toBeInTheDocument();
const discoverInput = screen.getByPlaceholderText(/e\.g\., googling/i);
const discoverInput = screen.getByPlaceholderText(
/e\.g\., I asked ChatGPT/i,
);
await user.type(discoverInput, 'Found via Google search');
const interestCheckbox = screen.getByLabelText(
@@ -256,11 +258,11 @@ describe('OnboardingQuestionaire Component', () => {
await user.click(screen.getByRole('button', { name: /next/i }));
await expect(
screen.findByPlaceholderText(/e\.g\., googling/i, {}),
screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i, {}),
).resolves.toBeInTheDocument();
await user.type(
screen.getByPlaceholderText(/e\.g\., googling/i),
screen.getByPlaceholderText(/e\.g\., I asked ChatGPT/i),
'Found via Google',
);
await user.click(screen.getByLabelText(/lowering observability costs/i));
@@ -297,7 +299,7 @@ describe('OnboardingQuestionaire Component', () => {
await user.click(screen.getByRole('button', { name: /next/i }));
await user.type(
await screen.findByPlaceholderText(/e\.g\., googling/i),
await screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i),
'Found via Google',
);
await user.click(screen.getByLabelText(/lowering observability costs/i));
@@ -329,11 +331,11 @@ describe('OnboardingQuestionaire Component', () => {
await user.click(screen.getByRole('button', { name: /next/i }));
await expect(
screen.findByPlaceholderText(/e\.g\., googling/i, {}),
screen.findByPlaceholderText(/e\.g\., I asked ChatGPT/i, {}),
).resolves.toBeInTheDocument();
await user.type(
screen.getByPlaceholderText(/e\.g\., googling/i),
screen.getByPlaceholderText(/e\.g\., I asked ChatGPT/i),
'Found via Google',
);
await user.click(screen.getByLabelText(/lowering observability costs/i));

View File

@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(10);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -337,6 +337,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'quick-filter',
'role',
'serviceaccount',
'subscription',
@@ -421,7 +422,7 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(10);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -429,6 +430,7 @@ describe('createEmptyRolePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'quick-filter',
'role',
'serviceaccount',
'subscription',

View File

@@ -6,6 +6,7 @@ import {
Gauge,
Grid3X3,
Key,
ListFilter,
Logs,
Receipt,
Shield,
@@ -86,6 +87,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
selectorPlaceholder: 'Type * to cover the workspace subscription',
docsAnchor: 'subscription',
},
'quick-filter': {
label: 'Quick Filters',
description: 'Quick filters shown in the logs, traces, and other explorers.',
icon: ListFilter,
selectorPlaceholder:
'Type quick filter ID, separate multiple with comma or space',
docsAnchor: 'quick-filter',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',

View File

@@ -0,0 +1,185 @@
import { QueryClient } from 'react-query';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
getFieldKeysQueryOptions,
toFieldKeys,
} from '../useFieldKeysSuggestion';
/** Drives the options object the way react-query does, without a client. */
const fetchKeys = async (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): Promise<TelemetryFieldKey[]> => {
const { queryFn, select } = getFieldKeysQueryOptions(
fieldKeysConfig,
builderQueryType,
);
const response = await (
queryFn as (context: { signal: AbortSignal }) => Promise<FieldKeysResponse>
)({ signal: new AbortController().signal });
return select?.(response) ?? [];
};
const mockKeys = (
path: '/api/v1/ai_observability/fields/keys' | '/api/v1/fields/keys',
names: string[],
onRequest?: (params: URLSearchParams) => void,
): void => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}${path}`, (req, res, ctx) => {
onRequest?.(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(names.map((name) => [name, [{ name }]])),
},
}),
);
}),
);
};
describe('useFieldKeysSuggestion', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const keys = await fetchKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: 'llm',
fieldContext: TelemetrytypesFieldContextDTO.trace,
},
'builder_ai_query',
);
expect(seen).toHaveLength(1);
expect(seen[0]?.get('searchText')).toBe('llm');
expect(seen[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
});
it('reads the generic endpoint for an unmarked query', async () => {
const seen: URLSearchParams[] = [];
mockKeys('/api/v1/fields/keys', ['service.name'], (params) => {
seen.push(params);
});
const keys = await fetchKeys({
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: 'svc',
});
expect(seen).toHaveLength(1);
expect(seen[0]?.get('signal')).toBe(DataSource.TRACES);
expect(seen[0]?.get('searchText')).toBe('svc');
expect(keys.map((key) => key.name)).toStrictEqual(['service.name']);
});
it('reads the trace context of the ai_observability endpoint', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const keys = await fetchKeys(
{
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
fieldContext: TelemetrytypesFieldContextDTO.trace,
},
'builder_ai_query',
);
expect(seen[0]?.get('searchText')).toBe('');
expect(keys.map((key) => key.name)).toStrictEqual(['total_tokens']);
});
it('reuses the cached keys response for a second empty search', async () => {
const seen: URLSearchParams[] = [];
mockKeys(
'/api/v1/ai_observability/fields/keys',
['total_tokens'],
(params) => {
seen.push(params);
},
);
const fieldKeysConfig = {
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
fieldContext: TelemetrytypesFieldContextDTO.trace,
};
// Built twice: equal keys must resolve to one cache entry, not two requests.
await queryClient.fetchQuery(
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
);
await queryClient.fetchQuery(
getFieldKeysQueryOptions(fieldKeysConfig, 'builder_ai_query'),
);
expect(seen).toHaveLength(1);
});
it('hands the query signal to the fetcher so a superseded search aborts', async () => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_req, res, ctx) =>
res(ctx.delay(500), ctx.status(200), ctx.json({ status: 'success' })),
),
);
const controller = new AbortController();
const { queryFn } = getFieldKeysQueryOptions({
signal: DATA_SOURCE_TO_SIGNAL[DataSource.LOGS],
searchText: 'svc',
});
const pending = (
queryFn as (context: { signal: AbortSignal }) => Promise<unknown>
)({ signal: controller.signal });
controller.abort();
await expect(pending).rejects.toBeDefined();
});
it('treats a null keys map as empty', () => {
expect(
toFieldKeys({
status: 'success',
data: { complete: false, keys: null },
}),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,50 @@
import {
QueryKey,
useQuery,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { getFieldKeySuggestions } from 'api/querySuggestions/getFieldKeySuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { FieldKeysConfig, FieldKeysResponse } from 'api/querySuggestions/types';
import { BuilderQueryType, TelemetryFieldKey } from 'types/api/v5/queryRange';
export type FieldKeysQueryOptions = UseQueryOptions<
FieldKeysResponse,
ErrorType<RenderErrorResponseDTO>,
TelemetryFieldKey[]
> & { queryKey: QueryKey };
export const toFieldKeys = (
res: FieldKeysResponse | undefined,
): TelemetryFieldKey[] =>
Object.values(res?.data?.keys ?? {})
.flat()
.map((key) => ({ ...key }) as TelemetryFieldKey);
export const getFieldKeysQueryOptions = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): FieldKeysQueryOptions => ({
queryKey: [
REACT_QUERY_KEY.FIELD_KEYS_SUGGESTION,
builderQueryType,
fieldKeysConfig,
],
queryFn: ({ signal }): Promise<FieldKeysResponse> =>
getFieldKeySuggestions(fieldKeysConfig, builderQueryType, signal),
select: toFieldKeys,
staleTime: FIELD_API_CACHE_TIME,
cacheTime: FIELD_API_CACHE_TIME,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
export const useFieldKeysSuggestion = (
fieldKeysConfig: FieldKeysConfig,
builderQueryType?: BuilderQueryType,
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));

View File

@@ -18,6 +18,11 @@ export default {
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'quick-filter',
type: 'metaresource',
allowedVerbs: ['list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',

View File

@@ -0,0 +1,16 @@
import { buildPermission } from '../utils';
export const QuickFilterReadPermission = buildPermission(
'read',
'quick-filter:*',
);
export const QuickFilterUpdatePermission = buildPermission(
'update',
'quick-filter:*',
);
// Editing quick filters needs read as well as update
export const QuickFilterManagePermissions = [
QuickFilterReadPermission,
QuickFilterUpdatePermission,
];

View File

@@ -0,0 +1,57 @@
@use '../../../../styles/scrollbar' as *;
.container {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
// Let the flex children shrink below their content height so the series list
// scrolls within the capped legend height instead of overflowing the wrapper
// (the default min-height:auto would block the shrink).
min-height: 0;
}
.scroller {
// flex:1 + min-height:0 pins the scroller to the space left after the
// toolbar instead of growing to fit every row.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
padding-right: var(--spacing-2);
overflow-x: hidden;
overscroll-behavior: contain;
@include custom-scrollbar;
}
.gridItem {
// Or the item keeps its content width and the label never ellipsizes.
min-width: 0;
max-width: 100%;
}
.gridList {
min-width: 0;
display: grid;
grid-auto-flow: row;
// min() keeps the column inside a narrow panel, where a wider one would push
// the row's actions out of the clipped area.
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
);
gap: var(--spacing-1) var(--spacing-4);
}
.container.isRight .gridList {
grid-template-columns: 1fr;
}
.emptyState {
padding: var(--spacing-16) 0;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
text-align: center;
}

View File

@@ -1,204 +0,0 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
padding-right: 8px;
.legend-search-input {
font-size: 12px;
}
}
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
.legend-empty-state {
font-size: 12px;
color: var(--l2-foreground);
text-align: center;
padding: 12px;
padding: 2rem 0;
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
.virtuoso-grid-list {
min-width: 0;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
column-gap: 12px;
}
.virtuoso-grid-item {
min-width: 0;
}
&.legend-virtuoso-container-right {
.virtuoso-grid-list {
grid-template-columns: 1fr;
}
}
&.legend-virtuoso-container-single-row {
.virtuoso-grid-list {
grid-template-columns: repeat(
auto-fit,
minmax(var(--legend-average-width, 240px), max-content)
);
justify-content: center;
}
}
@include custom-scrollbar;
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
&.legend-item-right {
width: 100%;
}
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-item-label-trigger {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.legend-marker {
border-width: 2px;
border-style: solid;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
user-select: none;
}
.legend-copy-button {
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
// Shrink the shared icon Button (defaults to a 2rem square) to the
// compact legend row via its size tokens.
--button-height: auto;
--button-width: auto;
--button-padding: 2px;
opacity: 0;
flex-shrink: 0;
color: var(--l2-foreground);
border-radius: 4px;
transition:
opacity 0.15s ease,
color 0.15s ease;
&:hover {
color: var(--l1-foreground);
}
}
&:hover {
background: var(--l3-background);
.legend-copy-button {
opacity: 1;
}
}
}

View File

@@ -1,139 +1,106 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendPosition, LegendProps } from '../types';
import { LegendAction, LegendPosition, LegendProps } from '../types';
import './Legend.styles.scss';
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
import LegendRow from './LegendRow';
import LegendToolbar from './LegendToolbar';
import { filterLegendItems, getShownSeriesState } from './utils';
export const MAX_LEGEND_WIDTH = 240;
import styles from './Legend.module.scss';
/**
* Presentational legend. Renders the supplied `items` (markers + labels, an
* optional copy button, and a search box for the RIGHT position) and delegates
* all interaction to the container handlers. Source-agnostic — the uPlot
* charts feed it via UPlotLegend; Pie feeds it directly.
* Presentational legend, source-agnostic: the uPlot charts feed it via
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
*/
export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex,
onClick,
onMouseMove,
onMouseLeave,
onAction,
showCopy = true,
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const [filterQuery, setFilterQuery] = useState('');
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const { width: containerWidth } = useResizeObserver(legendContainerRef);
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
const isRightPosition = position === LegendPosition.RIGHT;
const isSingleRow = useMemo(() => {
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
return false;
}
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, items.length, position, containerWidth]);
const visibleLegendItems = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return items;
}
const query = legendSearchQuery.trim().toLowerCase();
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => {
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
return (
<div
key={item.seriesIndex}
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: markerColor }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</TooltipSimple>
{showCopy && (
<CopyButton
value={item.label ?? ''}
size={12}
className="legend-copy-button"
ariaLabel={`Copy ${item.label}`}
testId="legend-copy"
/>
)}
</div>
);
},
[focusedSeriesIndex, position, showCopy],
const { visibleCount, soleShownSeriesIndex } = useMemo(
() => getShownSeriesState(items),
[items],
);
const isEmptyState = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return false;
}
return visibleLegendItems.length === 0;
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
// A bottom legend gets two rows; spending one on chrome costs more chart than
// the readout is worth.
const showToolbar = isRightPosition && items.length > 0;
const showFilter = showToolbar;
const effectiveQuery = showFilter ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
[items, effectiveQuery],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
const isAllShown = visibleCount === items.length;
// A row that unmounts under the pointer never fires its own mouseleave.
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<LegendRow
key={item.seriesIndex}
item={item}
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
isAllShown={isAllShown}
isFocused={focusedSeriesIndex === item.seriesIndex}
showCopy={showCopy}
onAction={onAction}
/>
),
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
className={cx(styles.container, {
[styles.isRight]: isRightPosition,
})}
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
onMouseLeave={handleMouseLeave}
data-testid="legend-container"
>
{searchEnabled && (
<div className="legend-search-container">
<Input
allowClear
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
data-testid="legend-search-input"
className="legend-search-input"
/>
</div>
{showToolbar && (
<LegendToolbar
visibleCount={visibleCount}
totalCount={items.length}
showFilter={showFilter}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>
)}
{isEmptyState ? (
<div className="legend-empty-state">
No series found matching &quot;{legendSearchQuery}&quot;
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
</div>
) : (
<VirtuosoGrid
className={cx(
'legend-virtuoso-container',
`legend-virtuoso-container-${position.toLowerCase()}`,
{ 'legend-virtuoso-container-single-row': isSingleRow },
)}
className={styles.scroller}
listClassName={styles.gridList}
itemClassName={styles.gridItem}
data={visibleLegendItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>

View File

@@ -0,0 +1,170 @@
.row {
// Width of the revealed actions, given up by the label on hover only.
--legend-actions-reserve: 78px;
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
height: 28px;
padding: 0 var(--spacing-3) 0 var(--spacing-4);
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
border-radius: var(--radius);
cursor: pointer;
transition: background 160ms linear;
&:hover,
&:focus-visible {
background: var(--l3-background);
}
}
.isFocused {
background: var(--l3-background);
}
.marker {
// Reads as a checkbox without being one: filled when shown, hollow when
// hidden, deliberately not a check glyph.
flex: 0 0 auto;
box-sizing: border-box;
position: relative;
// Above the actions, so a narrow row's chip never covers the series colour.
z-index: 4;
width: 12px;
height: 12px;
padding: 0;
appearance: none;
border-width: 1.5px;
border-style: solid;
border-radius: var(--radius);
cursor: pointer;
transition:
transform 200ms ease,
box-shadow 200ms ease,
background-color 160ms linear,
opacity 160ms linear;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
}
&:active {
transform: scale(0.9);
}
&:disabled {
cursor: default;
}
&:disabled:hover {
transform: none;
box-shadow: none;
}
}
// Series names run long and have no spaces to break on, so they need both a
// cap and a break rule or the tooltip becomes one panel-wide line.
.rowTooltip {
max-width: 420px;
white-space: normal;
overflow-wrap: anywhere;
}
.label {
flex: 1 1 auto;
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
user-select: none;
}
.isHidden .marker {
opacity: 0.45;
}
.isHidden .label {
color: var(--l3-foreground);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* Row actions */
.actions {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-3);
z-index: 3;
display: flex;
align-items: center;
gap: var(--spacing-2);
padding-left: var(--spacing-5, 10px);
// Sits on the row's hover background and masks the label's tail behind it.
background: var(--l3-background);
box-shadow: -8px 0 8px var(--l3-background);
opacity: 0;
transform: translateX(10px);
pointer-events: none;
transition:
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
}
// :focus-visible, not :focus-within — the latter also matches the click that
// just toggled the series, leaving the actions stuck open.
.row:hover .actions,
.row:focus-visible .actions,
.row:has(:focus-visible) .actions {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
// The cap spares rows sized to their actions, not their label.
.row:hover .label,
.row:focus-visible .label,
.row:has(:focus-visible) .label {
padding-right: min(var(--legend-actions-reserve), 50%);
}
.actionTrigger {
display: inline-flex;
}
.actionButton {
--button-height: 20px;
--button-width: 20px;
--button-padding: 0;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
flex-shrink: 0;
}
.actionButton.scopeButton {
--button-width: auto;
--button-padding: 0 var(--spacing-4);
--button-font-size: var(--font-size-xs);
--button-border-radius: calc(var(--radius) * 4);
--button-base-border-width: 1px;
// --l2-border is the actions bar's own background: one step up reads.
--button-base-border-color: var(--l3-border);
border-style: solid;
}
.actionButton.scopeButton:hover {
border-color: var(--l2-border);
}

View File

@@ -0,0 +1,182 @@
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendAction, OnLegendAction } from '../types';
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
import styles from './LegendRow.module.scss';
export interface LegendRowProps {
item: LegendItem;
/** The only series currently shown, so hiding it is refused. */
isSoleShown: boolean;
/** Nothing is hidden, so the row's action can only narrow the selection. */
isAllShown: boolean;
isFocused: boolean;
showCopy: boolean;
onAction: OnLegendAction;
}
/**
* One legend row. The marker is its own target for excluding a single series —
* the one thing the row click can't do while everything is showing. The actions
* overlay the label's tail rather than taking layout width, and their reveal is
* pure CSS.
*/
function LegendRow({
item,
isSoleShown,
isAllShown,
isFocused,
showCopy,
onAction,
}: LegendRowProps): JSX.Element {
const { seriesIndex, show } = item;
const label = item.label ?? '';
const isShowAllAction = show && !isAllShown;
const scopeActionLabel = isShowAllAction
? 'Show all series'
: 'Show only current series';
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
// a usable CSS colour for the marker.
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
/** Everything showing -> isolate; showing alone -> restore all. */
const handleRowClick = useCallback((): void => {
if (isSoleShown) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
seriesIndex,
});
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
const handleMarkerClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
onAction({ type: LegendAction.TOGGLE, seriesIndex });
},
[onAction, seriesIndex],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
// Let the row actions handle their own keys.
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleRowClick();
}
},
[handleRowClick],
);
const handleScopeClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
if (isShowAllAction) {
onAction({ type: LegendAction.SHOW_ALL });
return;
}
onAction({ type: LegendAction.SHOW_ONLY, seriesIndex });
},
[isShowAllAction, onAction, seriesIndex],
);
const handleMouseEnter = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex }),
[onAction, seriesIndex],
);
const handleMouseLeave = useCallback(
(): void => onAction({ type: LegendAction.HOVER, seriesIndex: null }),
[onAction],
);
return (
<div
className={cx(styles.row, {
[styles.isHidden]: !show,
[styles.isFocused]: isFocused,
})}
data-legend-item-id={seriesIndex}
data-testid={`legend-item-${seriesIndex}`}
role="switch"
tabIndex={0}
aria-checked={show}
aria-label={label}
onClick={handleRowClick}
onKeyDown={handleKeyDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
className={styles.marker}
style={{
borderColor: seriesColor,
backgroundColor: show ? seriesColor : 'transparent',
}}
onClick={handleMarkerClick}
disabled={isSoleShown}
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
data-is-legend-marker={true}
data-testid={`legend-marker-${seriesIndex}`}
/>
<TooltipSimple
title={label}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
<span className={styles.label}>{label}</span>
</TooltipSimple>
<div className={styles.actions}>
<TooltipSimple
title={scopeActionLabel}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
{/* Radix's asChild merge strips the button's own data-testid. */}
<span className={styles.actionTrigger}>
<Button
variant="ghost"
color="secondary"
size="sm"
className={cx(styles.actionButton, styles.scopeButton)}
onClick={handleScopeClick}
aria-label={scopeActionLabel}
testId={`legend-scope-${seriesIndex}`}
>
{isShowAllAction ? 'All' : 'Only'}
</Button>
</span>
</TooltipSimple>
{showCopy && (
<CopyButton
value={label}
size={13}
className={styles.actionButton}
ariaLabel={`Copy ${label}`}
testId={`legend-copy-${seriesIndex}`}
/>
)}
</div>
</div>
);
}
export default memo(LegendRow);

View File

@@ -0,0 +1,35 @@
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-5, 10px);
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
flex-shrink: 0;
> * {
flex: 0 1 auto;
}
}
.status {
// Wraps rather than losing the count at its end.
min-width: 0;
font-family: var(--font-mono);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.searchContainer {
flex-shrink: 0;
width: 100%;
padding-right: var(--spacing-4);
padding-bottom: var(--spacing-5, 10px);
}
.searchInput {
font-size: var(--font-size-xs);
}
.searchIcon {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,56 @@
import { ChangeEvent, useCallback } from 'react';
import { Input } from 'antd';
import { Search } from '@signozhq/icons';
import styles from './LegendToolbar.module.scss';
export interface LegendToolbarProps {
visibleCount: number;
totalCount: number;
/** Search is intrinsic to the right-positioned legend. */
showFilter: boolean;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
}
/** Legend chrome: the series search box and the "Showing N of M" readout. */
export default function LegendToolbar({
visibleCount,
totalCount,
showFilter,
filterQuery,
onFilterQueryChange,
}: LegendToolbarProps): JSX.Element {
const handleFilterChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void =>
onFilterQueryChange(event.target.value),
[onFilterQueryChange],
);
return (
<>
{showFilter && (
<div className={styles.searchContainer}>
<Input
allowClear
prefix={<Search size={12} className={styles.searchIcon} />}
placeholder="Search..."
value={filterQuery}
onChange={handleFilterChange}
className={styles.searchInput}
data-testid="legend-search-input"
/>
</div>
)}
<div className={styles.toolbar}>
<span
className={styles.status}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
</div>
</>
);
}

View File

@@ -8,8 +8,8 @@ import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the toggle/focus interactions from
* the plot context (useLegendActions), then renders the presentational Legend.
* from the chart config (useLegendsSync) and the series interactions from the
* plot context (useLegendActions), then renders the presentational Legend.
* Must be rendered inside a PlotContextProvider.
*/
export default function UPlotLegend({
@@ -17,13 +17,8 @@ export default function UPlotLegend({
config,
averageLegendWidth,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
const onAction = useLegendActions();
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
@@ -33,9 +28,7 @@ export default function UPlotLegend({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
onAction={onAction}
/>
);
}

View File

@@ -0,0 +1,419 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../../hooks/useLegendActions';
import UPlotLegend from '../UPlotLegend';
import { LegendAction, LegendActionPayload, LegendPosition } from '../../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
/** The payloads of one action type, in dispatch order. */
const dispatched = (
onAction: jest.Mock,
type: LegendAction,
): LegendActionPayload[] =>
onAction.mock.calls
.map(([payload]) => payload as LegendActionPayload)
.filter((payload) => payload.type === type);
describe('UPlotLegend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onAction: jest.Mock;
beforeEach(() => {
onAction = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue(onAction);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<TooltipProvider>
<UPlotLegend
position={position}
// config is consumed by the mocked useLegendsSync hook, not directly
config={{} as any}
/>
</TooltipProvider>,
);
describe('layout and position', () => {
it('renders the search input on a RIGHT legend', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
// The row interactions are the same in both placements.
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
});
it('renders the marker with the series colour, filled only when shown', () => {
renderLegend(LegendPosition.RIGHT);
expect(
document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
),
).toHaveStyle({
'border-color': '#ff0000',
'background-color': '#ff0000',
});
// Hidden series read as an empty checkbox.
expect(
document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
),
).toHaveStyle({ 'background-color': 'transparent' });
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('status readout', () => {
it('reports how many series are showing', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 2 of 3 series',
);
});
});
describe('filter behavior', () => {
it('filters legend items based on the query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'a');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows the empty state when nothing matches', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('ignores a whitespace-only query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.type(screen.getByTestId('legend-search-input'), ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('row interactions', () => {
const allShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1], show: true },
2: { ...baseLegendItemsMap[2] },
};
const mockAllShown = (): void => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: allShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
};
it('isolates the series when everything is showing', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
// Nothing the user can see is there to exclude, so the click means Only.
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('toggles the series once something is already hidden', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('excludes just that series when its marker is clicked', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-marker-0'));
// The marker is the one way to exclude a single series while
// everything is showing — the row click isolates instead.
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('stops the marker offering to hide the last series showing', () => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
});
it('labels the marker with what clicking it does', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
'aria-label',
'Hide A',
);
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
'aria-label',
'Show B',
);
});
it('adds the clicked series to the selection while one is alone', async () => {
const user = userEvent.setup();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
// Series 0 is showing alone; clicking another row builds the selection
// up rather than moving the isolation.
await user.click(screen.getByText('B'));
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('toggles the series on Enter and Space', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
row.focus();
await user.keyboard('{Enter}');
await user.keyboard(' ');
expect(dispatched(onAction, LegendAction.TOGGLE)).toStrictEqual([
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
{ type: LegendAction.TOGGLE, seriesIndex: 0 },
]);
});
it('reflects visibility on the row for assistive tech', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
'aria-checked',
'true',
);
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
'aria-checked',
'false',
);
});
it('restores every series from All without also toggling the row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
// Series 0 is shown while B is hidden, so its action is All.
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
it('isolates the series from Only on a hidden row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-1'));
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toStrictEqual([
{ type: LegendAction.SHOW_ONLY, seriesIndex: 1 },
]);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
});
it('highlights the hovered series and clears it on leave', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
await user.hover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: 0,
});
await user.unhover(row);
expect(onAction).toHaveBeenCalledWith({
type: LegendAction.HOVER,
seriesIndex: null,
});
});
});
describe('one-series state', () => {
const soleShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
};
beforeEach(() => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: soleShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
});
it('offers All on the shown row and Only on the hidden ones', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
});
it('restores everything from All', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-0'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
});
it('restores everything when the row showing alone is clicked again', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(dispatched(onAction, LegendAction.SHOW_ALL)).toHaveLength(1);
expect(dispatched(onAction, LegendAction.TOGGLE)).toHaveLength(0);
expect(dispatched(onAction, LegendAction.SHOW_ONLY)).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,45 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import { filterLegendItems, getShownSeriesState } from '../utils';
const items = (shown: boolean[]): LegendItem[] =>
shown.map((show, index) => ({
seriesIndex: index + 1,
label: `series-${index}`,
color: '#000',
show,
}));
describe('getShownSeriesState', () => {
it('counts the shown series', () => {
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
visibleCount: 2,
soleShownSeriesIndex: null,
});
});
it('names the series when exactly one is shown', () => {
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
visibleCount: 1,
soleShownSeriesIndex: 2,
});
});
it('reports nothing shown', () => {
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
visibleCount: 0,
soleShownSeriesIndex: null,
});
});
});
describe('filterLegendItems', () => {
it('matches case-insensitively on the label', () => {
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
});
it('returns every item for a blank query', () => {
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,20 @@
/** Widest a single legend item is allowed to get when sizing the legend grid. */
export const MAX_LEGEND_WIDTH = 240;
/**
* Enough for a row to contain its own hover actions, which a short label would
* otherwise size a column too narrow for. Little room for the label is intended.
*/
export const MIN_LEGEND_ITEM_WIDTH = 110;
/** Marker + row padding, on top of the estimated label width. */
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
/** Must match `.row`'s height and the grid's row gap, or the reserved
* rectangle clips a row. */
export const LEGEND_ROW_HEIGHT = 28;
export const LEGEND_ROW_GAP = 2;
export const LEGEND_MAX_BOTTOM_ROWS = 2;
/** Hover delay before a row's full-name tooltip opens. */
export const LEGEND_TOOLTIP_DELAY_MS = 500;

View File

@@ -0,0 +1,34 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
export interface ShownSeriesState {
visibleCount: number;
/** The series index when exactly one series is shown, else null. */
soleShownSeriesIndex: number | null;
}
/**
* Driven by what is actually shown, never a remembered isolation: hiding series
* one at a time down to a single one is the same state as "Only".
*/
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
const shown = items.filter((item) => item.show);
return {
visibleCount: shown.length,
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
};
}
export function filterLegendItems(
items: LegendItem[],
query: string,
): LegendItem[] {
const normalisedQuery = query.trim().toLowerCase();
if (!normalisedQuery) {
return items;
}
return items.filter((item) =>
item.label?.toLowerCase().includes(normalisedQuery),
);
}

View File

@@ -12,10 +12,11 @@
}
}
// Matches the legend row's marker.
.uplotTooltipItemMarker {
border-radius: 50%;
border-radius: var(--radius);
border-style: solid;
border-width: 2px;
border-width: 1.5px;
width: 12px;
height: 12px;
box-sizing: border-box;
@@ -30,11 +31,23 @@
justify-content: space-between;
}
// The legend's mono type; the container's Inter stays for the header.
.uplotTooltipItemLabel,
.uplotTooltipItemValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.uplotTooltipItemLabel {
white-space: normal;
overflow-wrap: anywhere;
}
.uplotTooltipItemValue {
white-space: nowrap;
}
.uplotTooltipItemContentSeparator {
flex: 1;
border-width: 0.5px;

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