Compare commits

..

14 Commits

Author SHA1 Message Date
nityanandagohain
d1682f2ab6 feat: span mapper test endpoint 2026-06-19 18:31:31 +05:30
Vinicius Lourenço
b8567664da refactor(quick-filters): split checkbox into multiple files (#11768)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* refactor(quick-filters): extract checkbox value fetching into hook

* refactor(quick-filters): extract checkbox query algebra into pure module

* refactor(quick-filters): extract checkbox glue hooks

* refactor(quick-filters): split checkbox renderers into components
2026-06-18 15:20:16 +00:00
Vinicius Lourenço
643aac4424 fix(alerts): missing fields when duplicating via edit alert (#11767) 2026-06-18 13:30:08 +00:00
Srikanth Chekuri
2cf7ef93ea chore: send warning instead of error for unseen metrics and missing (… (#11754)
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
Release Drafter / update_release_draft (push) Has been cancelled
* chore: send warning instead of error for unseen metrics and missing (metric, key)

* chore: update integration test

* chore: fix integration test

* chore: fix test

* chore: add unit test for missing key
2026-06-18 10:42:09 +00:00
Nikhil Mantri
dba827ee33 feat(infra-monitoring): namespace+cluster group by for PVC monitoring, cluster group by for namespace monitoring (#11739)
* chore: deployments -> add default namespace group by

* chore: added integration tests for statefulsets

* chore: namespace group by for jobs

* chore: namespace group by for daemonsets

* chore: added group by clustername for all workloads and integration tests for the same

* chore: fix py fmt for integration tests

* chore: added group by namespace, cluster for pvcs

* chore: added cluster name default group by for namespaces monitoring
2026-06-18 09:14:42 +00:00
Ashwin Bhatkal
467a556062 feat(dashboard-v2): redesign public dashboard publish drawer (#11748)
* feat(dashboard-v2): redesign public dashboard publish drawer

Rework the Publish tab to the status-strip design (Claude Design handoff):
- a status strip with a lock/globe medallion, plain-language line and a
  Private/Public badge
- a public-link field shown in both states — a dashed placeholder while
  private, the live URL with copy / open actions once published
- an "Enable time range" switch + default-range select, and a quiet inline
  variables caveat
- actions grouped in a footer (Publish / Unpublish + Update)

Split each piece into its own folder with a co-located *.module.scss, drop the
dead time-range constants in favour of the shared RelativeDurationOptions, and
render the range dropdown without a portal (z-index + trigger width) so it shows
correctly inside the settings drawer.

* feat(dashboard-v2): fetch public dashboard meta once, globally

Move the public-sharing GET out of the publish drawer: a shared
usePublicDashboardMeta hook (keyed by dashboard id, license-gated, kept warm via
staleTime) owns the request, the toolbar mounts it with the dashboard to drive the
public-access badge, and the drawer's usePublicDashboard reads the same cache
instead of issuing its own call. Mutations invalidate the key so all consumers
refresh together.

Also rename the variables Callout to Hint, and drop redundant font-family: Inter /
font-weight: 400 from the publish-drawer styles (Inter is the inherited default).
2026-06-18 07:10:26 +00:00
primus-bot[bot]
a8f6b8187e chore(release): bump to v0.129.0 (#11773)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-06-18 07:02:37 +00:00
Swapnil Nakade
03796f012f chore: bumping agent version to v0.0.13 (#11757)
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
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-17 12:05:28 +00:00
Abhi kumar
a06900bbff chore: added fix for infinite query call on services page (#11755) 2026-06-17 11:17:17 +00:00
Nikhil Soni
b50933d622 chore(trace-details): remove flamegraph v2 API (#11629)
* chore: remove flamegraph v2 since we've moved to v4 now

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove orphaned helpers after v2 waterfall and flamegraph removal

Drop GetSpansForTrace, cacheForTraceDetail, fluxIntervalForTraceDetail from
clickhouseReader — all were only used by the deleted v2 handlers. Also remove
the orphaned model.Span and model.Event types, and clean up the
cacheForTraceDetail initialization from server.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update ee/query-service NewReader call after v2 cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: format whitespacing

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 10:24:24 +00:00
Abhi kumar
8bf649a3a5 feat(dashboards-v2): panel rendering system (pure-V5 data path, renderers, panel chrome) (#11639)
* feat(dashboards-v2): panel type system & shared chart utilities

* feat(dashboards-v2): usePanelQuery data-fetching hook

* feat(dashboards-v2): TimeSeriesPanel renderer

* feat(dashboards-v2): BarChartPanel renderer

* feat(dashboards-v2): HistogramPanel renderer

* feat(dashboards-v2): PieChartPanel renderer

* feat(dashboards-v2): panel plugin registry

* feat(dashboards-v2): panel status popover (error/warning surfacing)

* feat(dashboards-v2): panel chrome - header, body, interactions & grid wiring

* chore(dashboards-v2): dashboards-list-v2 query-param sort/order adjustments

* feat(dashboards-v2): number panel kind + registry entry

* fix(dashboards-v2): cap bottom legend height at 30% of the panel

The bottom-legend reservation was capped only at min(2 rows, 80px) with
no relation to the container height, despite the helper's doc promising a
%-of-container cap. On short grid panels the legend kept its full slice
and the chart — the pie donut especially — collapsed to a sliver.

- calculateChartDimensions now also caps the bottom legend at 30% of the
  container height (mirroring the RIGHT-legend width cap).
- Drop the legend grid row-gap so the capped height isn't overrun.
- Add the previously-missing tests for calculateChartDimensions,
  including the short-container cap.

echo "--- commit 1 done ---" && git log --oneline -1
echo "--- remaining ---" && git status --short

* fix(dashboards-v2): prevent pie donut and labels from clipping

The donut used a flat radius = 0.35 * min(w,h) with leader labels anchored
at 1.3 * radius, leaving only ~0.045 * size between the label anchor and
the SVG edge — so on small panels the top/bottom labels (and a stray
container padding under border-box) clipped.

- getDonutGeometry now solves the radius back from the box half-extent
  minus a fixed label allowance, so the label anchor always lands a
  constant 22px inside the edge regardless of panel size — extracted as a
  pure, tested helper with a shared label-ratio constant (no drift with
  getArcGeometry) and a DonutGeometry type.
- Align the pie legend wrapper padding with ChartLayout's.
- Tests for getDonutGeometry (anchor stays in-box, derived radii, smaller
  axis wins, no negative radius).

echo "--- commit 2 done ---" && git log --oneline -3
echo "--- remaining ---" && git status --short

* refactor(dashboards-v2): address PR review feedback

Tightening + structure changes from review of #11639:

- types: make rendererProps `panel`/`data` required and `syncMode`
  non-optional (None is the off-state); `Panel.panel` required with
  orphan layout items guarded in SectionGrid; key the panel interaction
  map by PanelKind; require `requestPayload` in useGetQueryRangeV5.
- panel chrome: split the unknown-kind path into UnsupportedPanelBody so
  PanelBody only runs with a resolved renderer; rename chartBody ->
  chartContainer; move PanelHeader/PanelBody into their own folders with
  dedicated scss (Panel.module.scss trimmed to .panel).
- panel status: normalize query failures via convertToApiError (the
  generated client rejects with a raw AxiosError, so the old isAPIError
  guard never matched and dropped the backend code/message); drop an
  unnecessary useMemo; docs link -> Typography.Link.
- registry: PascalCase aliases; Panels/index.ts -> registry.ts.
- utils: extract selectionPreferences out of baseConfigBuilder; split
  chartAppearanceMappings into chartAppearance/{enumMaps,resolvers};
  move each kind's buildConfig under utils/.
- renderers: render NoData on an empty result set.
- dashboards-list: drop redundant sort/order casts.

echo "=== result ==="; git log --oneline -3; echo "--- working tree ---"; git status --short

* refactor(dashboards-v2): use @signozhq/ui tooltip for panel status

Replace the antd Popover in the panel header status indicator with
@signozhq/ui TooltipSimple. Relies on the global TooltipProvider mounted
in AppLayout; the PanelHeader test wraps renders in TooltipProvider since
the radix tooltip needs that ancestor in isolation.

* refactor(dashboards-v2): derive PanelKind from generated contract

Type panel kinds off DashboardtypesPanelPluginKindDTO instead of a
hand-written union, keeping the generated API contract as the single
source of truth. getPanelDefinition and PANEL_KIND_TO_PANEL_TYPE now take
PanelKind directly, dropping the `as PanelKind` casts at their call sites.

Renderers drop the defensive optional chaining / `?? {}` fallbacks the
render boundary already guarantees, and panelDef is renamed to
panelDefinition for clarity.

* chore: pr reviews fixes
2026-06-17 09:54:04 +00:00
Naman Verma
9150c2ac87 feat: introduce dashboard views for list dashboards page (#11465)
* feat: functional unique index in sql schema

* fix: only ascii in regex

* fix: use sync tags method in update

* fix: use sync tags method in update

* fix: correct the method name being called

* chore: rename create method to createOrGet

* chore: use tagtypestest package for mock store

* chore: combine functional unique index with unique index

* chore: move tag resolution to module

* test: add unit tests for new idx type

* feat: delete dashboard v2 API

* chore: comment out tags unique index for now

* chore: add a todo comment

* chore: comment out unique index test

* feat: add created at to tag relations

* chore: comment out unique index test

* chore: bump migration number

* chore: remove uploaded grafana flag from metadata

* Merge branch 'main' into nv/v2-dashboard-create

* chore: revert idx generation to resolve conflicts

* fix: use store.RunInTx instead of taking in sqlstore

* fix: use binding package to get request

* chore: move NewDashboardV2 to NewDashboardV2WithoutTags

* chore: rename module to m

* fix: add ctx needed in sqlstore

* fix: remove sqlstore passage in ee pkg

* chore: change dashboardData to dashboardSpec

* feat: follow the metadata+spec key structure

* feat: follow the metadata+spec key structure in open api spec

* feat: v2 dashboard GET API (#11136)

* feat: v2 dashboard GET API

* Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get

* chore: update api specs

* fix: remove soft delete references

* chore: embed StorableDashboard into joinedRow in store method

* fix: fix build error

* chore: revert all frontend changes

* fix: remove public dashboard from get v2 call

* chore: revert all frontend changes

* fix: fix build errors post merge conflict resolution

* feat: lock, unlock, create public, update public v2 dashboard APIs (#11167)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* chore: update api specs

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* chore: revert all frontend changes

* fix: fix build errors and remove v2 create/update public apis

* chore: use v1 methods wherever possible

* fix: use update v2 store method

* chore: update frontend schema

* chore: update frontend schema

* chore: generate api specs

* chore: generate api specs

* feat: patch dashboard api (#11182)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* feat: delete dashboard v2 API and hard delete cron job

* feat: patch dashboard api

* chore: update api specs

* chore: update api specs

* chore: update api specs

* chore: remove delete related work

* fix: add examples of structs for value param in param description

* test: unit test fixes

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* test: key value tags in test

* fix: build error in patch module method

* fix: build error in Apply method

* fix: use sync tags method in update

* fix: fix build errors

* fix: fix all patch application tests

* chore: add more mapper methods

* fix: fix build errors

* chore: generate api specs

* fix: update migration numbering

* fix: add missing request struct in list api

* fix: remove hasMore from list response

* chore: bump migration number

* fix: send total count in response + bug fixes

* fix: add source for v2 dashboards

* chore: incorporate source

* chore: incorporate source in api spec

* chore: incorporate source

* fix: remove system dashboards from list v2 response

* fix: add some required fields

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2 api specs

* fix: remove unused param in constructor

* fix: improve api descriptions

* fix: remove unneeded comment

* chore: increase MaxTagsPerDashboard to 10

* fix: set display name in unmarshal json

* chore: remove integration test for now (will add along with list api)

* feat: add validation on dashboard name

* test: fix build errors and tests based on name related changes

* chore: bump migration number

* chore: generate api specs

* fix: fix tests based on name related changes

* fix: dont include full data in list response

* fix: add quotes around tag relation kind

* chore: bump migration number

* fix: fix post merge build and spec errors

* fix: correct convertor method name

* test: add unit tests for type conversions

* chore: remove enum def of threshold comparison operator

* feat: add flag to generate unique name in backend

* chore: generate api specs

* chore: make tags required in postable

* fix: build error fix

* chore: bump migration number

* feat: introduce dashboard views for list dashboards page

* fix: fix build error in test after merge conflict

* fix: remove unused store method

* fix: remove unused module methods

* fix: use v1 store update method

* fix: change data to spec in api param description

* chore: add back accidentally removed tests

* chore: update api spec

* chore: bump migration number

* feat: delete dashboard v2 API (#11299)

* feat: delete dashboard v2 API

* fix: fix post merge build and spec errors

* fix: address review comments

* chore: generate frontend api spec

* fix: add missing name fetch in listv2 store method

* fix: change title to name in api description

* fix: add all error codes for new apis

* test: change data to spec in unit tests

* fix: remove join to public dashboard table in list call

* fix: use valuer string for list order and sort

* test: integration test and fixes found through it

* chore: use same jsonpatch package as done in zeus

* chore: remove JSONPatchDocument and use patchable everywhere

* fix: make remove idempotent in patch

* chore: separate file for patch types

* chore: better error passage

* fix: remove extra decodePatch calls

* fix: fix post merge build and schema errors

* fix: remove omitempty

* chore: bump migration number

* fix: remove user auditable

* chore: dont pass email to create and update methods

* chore: dont pass email to create and update methods

* chore: regenerate api specs

* chore: rearrange

* fix: return better err

* chore: add all err codes in api spec

* chore: rearrange

* fix: use must new org id

* fix: proper error passage

* chore: rename updateable to updatable

* fix: use must new org id

* fix: use must new org id

* feat: include list of all dashboard tags in list api response

* fix: remove wrong api description msg

* fix: use must method for user id as well

* chore: add nolint comment

* fix: add missing image field in list response

* chore: regenerate api specs

* chore: regenerate api specs

* fix: make GettableTag a defined type instead of an alias

* fix: dont allow system dashboards to be deleted

* fix: remove public filter from visitor

* chore: use go sqlbuilder

* fix: use ESCAPE literal in contains and like operators

* fix: use correct perses package in list v2 file

* feat: change pinned dashboard table to user dashboard preference table

* fix: delete preferences on dashboard delete

* test: add integration test for pinning

* fix: wrap naked errors

* fix: integration dashboards should not be deletable either

* fix: remove org column in preferences and add foreign key to users table

* chore: add fk from prefs to dashbaord table

* chore: remove outer parenthesis removal function

* test: add unit test to ensure that all reserved keys have handlers

* fix: proper url for pin apis

* fix: delete preferences on user deletion

* test: address integration test comments

* test: change limit

* fix: revert the check in can delete

* fix: remove unit test from ee package

* fix: move list filter to impl to avoid db impl logic in types

* chore: code movement

* feat: add a pin free list dashboards api

* fix: update api specs

* fix: use request query in api defs for list apis

* chore: explicitly mark request as nil in list apis

* fix: remove extra noop assignment

* chore: remove separate gettable view typedef

* chore: rearrange structs

* fix: cover errors, fix column type, add tests

* test: add unit tests for dashboard view validations

* fix: move view create/edit/delete to edit access

* fix: move view create/edit/delete to edit access

* fix: change url and fix api descriptions

* chore: add query max length check for views

* fix: wrong spelling for UpdatableDashboardView

* fix: extract common validation logic between list api and saved views

* test: add integration tests for dashboard views

* test: add similar tests for dashboards as well

* fix: dont trim name in views

* fix: add integration test for trailing whitespace rejection

* fix: return name required err for name with only spaces

* test: fix python formatting

* fix: generate api specs

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-17 08:33:07 +00:00
Naman Verma
78df13dabd perf: reuse label maps and index series by variable in formulas (#11529)
* perf: reuse label maps and index series by variable in formulas

* perf: switch to simple worker pool

* perf: use a pointer free backing array for result values

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-17 05:34:46 +00:00
Naman Verma
eb4b11295b feat: add clone dashboard API for perses dashboards (#11742)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat: add clone dashboard API for perses dashboards

* fix: allow integration dashboards to be cloned

* test: add integration test for cloning of integration dashboards

* test: fix lint in test

* test: revert the integration dashboard test
2026-06-17 01:35:07 +00:00
258 changed files with 11483 additions and 9959 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.128.0
image: signoz/signoz:v0.129.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.128.0
image: signoz/signoz:v0.129.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.128.0}
image: signoz/signoz:${VERSION:-v0.129.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.128.0}
image: signoz/signoz:${VERSION:-v0.129.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -2591,6 +2591,41 @@ components:
- panels
- layouts
type: object
DashboardtypesDashboardView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
DashboardtypesDashboardViewData:
properties:
order:
$ref: '#/components/schemas/DashboardtypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/DashboardtypesListSort'
version:
type: string
required:
- version
type: object
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
@@ -2875,6 +2910,15 @@ components:
- total
- tags
type: object
DashboardtypesListableDashboardView:
properties:
views:
items:
$ref: '#/components/schemas/DashboardtypesDashboardView'
type: array
required:
- views
type: object
DashboardtypesListedDashboardForUserV2:
properties:
createdAt:
@@ -3179,6 +3223,16 @@ components:
- tags
- spec
type: object
DashboardtypesPostableDashboardView:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
name:
type: string
required:
- name
- data
type: object
DashboardtypesPostablePublicDashboard:
properties:
defaultTimeRange:
@@ -6938,6 +6992,16 @@ components:
required:
- items
type: object
SpantypesGettableSpanMapperTest:
properties:
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
required:
- spans
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -7025,6 +7089,39 @@ components:
- name
- condition
type: object
SpantypesPostableSpanMapperTest:
properties:
groups:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
required:
- spans
- groups
type: object
SpantypesPostableSpanMapperTestGroup:
properties:
condition:
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
enabled:
type: boolean
mappers:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
nullable: true
type: array
name:
type: string
required:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
@@ -7186,6 +7283,17 @@ components:
- operation
- priority
type: object
SpantypesSpanMapperTestSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
resource:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -12743,6 +12851,69 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/span_mapper_groups/test:
post:
deprecated: false
description: Tests how span mappers would transform sample spans
operationId: TestSpanMappers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Test span mappers against sample spans
tags:
- spanmapper
/api/v1/stats:
get:
deprecated: false
@@ -13328,6 +13499,231 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/dashboard_views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListDashboardViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboard saved views
tags:
- dashboard
post:
deprecated: false
description: Persists the calling user's dashboard listing state (query, sort,
order) as a named, reusable view shared across the org.
operationId: CreateDashboardView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Create dashboard saved view
tags:
- dashboard
/api/v2/dashboard_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Delete dashboard saved view
tags:
- dashboard
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update dashboard saved view
tags:
- dashboard
/api/v2/dashboards:
get:
deprecated: false
@@ -13721,6 +14117,74 @@ paths:
summary: Update dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/clone:
post:
deprecated: false
description: This endpoint clones an existing v2-shape dashboard. User and integration
dashboards can be cloned; system dashboards are rejected. The clone keeps
the source's display name, panels, and tags, but gets a freshly generated
unique internal name and is always created as an unlocked user dashboard owned
by the caller.
operationId: CloneDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/lock:
delete:
deprecated: false

View File

@@ -217,6 +217,10 @@ func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
}
func (module *module) CloneV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CloneV2(ctx, orgID, createdBy, creator, id)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
@@ -262,6 +266,22 @@ func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.CreateView(ctx, orgID, postable)
}
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
return module.pkgDashboardModule.ListViews(ctx, orgID)
}
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdatableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.UpdateView(ctx, orgID, id, updateable)
}
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -10,7 +10,6 @@ import (
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/gorilla/handlers"
@@ -20,7 +19,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/web"
@@ -59,25 +57,12 @@ type Server struct {
// NewServer creates and initializes Server
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 10000,
MaxCost: 1 << 27, // 128 MB
},
})
if err != nil {
return nil, err
}
reader := clickhouseReader.NewReader(
signoz.Instrumentation.Logger(),
signoz.SQLStore,
signoz.TelemetryStore,
signoz.Prometheus,
signoz.TelemetryStore.Cluster(),
config.Querier.FluxInterval,
cacheForTraceDetail,
signoz.Cache,
nil,
)

View File

@@ -94,7 +94,6 @@
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
"qs": "6.15.2",
"rc-select": "14.10.0",
"react": "18.2.0",
"react-addons-update": "15.6.3",
@@ -169,7 +168,6 @@
"@types/lodash-es": "^4.17.4",
"@types/node": "^16.10.3",
"@types/papaparse": "5.3.7",
"@types/qs": "6.15.1",
"@types/react": "18.0.26",
"@types/react-addons-update": "0.14.21",
"@types/react-beautiful-dnd": "13.1.8",

View File

@@ -208,9 +208,6 @@ importers:
posthog-js:
specifier: 1.298.0
version: 1.298.0
qs:
specifier: 6.15.2
version: 6.15.2
rc-select:
specifier: 14.10.0
version: 14.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -392,9 +389,6 @@ importers:
'@types/papaparse':
specifier: 5.3.7
version: 5.3.7
'@types/qs':
specifier: 6.15.1
version: 6.15.1
'@types/react':
specifier: 18.0.26
version: 18.0.26
@@ -3564,9 +3558,6 @@ packages:
'@types/prop-types@15.7.5':
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/react-addons-update@0.14.21':
resolution: {integrity: sha512-HOxr0Hd8C1L4uw8DHyv2etqMVIj78oLEpe567/HgjoE+1Lc+PUsTGXTrkr1BDvFqsu5r49mSlgI5evwrk9eutA==}
@@ -7218,10 +7209,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -12371,8 +12358,6 @@ snapshots:
'@types/prop-types@15.7.5': {}
'@types/qs@6.15.1': {}
'@types/react-addons-update@0.14.21':
dependencies:
'@types/react': 18.0.26
@@ -16701,10 +16686,6 @@ snapshots:
dependencies:
react: 18.2.0
qs@6.15.2:
dependencies:
side-channel: 1.1.0
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}

View File

@@ -18,15 +18,20 @@ import type {
} from 'react-query';
import type {
CloneDashboardV2201,
CloneDashboardV2PathParameters,
CreateDashboardV2201,
CreateDashboardView201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostableDashboardViewDTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeleteDashboardViewPathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -36,6 +41,7 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListDashboardsV2200,
@@ -49,6 +55,8 @@ import type {
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdateDashboardView200,
UpdateDashboardViewPathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -648,6 +656,354 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List dashboard saved views
*/
export const listDashboardViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListDashboardViews200>({
url: `/api/v2/dashboard_views`,
method: 'GET',
signal,
});
};
export const getListDashboardViewsQueryKey = () => {
return [`/api/v2/dashboard_views`] as const;
};
export const getListDashboardViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardViewsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardViews>>
> = ({ signal }) => listDashboardViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardViews>>
>;
export type ListDashboardViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboard saved views
*/
export function useListDashboardViews<
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboard saved views
*/
export const invalidateListDashboardViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.
* @summary Create dashboard saved view
*/
export const createDashboardView = (
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardView201>({
url: `/api/v2/dashboard_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getCreateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
const mutationKey = ['createDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDashboardView>>,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardView>>
>;
export type CreateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type CreateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard saved view
*/
export const useCreateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
return useMutation(getCreateDashboardViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete dashboard saved view
*/
export const deleteDashboardView = (
{ id }: DeleteDashboardViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboard_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteDashboardView>>,
{ pathParams: DeleteDashboardViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardView>>
>;
export type DeleteDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard saved view
*/
export const useDeleteDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
return useMutation(getDeleteDashboardViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update dashboard saved view
*/
export const updateDashboardView = (
{ id }: UpdateDashboardViewPathParameters,
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardView200>({
url: `/api/v2/dashboard_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getUpdateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardView>>,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardView>>
>;
export type UpdateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type UpdateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard saved view
*/
export const useUpdateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardViewMutationOptions(options));
};
/**
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List dashboards (v2)
@@ -1206,6 +1562,85 @@ export const useUpdateDashboardV2 = <
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.
* @summary Clone dashboard (v2)
*/
export const cloneDashboardV2 = (
{ id }: CloneDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneDashboardV2201>({
url: `/api/v2/dashboards/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['cloneDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof cloneDashboardV2>>,
{ pathParams: CloneDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof cloneDashboardV2>>
>;
export type CloneDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone dashboard (v2)
*/
export const useCloneDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
> => {
return useMutation(getCloneDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)

View File

@@ -4633,6 +4633,54 @@ export interface DashboardtypesDashboardSpecDTO {
variables: DashboardtypesVariableDTO[];
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesDashboardViewDataDTO {
order?: DashboardtypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: DashboardtypesListSortDTO;
/**
* @type string
*/
version: string;
}
export interface DashboardtypesDashboardViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
@@ -4744,15 +4792,6 @@ export interface DashboardtypesJSONPatchOperationDTO {
value?: unknown;
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: DashboardtypesDisplayDTO;
}
@@ -4895,6 +4934,13 @@ export interface DashboardtypesListableDashboardV2DTO {
total: number;
}
export interface DashboardtypesListableDashboardViewDTO {
/**
* @type array
*/
views: DashboardtypesDashboardViewDTO[];
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4946,6 +4992,14 @@ export interface DashboardtypesPostableDashboardV2DTO {
tags: TagtypesPostableTagDTO[] | null;
}
export interface DashboardtypesPostableDashboardViewDTO {
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
name: string;
}
export interface DashboardtypesPostablePublicDashboardDTO {
/**
* @type string
@@ -8081,6 +8135,44 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -8376,6 +8468,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -9744,6 +9863,14 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {
@@ -9837,6 +9964,36 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardView201 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardView200 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
@@ -9915,6 +10072,17 @@ export type UpdateDashboardV2200 = {
status: string;
};
export type CloneDashboardV2PathParameters = {
id: string;
};
export type CloneDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};

View File

@@ -30,8 +30,10 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

@@ -6,10 +6,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
@@ -128,13 +124,15 @@ export function useNavigateToExplorer(): (
});
}
applySerializedParams(serialize(preparedQuery), urlParams);
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
const basePath =
dataSource === DataSource.TRACES
? ROUTES.TRACES_EXPLORER
: ROUTES.LOGS_EXPLORER;
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},

View File

@@ -32,7 +32,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
@@ -253,7 +252,7 @@ function LogDetailInner({
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
...serializeToParams(
[QueryParams.compositeQuery]: JSON.stringify(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,

View File

@@ -1,67 +1,29 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button, Skeleton } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import {
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { cloneDeep, isArray, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import { isKeyMatch } from './utils';
import useActiveQueryIndex from './useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';
import useCheckboxFilterValues from './useCheckboxFilterValues';
import './Checkbox.styles.scss';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
interface ICheckboxProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
@@ -72,194 +34,39 @@ interface ICheckboxProps {
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { source, filter, onFilterChange } = props;
const [searchText, setSearchText] = useState<string>('');
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
const activeQueryIndex = useActiveQueryIndex(source);
const {
lastUsedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType,
} = useQueryBuilder();
// Determine if we're in ListView mode
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// Check if this filter has active filters in the query
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
// Derive isOpen from filter state + user action
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isOpen,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
visibleItemsCount,
onToggleOpen,
onShowMore,
} = useCheckboxDisclosure({ filter, activeQueryIndex });
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { attributeValues, isLoading } = useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
});
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
// derive the state of each filter key here in the renderer itself and keep it in sync with current query
// also we need to keep a note of last focussed query.
// eslint-disable-next-line sonarjs/cognitive-complexity
const currentFilterState = useMemo(() => {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}, [
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
]);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// variable to check if the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
// Sort checked items to the top, then unchecked items
const currentAttributeKeys = useMemo(() => {
const checkedValues = attributeValues.filter(
@@ -277,293 +84,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
[currentAttributeKeys, currentFilterState],
);
const handleClearFilterAttribute = (): void => {
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(preparedQuery);
} else {
redirectWithQueryBuilderData(preparedQuery);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
const finalQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(finalQuery);
} else {
redirectWithQueryBuilderData(finalQuery);
}
};
const isEmptyStateWithDocsEnabled =
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
!searchText &&
@@ -571,48 +91,19 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
return (
<div className="checkbox-filter">
<section
className="filter-header-checkbox"
onClick={(): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(10);
} else {
setUserToggleState(true);
}
}}
>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{filter.title}</Typography.Text>
<CheckboxFilterHeader
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
/>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
<section className="right-action">
{isOpen && !!attributeValues.length && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleClearFilterAttribute();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
{isOpen &&
(isLoading || isLoadingKeyValueSuggestions) &&
!attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
)}
{isOpen && !isLoading && (
<>
{!isEmptyStateWithDocsEnabled && (
<section className="search">
@@ -634,48 +125,24 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
data-testid="filter-separator"
/>
)}
<div className="value">
<Checkbox
onChange={(checked): void =>
onChange(value, checked === true, false)
}
value={currentFilterState[value]}
disabled={isFilterDisabled}
className="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
<div className={`${filter.title} label-${value}`} />
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
<CheckboxValueRow
value={value}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
title={filter.title}
onlyButtonLabel={
isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'
}
customRendererForValue={filter.customRendererForValue}
onCheckboxChange={(checked): void => onChange(value, checked, false)}
onOnlyOrAllClick={(): void =>
onChange(value, currentFilterState[value], true)
}
/>
</Fragment>
))}
</section>
@@ -688,10 +155,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text
className="show-more-text"
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
>
<Typography.Text className="show-more-text" onClick={onShowMore}>
Show More...
</Typography.Text>
</section>

View File

@@ -0,0 +1,47 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onClear: () => void;
}
function CheckboxFilterHeader({
title,
isOpen,
showClearAll,
onToggleOpen,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section className="filter-header-checkbox" onClick={onToggleOpen}>
<section className="left-action">
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className="title">{title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && showClearAll && (
<Typography.Text
className="clear-all"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
>
Clear All
</Typography.Text>
)}
</section>
</section>
);
}
export default CheckboxFilterHeader;

View File

@@ -0,0 +1,68 @@
import { Button } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
interface CheckboxValueRowProps {
value: string;
checked: boolean;
disabled: boolean;
title: string;
onlyButtonLabel: string;
customRendererForValue?: (value: string) => JSX.Element;
onCheckboxChange: (checked: boolean) => void;
onOnlyOrAllClick: () => void;
}
function CheckboxValueRow({
value,
checked,
disabled,
title,
onlyButtonLabel,
customRendererForValue,
onCheckboxChange,
onOnlyOrAllClick,
}: CheckboxValueRowProps): JSX.Element {
return (
<div className="value">
<Checkbox
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
value={checked}
disabled={disabled}
className="check-box"
/>
<div
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
onClick={(): void => {
if (disabled) {
return;
}
onOnlyOrAllClick();
}}
>
<div className={`${title} label-${value}`} />
{customRendererForValue ? (
customRendererForValue(value)
) : (
<Typography.Text className="value-string" truncate={1}>
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
);
}
CheckboxValueRow.defaultProps = {
customRendererForValue: undefined,
};
export default CheckboxValueRow;

View File

@@ -0,0 +1,417 @@
/* eslint-disable sonarjs/no-identical-functions */
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { isKeyMatch } from './utils';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
export function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
/**
* Derives the checked/unchecked state for each attribute value by reading the
* active filter clause for this attribute key out of the query.
*
* - No matching clause -> every value is checked (all selected).
* - IN / `=` clause -> only the listed values are checked.
* - NOT IN / `!=` clause -> every value is checked except the excluded ones.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function deriveCheckboxState({
attributeValues,
filterItems,
filterKey,
}: {
attributeValues: string[];
filterItems: TagFilterItem[] | undefined;
filterKey: string;
}): Record<string, boolean> {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = filterItems?.find((item) =>
isKeyMatch(item.key?.key, filterKey),
);
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[String(val)] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}
/**
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
*/
export function clearFilterFromQuery({
currentQuery,
filter,
activeQueryIndex,
}: {
currentQuery: Query;
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
},
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}: {
currentQuery: Query;
activeQueryIndex: number;
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
value: string;
checked: boolean;
isOnlyOrAllClicked: boolean;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
const currentFilterState = deriveCheckboxState({
attributeValues,
filterItems: activeItems,
filterKey: filter.attributeKey.key,
});
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
filter.attributeKey.key,
]);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
}

View File

@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
/**
* Resolves which query-builder query index the checkbox filter reads from and
* writes to.
*
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
* mode track the last focused query.
*/
function useActiveQueryIndex(source: QuickFiltersSource): number {
const { lastUsedQuery, panelType } = useQueryBuilder();
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -0,0 +1,90 @@
import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isKeyMatch } from './utils';
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
interface UseCheckboxDisclosureProps {
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}
interface UseCheckboxDisclosureReturn {
isOpen: boolean;
isSomeFilterPresentForCurrentAttribute: boolean;
visibleItemsCount: number;
onToggleOpen: () => void;
onShowMore: () => void;
}
/**
* Owns the open/collapsed state of a checkbox filter section and how many
* values are visible.
*
* Auto-opens when the query already has a clause for this attribute, otherwise
* falls back to `filter.defaultOpen`. An explicit user toggle always wins.
* Collapsing resets the visible count.
*/
function useCheckboxDisclosure({
filter,
activeQueryIndex,
}: UseCheckboxDisclosureProps): UseCheckboxDisclosureReturn {
const { currentQuery } = useQueryBuilder();
// null = no user action, true = user opened, false = user closed
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(
DEFAULT_VISIBLE_ITEMS_COUNT,
);
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
const isOpen = useMemo(() => {
// If user explicitly toggled, respect that
if (userToggleState !== null) {
return userToggleState;
}
// Auto-open if this filter has active filters in the query
if (isSomeFilterPresentForCurrentAttribute) {
return true;
}
// Otherwise use default behavior (first 2 filters open)
return filter.defaultOpen;
}, [
userToggleState,
isSomeFilterPresentForCurrentAttribute,
filter.defaultOpen,
]);
const onToggleOpen = (): void => {
if (isOpen) {
setUserToggleState(false);
setVisibleItemsCount(DEFAULT_VISIBLE_ITEMS_COUNT);
} else {
setUserToggleState(true);
}
};
const onShowMore = (): void => {
setVisibleItemsCount((prev) => prev + DEFAULT_VISIBLE_ITEMS_COUNT);
};
return {
isOpen,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
};
}
export default useCheckboxDisclosure;

View File

@@ -0,0 +1,78 @@
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
attributeValues: string[];
activeQueryIndex: number;
onFilterChange?: ((query: Query) => void) | null;
}
interface UseCheckboxFilterActionsReturn {
onChange: (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
) => void;
onClear: () => void;
}
/**
* Wires the pure checkbox query algebra to query-builder dispatch: the
* caller-provided `onFilterChange` when present, otherwise a URL redirect.
*/
function useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const dispatch = (query: Query): void => {
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(query);
} else {
redirectWithQueryBuilderData(query);
}
};
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
): void => {
dispatch(
applyCheckboxToggle({
currentQuery,
activeQueryIndex,
filter,
source,
attributeValues,
value,
checked,
isOnlyOrAllClicked,
}),
);
};
const onClear = (): void => {
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
};
return { onChange, onClear };
}
export default useCheckboxFilterActions;

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { deriveCheckboxState } from './checkboxFilterQuery';
import { isKeyMatch } from './utils';
interface UseCheckboxFilterStateProps {
filter: IQuickFiltersConfig;
attributeValues: string[];
activeQueryIndex: number;
}
interface UseCheckboxFilterStateReturn {
currentFilterState: Record<string, boolean>;
isFilterDisabled: boolean;
isMultipleValuesTrueForTheKey: boolean;
}
/**
* Reads the active query and derives the per-value checked state for this
* attribute, whether the filter is disabled (same key used more than once in
* the filter bar), and whether more than one value is currently selected.
*/
function useCheckboxFilterState({
filter,
attributeValues,
activeQueryIndex,
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
const { currentQuery } = useQueryBuilder();
// derive the state of each filter key here and keep it in sync with current query
const currentFilterState = useMemo(
() =>
deriveCheckboxState({
attributeValues,
filterItems:
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
filterKey: filter.attributeKey.key,
}),
[
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
],
);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// whether the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
return {
currentFilterState,
isFilterDisabled,
isMultipleValuesTrueForTheKey,
};
}
export default useCheckboxFilterState;

View File

@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
interface UseCheckboxFilterValuesProps {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
searchText: string;
isOpen: boolean;
}
interface UseCheckboxFilterValuesReturn {
attributeValues: string[];
isLoading: boolean;
}
function useCheckboxFilterValues({
filter,
source,
searchText,
isOpen,
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: filter.aggregateOperator || 'noop',
dataSource: filter.dataSource || DataSource.LOGS,
aggregateAttribute: filter.aggregateAttribute || '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
);
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
useGetQueryKeyValueSuggestions({
key: filter.attributeKey.key,
signal: filter.dataSource || DataSource.LOGS,
signalSource: 'meter',
options: {
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
keepPreviousData: true,
},
});
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
// Process the response data
const responseData = keyValueSuggestions?.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
);
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => value.toString());
// Combine all options and make sure we don't have duplicate labels
return [...stringOptions, ...numberOptions];
}
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
return {
attributeValues,
isLoading: isLoading || isLoadingKeyValueSuggestions,
};
}
export default useCheckboxFilterValues;

View File

@@ -18,6 +18,7 @@ export enum QueryParams {
q = 'q',
activeLogId = 'activeLogId',
timeRange = 'timeRange',
compositeQuery = 'compositeQuery',
panelTypes = 'panelTypes',
pageSize = 'pageSize',
viewMode = 'viewMode',

View File

@@ -6,10 +6,6 @@ import {
import { SelectOption } from 'types/common/select';
export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
{
value: MetricAggregateOperator.NOOP,
label: 'No aggregation',
},
{
value: MetricAggregateOperator.COUNT,
label: 'Count',

View File

@@ -1,7 +1,5 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getAutoContexts } from '../getAutoContexts';
@@ -149,24 +147,4 @@ describe('getAutoContexts', () => {
),
).toStrictEqual([]);
});
it('decodes the serialized composite query into metadata.query', () => {
const query = { builder: { queryData: [] } } as unknown as Query;
const search = `?${serialize(query).toString()}`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata?.query).toStrictEqual(query);
});
it('omits metadata.query when no serialized query is in the URL', () => {
// Detection no longer gates on the `compositeQuery` key — it routes
// through `deserialize`/the adapter list — so non-query params (time
// range, etc.) must not be mistaken for a query.
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata).not.toHaveProperty('query');
});
});

View File

@@ -24,7 +24,7 @@ import {
undoExecution,
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { QueryParams } from 'constants/query';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -363,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const params = serialize(normalized);
deps.history.push(`${base}?${params.toString()}`);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */

View File

@@ -8,7 +8,6 @@ import { getViewById } from 'api/saveView/getViewById';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { deserialize } from 'lib/compositeQuery/serializer';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -219,9 +218,7 @@ describe('buildExplorerNavigationUrl', () => {
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
const params = new URLSearchParams(new URL(url, 'http://x').search);
expect(deserialize(params)).not.toBeNull();
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});

View File

@@ -2,10 +2,6 @@ import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { ViewProps } from 'types/api/saveViews/types';
@@ -79,7 +75,10 @@ export function buildExplorerNavigationUrl(
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
applySerializedParams(serialize(query), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});

View File

@@ -1,7 +1,6 @@
import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { AlertListTabs } from 'pages/AlertList/types';
import { matchPath } from 'react-router-dom';
@@ -340,9 +339,15 @@ function collectSharedMetadata(
out.timeRange = { start: startTime, end: endTime };
}
const decodedQuery = deserialize(params);
if (decodedQuery) {
out.query = decodedQuery;
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
}
// Saved view selectors (logs / traces explorer) and dashboard variables.

View File

@@ -2,8 +2,8 @@ import { memo } from 'react';
import { Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
@@ -28,7 +28,9 @@ function PanelTypeSelectionModal(): JSX.Element {
const queryParams = {
graphType: name,
widgetId: id,
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
};
history.push(

View File

@@ -63,5 +63,6 @@
flex: 0 0 auto;
min-height: 0;
min-width: 0;
padding: 8px;
padding-left: 12px;
padding-bottom: 12px;
}

View File

@@ -16,7 +16,7 @@ import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { PieTooltipData } from './types';
import { getFillColor } from './utils';
import { getDonutGeometry, getFillColor } from './utils';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -78,16 +78,12 @@ export default function Pie({
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box.
const { size, radius, innerRadius } = useMemo(() => {
const nextSize = Math.min(width, height);
const nextRadius = nextSize * 0.35;
return {
size: nextSize,
radius: nextRadius,
innerRadius: nextRadius * 0.6,
};
}, [width, height]);
// Donut geometry derived from the allocated chart box, sized to leave room
// for the external leader labels (see getDonutGeometry).
const { size, radius, innerRadius } = useMemo(
() => getDonutGeometry(width, height),
[width, height],
);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),

View File

@@ -1,11 +1,40 @@
import {
getArcGeometry,
getDonutGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getDonutGeometry', () => {
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
const { radius } = getDonutGeometry(400, 300);
const half = Math.min(400, 300) / 2; // 150
// The label anchor sits at radius * 1.3 and must stay within the box
// half-extent so labels are not clipped.
expect(radius * 1.3).toBeLessThanOrEqual(half);
// And it should use the available room (anchor = half - 22 allowance).
expect(radius * 1.3).toBeCloseTo(half - 22);
});
it('derives size and inner radius from the outer radius', () => {
const { size, radius, innerRadius } = getDonutGeometry(300, 300);
expect(size).toBeCloseTo(radius * 2);
expect(innerRadius).toBeCloseTo(radius * 0.6);
});
it('sizes off the smaller dimension so it fits both axes', () => {
expect(getDonutGeometry(1000, 200)).toStrictEqual(
getDonutGeometry(200, 1000),
);
});
it('never returns a negative radius for a box too small for labels', () => {
expect(getDonutGeometry(20, 20).radius).toBe(0);
});
});
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(

View File

@@ -10,6 +10,16 @@ export interface ScaledFontSizeArgs {
innerRadius: number;
}
/** Donut sizing for a given chart box: the outer/inner radii and the square it spans. */
export interface DonutGeometry {
/** Outer diameter — feeds the visx Pie width/height and the render guard. */
size: number;
/** Outer radius of the donut ring. */
radius: number;
/** Inner radius (the hole) — also bounds the centre-total font. */
innerRadius: number;
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;

View File

@@ -3,7 +3,37 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
import {
ArcGeometry,
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from './types';
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is
// the px reserved beyond that anchor for the (10px, two-line) text so it never
// clips against the SVG edge.
const LABEL_RADIUS_RATIO = 1.3;
const LABEL_TEXT_ALLOWANCE = 22;
const INNER_RADIUS_RATIO = 0.6;
/**
* Sizes the donut to fit inside a `width × height` box *with room for the
* external leader labels*. The label anchor sits at `radius * 1.3`, so we solve
* the outer radius back from the box's half-extent minus the text allowance —
* guaranteeing the labels stay inside the SVG instead of being clipped (V1 used
* a flat `0.35 * min(w,h)`, which left too little margin on small panels).
*/
export function getDonutGeometry(width: number, height: number): DonutGeometry {
const half = Math.min(width, height) / 2;
const radius = Math.max(0, (half - LABEL_TEXT_ALLOWANCE) / LABEL_RADIUS_RATIO);
return {
size: radius * 2,
radius,
innerRadius: radius * INNER_RADIUS_RATIO,
};
}
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -37,7 +67,7 @@ export function getArcGeometry(
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * 1.3;
const labelRadius = radius * LABEL_RADIUS_RATIO;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,

View File

@@ -0,0 +1,79 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from '../utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
`label-${i}`.padEnd(length, 'x').slice(0, length),
);
describe('calculateChartDimensions', () => {
it('returns all zeros when the container has no space', () => {
expect(
calculateChartDimensions({
containerWidth: 0,
containerHeight: 300,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
}),
).toStrictEqual({
width: 0,
height: 0,
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
});
});
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
expect(dims.legendWidth).toBe(240);
expect(dims.width).toBe(760);
expect(dims.height).toBe(400);
expect(dims.legendHeight).toBe(400);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One row = line height (28) + padding (12).
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: many items cap at two rows on a tall container', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
});
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 160,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
});
});

View File

@@ -116,7 +116,15 @@ export function calculateChartDimensions({
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,

View File

@@ -62,8 +62,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useErrorNotification from 'hooks/useErrorNotification';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
@@ -176,7 +174,7 @@ function ExplorerOptions({
const handleConditionalQueryModification = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(defaultQuery: Query | null): Record<string, string> => {
(defaultQuery: Query | null): string => {
const queryToUse = defaultQuery || query;
if (!queryToUse) {
throw new Error('No query provided');
@@ -186,7 +184,7 @@ function ExplorerOptions({
StringOperators.NOOP &&
sourcepage !== DataSource.LOGS
) {
return serializeToParams(queryToUse);
return JSON.stringify(queryToUse);
}
// Convert NOOP to COUNT for alerts and strip orderBy for logs
@@ -210,7 +208,14 @@ function ExplorerOptions({
);
}
return serializeToParams(modifiedQuery);
try {
return JSON.stringify(modifiedQuery);
} catch (err) {
throw new Error(
'Failed to stringify modified query: ' +
(err instanceof Error ? err.message : String(err)),
);
}
},
[panelType, query, sourcepage],
);
@@ -233,9 +238,13 @@ function ExplorerOptions({
});
}
const serializedParams = handleConditionalQueryModification(defaultQuery);
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleConditionalQueryModification, history],

View File

@@ -34,7 +34,6 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
@@ -385,7 +384,7 @@ function FormAlertRules({
const onCancelHandler = useCallback(
(e?: React.MouseEvent) => {
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
@@ -611,7 +610,7 @@ function FormAlertRules({
`${ruleId}`,
]);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);

View File

@@ -23,10 +23,6 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
clearSerializedParams,
serializeToParams,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
@@ -216,7 +212,9 @@ function WidgetGraphComponent({
[QueryParams.graphType]: clonedWidget?.panelTypes,
[QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
...serializeToParams(clonedWidget.query),
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
}),
};
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
@@ -257,7 +255,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
clearSerializedParams(existingSearchParams);
existingSearchParams.delete(QueryParams.compositeQuery);
existingSearchParams.delete(QueryParams.graphType);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {

View File

@@ -29,10 +29,6 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { unparse } from 'papaparse';
@@ -90,7 +86,10 @@ function WidgetHeader({
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
urlQuery.set(QueryParams.graphType, widget.panelTypes);
applySerializedParams(serialize(widget.query), urlQuery);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),

View File

@@ -7,10 +7,6 @@ import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
@@ -138,7 +134,10 @@ export default function AlertRules({
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
applySerializedParams(serialize(compositeQuery), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -28,10 +28,6 @@ import {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
@@ -414,7 +410,7 @@ export default function K8sBaseDetails<T>({
},
};
applySerializedParams(serialize(compositeQuery as any), urlQuery);
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -439,7 +435,7 @@ export default function K8sBaseDetails<T>({
},
};
applySerializedParams(serialize(compositeQuery as any), urlQuery);
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -53,7 +53,6 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { serialize } from 'lib/compositeQuery/serializer';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
import {
ArrowUpRight,
@@ -78,7 +77,6 @@ import {
UpdateLimitProps,
} from 'types/api/ingestionKeys/limits/types';
import { PaginationProps } from 'types/api/ingestionKeys/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -898,6 +896,8 @@ function MultiIngestionSettings(): JSX.Element {
},
};
const stringifiedQuery = JSON.stringify(query);
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
thresholds[0].thresholdValue = thresholdValue;
thresholds[0].unit = thresholdUnit;
@@ -907,12 +907,17 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
const params = serialize(query as Query);
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
params.set(QueryParams.ruleName, ruleName);
params.set(QueryParams.yAxisUnit, yAxisUnit);
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
history.push(URL);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [

View File

@@ -1,6 +1,5 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
@@ -133,19 +132,17 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('{count}');
expect(compositeQuery?.builder.queryData).toBeDefined();
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
"signoz.workspace.key.id='k1'",
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe(
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
@@ -216,18 +213,18 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('bytes');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
"signoz.workspace.key.id='k2'",
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');

View File

@@ -6,10 +6,6 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
@@ -35,7 +31,10 @@ export function useAlertRulesHandlers(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
applySerializedParams(serialize(compositeQuery), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -14,10 +14,6 @@ import { FontSize } from 'container/OptionsMenu/types';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -115,7 +111,10 @@ function ContextLogRenderer({
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
applySerializedParams(serialize(query), urlQuery);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');

View File

@@ -247,12 +247,16 @@ function Application(): JSX.Element {
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
apmToTraceQuery,
JSONCompositeQuery,
queryString,
);

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { FeatureKeys } from 'constants/features';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -124,24 +123,14 @@ function ServiceOverview({
/>
<Card data-testid="service_latency">
<GraphContainer>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</>

View File

@@ -1,4 +1,3 @@
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -29,24 +28,14 @@ function TopLevelOperation({
</Typography>
) : (
<GraphContainer>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
)}
</Card>

View File

@@ -8,10 +8,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
@@ -64,18 +60,16 @@ export function generateExplorerPath(
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
apmToTraceQuery: Query,
JSONCompositeQuery: string,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
applySerializedParams(serialize(apmToTraceQuery), urlParams);
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
'&',
)}`;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
@@ -111,12 +105,16 @@ export function onViewTracePopupClick({
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
apmToTraceQuery,
JSONCompositeQuery,
queryString,
);

View File

@@ -1,6 +1,5 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
@@ -30,11 +29,13 @@ export const navigateToTrace = ({
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
const newTraceExplorerPath = `${
ROUTES.TRACES_EXPLORER
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
apmToTraceQuery,
).toString()}`;
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');

View File

@@ -33,7 +33,6 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -792,7 +791,9 @@ function NewWidget({
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType,
...serializeToParams(currentQuery),
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
[QueryParams.variables]: variables,
};

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -50,7 +49,7 @@ const useBaseDrilldownNavigate = ({
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
...serializeToParams(viewQuery),
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
@@ -95,7 +94,7 @@ export function buildDrilldownUrl(
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
...serializeToParams(viewQuery),
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),

View File

@@ -19,7 +19,6 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';
import { ILog } from 'types/api/logs/log';
@@ -140,7 +139,7 @@ function SpanLogs({
[QueryParams.activeLogId]: `"${log.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
...serializeToParams(updatedQuery),
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
};
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;

View File

@@ -15,10 +15,6 @@ import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { BarChart, Compass, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
@@ -159,7 +155,7 @@ function SpanRelatedSignals({
};
const searchParams = new URLSearchParams();
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -3,7 +3,6 @@ import getUserPreference from 'api/v1/user/preferences/name/get';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { deserialize } from 'lib/compositeQuery/serializer';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
@@ -546,13 +545,14 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000'); // traceEndTime + 5 minutes
// Verify composite query includes both trace_id and span_id filters
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
// Check that the filter expression contains trace_id
// Note: Current behavior uses only trace_id filter for navigation
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();
@@ -595,16 +595,16 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
// Verify composite query filters by trace_id and the context log's own span_id
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
// Verify composite query includes only trace_id filter (no span_id for context logs)
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
// Check that the filter expression contains trace_id
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
// Context logs use their own span id, not the currently selected span id
expect(filter?.expression).toContain("span_id = 'different-span-id'");
expect(filter?.expression).not.toContain('test-span-id');
// Check that the filter expression contains trace_id but not span_id for context logs
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
// Context logs should not have span_id filter
expect(filter.expression).not.toContain('span_id');
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();

View File

@@ -35,11 +35,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import {
applySerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -283,7 +278,7 @@ function DateTimeSelection({
return `Refreshed ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
const getUpdatedCompositeQuery = useCallback((): string => {
let updatedCompositeQuery = cloneDeep(currentQuery);
updatedCompositeQuery.id = uuid();
// Remove the filters
@@ -304,7 +299,7 @@ function DateTimeSelection({
})),
},
};
return serialize(updatedCompositeQuery);
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
}, [currentQuery]);
const onSelectHandler = useCallback(
@@ -339,9 +334,9 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -429,9 +424,9 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;

View File

@@ -1,170 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const BASE = 'http://localhost';
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
const search = params?.toString();
const query = search ? `?${search}` : '';
return new URL(`${pathname}${query}`, BASE);
};
/** Build params containing the serialized `compositeQuery` plus any extras. */
const withQuery = (
query: Query,
extra: Record<string, string> = {},
): URLSearchParams => {
const params = serialize(query);
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
return params;
};
describe('areUrlsEffectivelySame', () => {
it('returns false when pathnames differ', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
false,
);
});
it('returns true for two identical param-less URLs', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
});
it('returns true when only the compositeQuery is present and identical', () => {
const params = withQuery(initialQueriesMap.logs);
expect(
areUrlsEffectivelySame(
urlFrom('/logs', params),
urlFrom('/logs', new URLSearchParams(params.toString())),
),
).toBe(true);
});
// Regression: a matching compositeQuery must NOT mask differences in other
// params. Previously every param was compared via the decoded query, so any
// two URLs sharing a compositeQuery were judged identical.
it('returns false when compositeQuery matches but another param differs', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('ignores the volatile id when comparing compositeQuery', () => {
const url1 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
);
const url2 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
});
it('returns false when compositeQuery is semantically different', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
// Force same pathname so only the query differs.
expect(
areUrlsEffectivelySame(
url1,
urlFrom('/logs', new URLSearchParams(url2.search)),
),
).toBe(false);
});
it('returns false when compositeQuery exists on only one URL', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/logs');
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
const same1 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
const same2 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
const diff = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '3' }),
);
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
});
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
const corrupt1 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
const corrupt2 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
const corrupt3 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bother' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
});
});
describe('isDefaultNavigation', () => {
it('returns false for different pathnames', () => {
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
});
it('returns true when a clean URL gains params', () => {
expect(
isDefaultNavigation(
urlFrom('/logs'),
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
),
).toBe(true);
});
it('returns true when the target introduces a new param key', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
),
).toBe(true);
});
it('returns false when the target has no new param keys', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
),
).toBe(false);
});
});

View File

@@ -5,6 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
@@ -12,7 +13,6 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { AppState } from 'store/reducers';
@@ -58,14 +58,9 @@ export const useActiveLog = (): UseActiveLog => {
const [activeLog, setActiveLog] = useState<ILog | null>(null);
// Close drawer/clear active log when query in URL changes. Track the decoded
// query (not a single raw param) so it stays correct across serializer tiers
// that explode the query into many keys.
// Close drawer/clear active log when query in URL changes
const urlQuery = useUrlQuery();
const compositeQuery = useMemo(() => {
const decoded = deserialize(urlQuery);
return decoded ? JSON.stringify(decoded) : '';
}, [urlQuery]);
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
const prevQueryRef = useRef<string | null>(null);
useEffect(() => {
if (

View File

@@ -2,10 +2,9 @@ import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { deserialize } from 'lib/compositeQuery/serializer';
import { QueryParams } from 'constants/query';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
@@ -80,14 +79,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
},
}) as unknown as Widgets;
const getCompositeQueryFromLastOpen = (): Query => {
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const composite = deserialize(query);
if (!composite) {
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
throw new Error('compositeQuery not found in URL');
}
return composite;
return JSON.parse(decodeURIComponent(raw));
};
describe('useCreateAlerts', () => {

View File

@@ -1,26 +0,0 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
let mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
}));
describe('useGetCompositeQueryParam', () => {
it('decodes a legacy compositeQuery param', () => {
mockUrlQuery = new URLSearchParams({
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
});
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null when the param is absent', () => {
mockUrlQuery = new URLSearchParams();
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current).toBeNull();
});
});

View File

@@ -14,10 +14,6 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
@@ -90,7 +86,10 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
}
const params = new URLSearchParams();
applySerializedParams(serialize(updatedQuery), params);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,10 +1,72 @@
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => deserialize(urlQuery), [urlQuery]);
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return null;
}
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
parsedCompositeQuery = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
};

View File

@@ -1,9 +1,6 @@
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
@@ -21,6 +18,77 @@ interface UseSafeNavigateProps {
preventSameUrlNavigation?: boolean;
}
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
const allParams = new Set([...params1.keys(), ...params2.keys()]);
return [...allParams].every((param) => {
if (param === 'compositeQuery') {
try {
const query1 = params1.get('compositeQuery');
const query2 = params2.get('compositeQuery');
if (!query1 || !query2) {
return false;
}
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
delete filtered1.id;
delete filtered2.id;
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;
}
}
return params1.get(param) === params2.get(param);
});
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,

View File

@@ -1,103 +0,0 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { isEqual } from 'lodash-es';
/**
* Compare the (optional) `compositeQuery` param of two URLSearchParams
* semantically. Its serialized form is not byte-stable — the volatile `id` and
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
*
* compositeQuery is not guaranteed to be present: absent on both sides counts
* as equal, present on only one side counts as different. When either side is
* present but can't be decoded, we fall back to comparing the raw values.
*/
const compositeQueriesEqual = (
params1: URLSearchParams,
params2: URLSearchParams,
): boolean => {
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
if (!raw1 && !raw2) {
return true;
}
if (!raw1 || !raw2) {
return false;
}
try {
const decoded1 = deserialize(params1);
const decoded2 = deserialize(params2);
if (decoded1 && decoded2) {
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
return isEqual(rest1, rest2);
}
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
}
// One or both could not be decoded — compare the raw encoded values.
return raw1 === raw2;
};
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
// The compositeQuery is compared semantically (it round-trips through a
// non-stable serialized form); every other param is compared by raw value.
if (!compositeQueriesEqual(params1, params2)) {
return false;
}
const otherKeys = new Set(
[...params1.keys(), ...params2.keys()].filter(
(key) => key !== COMPOSITE_QUERY_KEY,
),
);
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
export const isDefaultNavigation = (
currentUrl: URL,
targetUrl: URL,
): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};

View File

@@ -1,269 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`baseline immutability snapshots LOGS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots LOGS_BASELINE_V1_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots METRICS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots TRACES_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;

View File

@@ -1,121 +0,0 @@
/**
* ╔════════════════════════════════════════════════════════════════════════════╗
* ║ ⚠️ CRITICAL WARNING ⚠️ ║
* ╠════════════════════════════════════════════════════════════════════════════╣
* ║ These baselines are FROZEN FOREVER. They must NEVER be modified. ║
* ║ ║
* ║ WHY: Every URL ever emitted by the compositeQuery serializer encodes a ║
* ║ diff against these exact baselines. Changing a single byte here silently ║
* ║ BREAKS ALL EXISTING URLs — dashboards, saved views, shared links, etc. ║
* ║ ║
* ║ If these snapshot tests fail: ║
* ║ 1. DO NOT update the snapshots ║
* ║ 2. REVERT your changes to baseline.ts immediately ║
* ║ 3. If you need a new schema, create a NEW versioned baseline: ║
* ║ - METRICS_BASELINE_V2, LOGS_BASELINE_V2, TRACES_BASELINE_V2 ║
* ║ - Create a new adapter (e.g., V2~) that uses the new baselines ║
* ║ - Keep the old baselines untouched for backwards compatibility ║
* ╚════════════════════════════════════════════════════════════════════════════╝
*/
import getBaselineByTag, { pickBaseline } from '../baseline';
import { METRICS_BASELINE_V1 } from 'lib/compositeQuery/baseline.metrics';
import { LOGS_BASELINE_V1 } from 'lib/compositeQuery/baseline.logs';
import { TRACES_BASELINE_V1 } from 'lib/compositeQuery/baseline.traces';
describe('baseline immutability snapshots', () => {
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('METRICS_BASELINE_V1 must never change', () => {
expect(METRICS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('LOGS_BASELINE_V1 must never change', () => {
expect(LOGS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('TRACES_BASELINE_V1 must never change', () => {
expect(TRACES_BASELINE_V1).toMatchSnapshot();
});
});
describe('pickBaseline', () => {
it('returns metrics baseline for metrics dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'metrics' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('returns logs baseline for logs dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'logs' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(LOGS_BASELINE_V1);
expect(result.tag).toBe('l');
});
it('returns traces baseline for traces dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'traces' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(TRACES_BASELINE_V1);
expect(result.tag).toBe('t');
});
it('defaults to metrics baseline for unknown dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'unknown' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('defaults to metrics baseline when queryData is empty', () => {
const query = {
builder: { queryData: [] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
});
describe('getBaselineByTag', () => {
it('returns LOGS_BASELINE_V1 for tag "l"', () => {
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE_V1);
});
it('returns TRACES_BASELINE_V1 for tag "t"', () => {
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE_V1);
});
it('returns METRICS_BASELINE_V1 for tag "m"', () => {
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE_V1);
});
});

View File

@@ -1,51 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import {
clearSerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('round-trips through serialize/deserialize', () => {
const query = initialQueriesMap.logs;
const decoded = deserialize(serialize(query));
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null on corrupt input instead of throwing', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
expect(deserialize(params)).toBeNull();
});
it('returns null for empty/missing value', () => {
const params = new URLSearchParams();
expect(deserialize(params)).toBeNull();
});
it('preserves id field through roundtrip', () => {
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
const serialized = serialize(query);
const decoded = deserialize(serialized);
expect(decoded?.id).toBe('test-query-uuid-123');
});
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
const params = serialize(initialQueriesMap.logs);
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(deserialize(params)).toBeNull();
expect(params.get('panelTypes')).toBe('list');
});
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(params.get('panelTypes')).toBe('list');
});
});

View File

@@ -1,63 +0,0 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import {
CompositeQueryAdapter,
COMPOSITE_QUERY_KEY,
} from 'lib/compositeQuery/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
function migrateLegacyFormat(parsed: Query): Query {
if (!parsed?.builder?.queryData) {
return parsed;
}
const next = parsed;
next.builder.queryData = parsed.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
if (Array.isArray(query.having)) {
convertedQuery.having = convertHavingToExpression(query.having);
}
if (!query.aggregations && query.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
}
return convertedQuery;
});
return next;
}
export const jsonAdapter: CompositeQueryAdapter = {
name: 'json(legacy)',
encode: (query) => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(query)));
return params;
},
matches: () => true,
decode: (params) => {
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
return migrateLegacyFormat(parsed);
},
};

View File

@@ -1,74 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { jsonAdapter } from './index';
const roundTrip = (query: Query): Query =>
jsonAdapter.decode(jsonAdapter.encode(query));
describe('jsonAdapter', () => {
describe('round-trip', () => {
it.each(['metrics', 'logs', 'traces'] as const)(
'round-trips %s baseline preserving dataSource',
(source) => {
const query = initialQueriesMap[source];
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].dataSource).toBe(source);
},
);
});
describe('legacy format compatibility', () => {
it('encodes to legacy format (encodeURIComponent + JSON)', () => {
const query = initialQueriesMap.logs;
const params = jsonAdapter.encode(query);
const encoded = params.get(COMPOSITE_QUERY_KEY) ?? '';
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
expect(encoded.startsWith('%7B')).toBe(true);
});
});
describe('tag matching', () => {
it('matches any value (catch-all fallback)', () => {
const params1 = new URLSearchParams();
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
expect(jsonAdapter.matches(params1)).toBe(true);
const params2 = new URLSearchParams();
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
expect(jsonAdapter.matches(params2)).toBe(true);
});
});
describe('migration', () => {
it('migrates old format (filters -> filter.expression)', () => {
const legacy = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { op: 'AND', items: [] },
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '' },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(legacy)));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filter).toBeDefined();
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
});
});
});

View File

@@ -1,81 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter encoding format field aliasing emits the short alias instead of the full field name: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=sum&query0.source="`;
exports[`qsAliasAdapter encoding format prefix substitution rewrites builder.queryData.0 to the query0 prefix: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is independent of source key order: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is stable after spread / reconstruct: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -1,225 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias leaf codec decodeLeaf falls back to raw text on a malformed tagged token (never throws): decoded-fallback 1`] = `
{
"fallback": "_not json",
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged empty containers: decoded-containers 1`] = `
{
"array": [],
"object": {},
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged scalars back to their type: decoded-scalars 1`] = `
{
"false": false,
"negative": -4.5,
"null": null,
"number": 123,
"true": true,
}
`;
exports[`qsAlias leaf codec decodeLeaf returns untagged tokens as plain strings: decoded-strings 1`] = `
{
"123": "123",
"empty": "",
"null": "null",
"traces": "traces",
"true": "true",
}
`;
exports[`qsAlias leaf codec decodeLeaf unescapes a doubled-tag string: decoded-escaped 1`] = `
{
"__": "_",
"___name__": "__name__",
"__x": "_x",
}
`;
exports[`qsAlias leaf codec encodeLeaf emits strings verbatim: encoded-strings 1`] = `
{
"empty": "",
"service.name": "service.name",
"traces": "traces",
}
`;
exports[`qsAlias leaf codec encodeLeaf escapes a string that begins with the tag char by doubling it: encoded-escaped 1`] = `
{
"_": "__",
"__name__": "___name__",
"_x": "__x",
}
`;
exports[`qsAlias leaf codec encodeLeaf normalizes undefined to null: encoded-undefined 1`] = `
{
"undefined": "_null",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags empty containers: encoded-containers 1`] = `
{
"array": "_[]",
"object": "_{}",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags non-string scalars with a leading underscore: encoded-scalars 1`] = `
{
"false": "_false",
"negative": "_-4.5",
"null": "_null",
"number": "_123",
"true": "_true",
}
`;
exports[`qsAlias leaf codec round-trip "" survives encode → decode: roundtrip-"" 1`] = `
{
"decoded": "",
"encoded": "",
"input": "",
}
`;
exports[`qsAlias leaf codec round-trip "_" survives encode → decode: roundtrip-"_" 1`] = `
{
"decoded": "_",
"encoded": "__",
"input": "_",
}
`;
exports[`qsAlias leaf codec round-trip "_leading" survives encode → decode: roundtrip-"_leading" 1`] = `
{
"decoded": "_leading",
"encoded": "__leading",
"input": "_leading",
}
`;
exports[`qsAlias leaf codec round-trip "123" survives encode → decode: roundtrip-"123" 1`] = `
{
"decoded": "123",
"encoded": "123",
"input": "123",
}
`;
exports[`qsAlias leaf codec round-trip "a=b&c#d%e+f.g" survives encode → decode: roundtrip-"a=b&c#d%e+f.g" 1`] = `
{
"decoded": "a=b&c#d%e+f.g",
"encoded": "a=b&c#d%e+f.g",
"input": "a=b&c#d%e+f.g",
}
`;
exports[`qsAlias leaf codec round-trip "false" survives encode → decode: roundtrip-"false" 1`] = `
{
"decoded": "false",
"encoded": "false",
"input": "false",
}
`;
exports[`qsAlias leaf codec round-trip "null" survives encode → decode: roundtrip-"null" 1`] = `
{
"decoded": "null",
"encoded": "null",
"input": "null",
}
`;
exports[`qsAlias leaf codec round-trip "service.name" survives encode → decode: roundtrip-"service.name" 1`] = `
{
"decoded": "service.name",
"encoded": "service.name",
"input": "service.name",
}
`;
exports[`qsAlias leaf codec round-trip "traces" survives encode → decode: roundtrip-"traces" 1`] = `
{
"decoded": "traces",
"encoded": "traces",
"input": "traces",
}
`;
exports[`qsAlias leaf codec round-trip "true" survives encode → decode: roundtrip-"true" 1`] = `
{
"decoded": "true",
"encoded": "true",
"input": "true",
}
`;
exports[`qsAlias leaf codec round-trip [] survives encode → decode: roundtrip-[] 1`] = `
{
"decoded": [],
"encoded": "_[]",
"input": [],
}
`;
exports[`qsAlias leaf codec round-trip {} survives encode → decode: roundtrip-{} 1`] = `
{
"decoded": {},
"encoded": "_{}",
"input": {},
}
`;
exports[`qsAlias leaf codec round-trip -4.5 survives encode → decode: roundtrip--4.5 1`] = `
{
"decoded": -4.5,
"encoded": "_-4.5",
"input": -4.5,
}
`;
exports[`qsAlias leaf codec round-trip 0 survives encode → decode: roundtrip-0 1`] = `
{
"decoded": 0,
"encoded": "_0",
"input": 0,
}
`;
exports[`qsAlias leaf codec round-trip 123 survives encode → decode: roundtrip-123 1`] = `
{
"decoded": 123,
"encoded": "_123",
"input": 123,
}
`;
exports[`qsAlias leaf codec round-trip false survives encode → decode: roundtrip-false 1`] = `
{
"decoded": false,
"encoded": "_false",
"input": false,
}
`;
exports[`qsAlias leaf codec round-trip null survives encode → decode: roundtrip-null 1`] = `
{
"decoded": null,
"encoded": "_null",
"input": null,
}
`;
exports[`qsAlias leaf codec round-trip true survives encode → decode: roundtrip-true 1`] = `
{
"decoded": true,
"encoded": "_true",
"input": true,
}
`;

View File

@@ -1,388 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias maps FIELD_ALIASES integrity FIELD_REVERSE is the exact inverse of FIELD_ALIASES: all-reverse 1`] = `
{
"aggAttr": "aggregateAttribute",
"aggOp": "aggregateOperator",
"ds": "dataSource",
"dt": "dataType",
"ic": "isColumn",
"ij": "isJSON",
"mn": "metricName",
"qn": "queryName",
"qt": "queryType",
"spaceAgg": "spaceAggregation",
"stepIn": "stepInterval",
"timeAgg": "timeAggregation",
"tp": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES integrity alias values are unique (no two fields share an alias): all-aliases 1`] = `
{
"aggregateAttribute": "aggAttr",
"aggregateOperator": "aggOp",
"dataSource": "ds",
"dataType": "dt",
"isColumn": "ic",
"isJSON": "ij",
"metricName": "mn",
"queryName": "qn",
"queryType": "qt",
"spaceAggregation": "spaceAgg",
"stepInterval": "stepIn",
"temporality": "tp",
"timeAggregation": "timeAgg",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateAttribute ⇄ aggAttr via aliasField / expandField: alias-aggregateAttribute 1`] = `
{
"alias": "aggAttr",
"aliased": "aggAttr",
"expanded": "aggregateAttribute",
"field": "aggregateAttribute",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateOperator ⇄ aggOp via aliasField / expandField: alias-aggregateOperator 1`] = `
{
"alias": "aggOp",
"aliased": "aggOp",
"expanded": "aggregateOperator",
"field": "aggregateOperator",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataSource ⇄ ds via aliasField / expandField: alias-dataSource 1`] = `
{
"alias": "ds",
"aliased": "ds",
"expanded": "dataSource",
"field": "dataSource",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataType ⇄ dt via aliasField / expandField: alias-dataType 1`] = `
{
"alias": "dt",
"aliased": "dt",
"expanded": "dataType",
"field": "dataType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isColumn ⇄ ic via aliasField / expandField: alias-isColumn 1`] = `
{
"alias": "ic",
"aliased": "ic",
"expanded": "isColumn",
"field": "isColumn",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isJSON ⇄ ij via aliasField / expandField: alias-isJSON 1`] = `
{
"alias": "ij",
"aliased": "ij",
"expanded": "isJSON",
"field": "isJSON",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips metricName ⇄ mn via aliasField / expandField: alias-metricName 1`] = `
{
"alias": "mn",
"aliased": "mn",
"expanded": "metricName",
"field": "metricName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryName ⇄ qn via aliasField / expandField: alias-queryName 1`] = `
{
"alias": "qn",
"aliased": "qn",
"expanded": "queryName",
"field": "queryName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryType ⇄ qt via aliasField / expandField: alias-queryType 1`] = `
{
"alias": "qt",
"aliased": "qt",
"expanded": "queryType",
"field": "queryType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips spaceAggregation ⇄ spaceAgg via aliasField / expandField: alias-spaceAggregation 1`] = `
{
"alias": "spaceAgg",
"aliased": "spaceAgg",
"expanded": "spaceAggregation",
"field": "spaceAggregation",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips stepInterval ⇄ stepIn via aliasField / expandField: alias-stepInterval 1`] = `
{
"alias": "stepIn",
"aliased": "stepIn",
"expanded": "stepInterval",
"field": "stepInterval",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips temporality ⇄ tp via aliasField / expandField: alias-temporality 1`] = `
{
"alias": "tp",
"aliased": "tp",
"expanded": "temporality",
"field": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips timeAggregation ⇄ timeAgg via aliasField / expandField: alias-timeAggregation 1`] = `
{
"alias": "timeAgg",
"aliased": "timeAgg",
"expanded": "timeAggregation",
"field": "timeAggregation",
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips chsql ⇄ [["clickhouse_sql"]] via transformPath / expandPath: prefix-chsql 1`] = `
{
"expanded": [
"clickhouse_sql",
0,
"someField",
],
"match": [
"clickhouse_sql",
],
"prefix": "chsql",
"transformed": [
"chsql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips formula ⇄ [["builder", "queryFormulas"]] via transformPath / expandPath: prefix-formula 1`] = `
{
"expanded": [
"builder",
"queryFormulas",
0,
"someField",
],
"match": [
"builder",
"queryFormulas",
],
"prefix": "formula",
"transformed": [
"formula0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips handles multi-digit indices: multi-digit 1`] = `
{
"expanded": [
"builder",
"queryData",
12,
"x",
],
"prefix": "query",
"transformed": [
"query12",
"x",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips promql ⇄ [["promql"]] via transformPath / expandPath: prefix-promql 1`] = `
{
"expanded": [
"promql",
0,
"someField",
],
"match": [
"promql",
],
"prefix": "promql",
"transformed": [
"promql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips query ⇄ [["builder", "queryData"]] via transformPath / expandPath: prefix-query 1`] = `
{
"expanded": [
"builder",
"queryData",
0,
"someField",
],
"match": [
"builder",
"queryData",
],
"prefix": "query",
"transformed": [
"query0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips traceOp ⇄ [["builder", "queryTraceOperator"]] via transformPath / expandPath: prefix-traceOp 1`] = `
{
"expanded": [
"builder",
"queryTraceOperator",
0,
"someField",
],
"match": [
"builder",
"queryTraceOperator",
],
"prefix": "traceOp",
"transformed": [
"traceOp0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_REVERSE consistency mirrors PREFIX_PATTERNS one-to-one: all-prefix-reverse 1`] = `
{
"chsql": [
"clickhouse_sql",
],
"formula": [
"builder",
"queryFormulas",
],
"promql": [
"promql",
],
"query": [
"builder",
"queryData",
],
"traceOp": [
"builder",
"queryTraceOperator",
],
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric path segments untouched: numeric-passthrough 1`] = `
{
"seven": 7,
"zero": 0,
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric-string segments untouched in expandField: numeric-string-passthrough 1`] = `
{
"fortyTwo": "42",
"zero": "0",
}
`;
exports[`qsAlias maps alias / expand passthrough leaves unknown field names untouched: unknown-passthrough 1`] = `
{
"aliasUnknown": "unknownField",
"expandUnknown": "zz",
}
`;
exports[`qsAlias maps isOwnedKey matches chsql prefix with index: owned-chsql 1`] = `
{
"chsql0": true,
"chsql0.field": true,
"chsql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches delete-prefixed keys: delete-prefixed 1`] = `
{
"-formula0": true,
"-query0.field": true,
}
`;
exports[`qsAlias maps isOwnedKey matches formula prefix with index: owned-formula 1`] = `
{
"formula0": true,
"formula0.field": true,
"formula12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches promql prefix with index: owned-promql 1`] = `
{
"promql0": true,
"promql0.field": true,
"promql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches query prefix with index: owned-query 1`] = `
{
"query0": true,
"query0.field": true,
"query12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches the tag key: tag-key 1`] = `
{
"_t": true,
}
`;
exports[`qsAlias maps isOwnedKey matches top-level query keys: top-level-keys 1`] = `
{
"id": true,
"qt": true,
"queryType": true,
"unit": true,
}
`;
exports[`qsAlias maps isOwnedKey matches traceOp prefix with index: owned-traceOp 1`] = `
{
"traceOp0": true,
"traceOp0.field": true,
"traceOp12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey rejects foreign params: foreign-params 1`] = `
{
"compositeQuery": false,
"endTime": false,
"panelTypes": false,
"startTime": false,
}
`;
exports[`qsAlias maps isOwnedKey rejects prefix without index: prefix-without-index 1`] = `
{
"formula": false,
"query": false,
}
`;

View File

@@ -1,795 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "SELECT count() FROM signoz_logs",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "clickhouse_sql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-url 1`] = `"_t=QAm&chsql0.query=SELECT+count%28%29+FROM+signoz_logs&id=test-stable-id&qt=clickhouse_sql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "sum",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.legend=sum&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "p95",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": true,
"expression": "A",
"filter": {
"expression": "severity_text = 'ERROR'",
},
"filters": {
"items": [
{
"id": "item-1",
"key": {
"dataType": "string",
"isColumn": false,
"isJSON": false,
"key": "severity_text",
"type": "tag",
},
"op": "=",
"value": "ERROR",
},
],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "error rate",
"limit": null,
"orderBy": [
{
"columnName": "timestamp",
"order": "desc",
},
],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": 60,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=p95&query0.disabled=_true&query0.filter.expression=severity_text+%3D+%27ERROR%27&query0.filters.items.0.id=item-1&query0.filters.items.0.key.dt=string&query0.filters.items.0.key.ic=_false&query0.filters.items.0.key.ij=_false&query0.filters.items.0.key.key=severity_text&query0.filters.items.0.key.type=tag&query0.filters.items.0.op=%3D&query0.filters.items.0.value=ERROR&query0.legend=error+rate&query0.orderBy.0.columnName=timestamp&query0.orderBy.0.order=desc&query0.source=&query0.stepIn=_60"`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "rate(http_requests_total[5m])",
},
],
"queryType": "promql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-url 1`] = `"_t=QAm&id=test-stable-id&promql0.query=rate%28http_requests_total%5B5m%5D%29&qt=promql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "!weird = "x_y*z"",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "_a*b_*c",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.filter.expression=%21weird+%3D+%22x_y*z%22&query0.legend=__a*b_*c&query0.source="`;

View File

@@ -1,277 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter tagging encode tags by dataSource logs → QAl: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource metrics → QAm: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource traces → QAt: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAl decodes to the logs baseline: decoded-QAl 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAm decodes to the metrics baseline: decoded-QAm 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAt decodes to the traces baseline: decoded-QAt 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -1,213 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const makeFilterItem = (value: string): any => ({
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
},
id: `item-${value}`,
op: '=',
value,
});
describe('qsAliasAdapter edge cases', () => {
describe('baseline field deletion', () => {
it('emits a delete token and decode drops the field', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (query.builder.queryData[0] as any).aggregateOperator;
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('-query0.aggOp');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect('aggregateOperator' in decoded.builder.queryData[0]).toBe(false);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('array growth', () => {
it('round-trips multiple added filter items element-wise', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filters = {
op: 'AND',
items: [makeFilterItem('a'), makeFilterItem('b')],
};
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.filters.items.0.');
expect(wire).toContain('query0.filters.items.1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('null and empty containers', () => {
it('round-trips a null leaf', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).legend = null;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-object leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filter =
{} as Query['builder']['queryData'][0]['filter'];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-array leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].groupBy = [];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('undefined values', () => {
it('does not break decode when fields are undefined', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
expect(() => roundTrip(query)).not.toThrow();
const decoded = roundTrip(query);
expect(decoded).not.toBeNull();
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
/**
* The wire type-tags non-strings (`_123`, `_true`, `_null`) and emits strings
* verbatim, while qs percent-encodes values. Every scalar therefore
* round-trips losslessly — including strings that look like numbers/booleans
* or contain query-string delimiters.
*/
describe('tricky scalar values (lossless)', () => {
it('keeps a numeric-looking string as a string', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '123';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps "true" / "false" / "null" string values as strings', () => {
['true', 'false', 'null'].forEach((literal) => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = literal;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`url-${literal}`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${literal}`);
});
});
it('preserves a value containing the ampersand delimiter', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'x&y';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves assorted wire-special characters', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'a=b&c#d%e+f.g';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves a string that begins with the type-tag char', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_underscored';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('scalar type fidelity', () => {
it('keeps number and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].stepInterval = 300;
query.builder.queryData[0].legend = '300';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].stepInterval).toBe(300);
expect(decoded.builder.queryData[0].legend).toBe('300');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps boolean and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].disabled = true;
query.builder.queryData[0].legend = 'true';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].disabled).toBe(true);
expect(decoded.builder.queryData[0].legend).toBe('true');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -1,89 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
describe('qsAliasAdapter encoding format', () => {
describe('prefix substitution', () => {
it('rewrites builder.queryData.0 to the query0 prefix', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData[0].aggregateOperator = 'count';
const encoded = qsAliasAdapter.encode(query);
const keys = Array.from(encoded.keys());
expect(keys.some((k) => k.startsWith('query0.'))).toBe(true);
expect(keys.some((k) => k.includes('queryData'))).toBe(false);
expect(keys.some((k) => k.includes('builder'))).toBe(false);
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('field aliasing', () => {
it('emits the short alias instead of the full field name', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].aggregateOperator = 'sum';
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.aggOp=');
expect(wire).not.toContain('aggregateOperator');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('stability', () => {
it('re-encoding after a decode is byte-identical', () => {
const encoded1 = qsAliasAdapter.encode(initialQueriesMap.metrics);
const encoded2 = qsAliasAdapter.encode(qsAliasAdapter.decode(encoded1));
expect(encoded2.toString()).toBe(encoded1.toString());
expect(normalizeUrl(encoded1.toString())).toMatchSnapshot('url');
expect(normalizeId(qsAliasAdapter.decode(encoded1))).toMatchSnapshot(
'decoded',
);
});
it('is independent of source key order', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const wire1 = qsAliasAdapter.encode(query1).toString();
const wire2 = qsAliasAdapter.encode(reordered).toString();
expect(wire2).toBe(wire1);
expect(normalizeUrl(wire1)).toMatchSnapshot('url');
});
it('is stable after spread / reconstruct', () => {
const query = { ...initialQueriesMap.metrics };
const transformed = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({ ...item })),
},
};
const wire = qsAliasAdapter.encode(transformed).toString();
expect(wire).toBe(qsAliasAdapter.encode(query).toString());
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
});

View File

@@ -1,154 +0,0 @@
import { Json } from '../diff/predicates';
import { decodeLeaf, encodeLeaf } from '../leaf';
describe('qsAlias leaf codec', () => {
describe('encodeLeaf', () => {
it('emits strings verbatim', () => {
expect(encodeLeaf('traces')).toBe('traces');
expect(encodeLeaf('service.name')).toBe('service.name');
expect(encodeLeaf('')).toBe('');
expect({
traces: encodeLeaf('traces'),
'service.name': encodeLeaf('service.name'),
empty: encodeLeaf(''),
}).toMatchSnapshot('encoded-strings');
});
it('type-tags non-string scalars with a leading underscore', () => {
expect(encodeLeaf(123)).toBe('_123');
expect(encodeLeaf(-4.5)).toBe('_-4.5');
expect(encodeLeaf(true)).toBe('_true');
expect(encodeLeaf(false)).toBe('_false');
expect(encodeLeaf(null)).toBe('_null');
expect({
number: encodeLeaf(123),
negative: encodeLeaf(-4.5),
true: encodeLeaf(true),
false: encodeLeaf(false),
null: encodeLeaf(null),
}).toMatchSnapshot('encoded-scalars');
});
it('type-tags empty containers', () => {
expect(encodeLeaf([])).toBe('_[]');
expect(encodeLeaf({})).toBe('_{}');
expect({
array: encodeLeaf([]),
object: encodeLeaf({}),
}).toMatchSnapshot('encoded-containers');
});
it('normalizes undefined to null', () => {
expect(encodeLeaf(undefined)).toBe('_null');
expect({ undefined: encodeLeaf(undefined) }).toMatchSnapshot(
'encoded-undefined',
);
});
it('escapes a string that begins with the tag char by doubling it', () => {
expect(encodeLeaf('_x')).toBe('__x');
expect(encodeLeaf('_')).toBe('__');
expect(encodeLeaf('__name__')).toBe('___name__');
expect({
_x: encodeLeaf('_x'),
_: encodeLeaf('_'),
__name__: encodeLeaf('__name__'),
}).toMatchSnapshot('encoded-escaped');
});
});
describe('decodeLeaf', () => {
it('returns untagged tokens as plain strings', () => {
expect(decodeLeaf('traces')).toBe('traces');
expect(decodeLeaf('123')).toBe('123');
expect(decodeLeaf('true')).toBe('true');
expect(decodeLeaf('null')).toBe('null');
expect(decodeLeaf('')).toBe('');
expect({
traces: decodeLeaf('traces'),
'123': decodeLeaf('123'),
true: decodeLeaf('true'),
null: decodeLeaf('null'),
empty: decodeLeaf(''),
}).toMatchSnapshot('decoded-strings');
});
it('parses tagged scalars back to their type', () => {
expect(decodeLeaf('_123')).toBe(123);
expect(decodeLeaf('_-4.5')).toBe(-4.5);
expect(decodeLeaf('_true')).toBe(true);
expect(decodeLeaf('_false')).toBe(false);
expect(decodeLeaf('_null')).toBeNull();
expect({
number: decodeLeaf('_123'),
negative: decodeLeaf('_-4.5'),
true: decodeLeaf('_true'),
false: decodeLeaf('_false'),
null: decodeLeaf('_null'),
}).toMatchSnapshot('decoded-scalars');
});
it('parses tagged empty containers', () => {
expect(decodeLeaf('_[]')).toStrictEqual([]);
expect(decodeLeaf('_{}')).toStrictEqual({});
expect({
array: decodeLeaf('_[]'),
object: decodeLeaf('_{}'),
}).toMatchSnapshot('decoded-containers');
});
it('unescapes a doubled-tag string', () => {
expect(decodeLeaf('__x')).toBe('_x');
expect(decodeLeaf('__')).toBe('_');
expect(decodeLeaf('___name__')).toBe('__name__');
expect({
__x: decodeLeaf('__x'),
__: decodeLeaf('__'),
___name__: decodeLeaf('___name__'),
}).toMatchSnapshot('decoded-escaped');
});
it('falls back to raw text on a malformed tagged token (never throws)', () => {
expect(() => decodeLeaf('_not json')).not.toThrow();
expect(decodeLeaf('_not json')).toBe('_not json');
expect({ fallback: decodeLeaf('_not json') }).toMatchSnapshot(
'decoded-fallback',
);
});
});
describe('round-trip', () => {
const cases: Json[] = [
'traces',
'',
'123',
'true',
'false',
'null',
'_leading',
'_',
'a=b&c#d%e+f.g',
'service.name',
0,
123,
-4.5,
true,
false,
null,
[],
{},
];
it.each(cases.map((value) => [JSON.stringify(value), value] as const))(
'%s survives encode → decode',
(label, value) => {
const encoded = encodeLeaf(value);
const decoded = decodeLeaf(encoded);
expect(decoded).toStrictEqual(value);
expect({ input: value, encoded, decoded }).toMatchSnapshot(
`roundtrip-${label}`,
);
},
);
});
});

View File

@@ -1,179 +0,0 @@
import { aliasField, expandField, expandPath, transformPath } from '../codec';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from '../maps';
describe('qsAlias maps', () => {
describe('FIELD_ALIASES — every key round-trips', () => {
it.each(Object.entries(FIELD_ALIASES))(
'%s ⇄ %s via aliasField / expandField',
(field, alias) => {
expect(aliasField(field)).toBe(alias);
expect(expandField(alias)).toBe(field);
expect({
field,
alias,
aliased: aliasField(field),
expanded: expandField(alias),
}).toMatchSnapshot(`alias-${field}`);
},
);
});
describe('FIELD_ALIASES integrity', () => {
it('alias values are unique (no two fields share an alias)', () => {
const values = Object.values(FIELD_ALIASES);
expect(new Set(values).size).toBe(values.length);
expect(FIELD_ALIASES).toMatchSnapshot('all-aliases');
});
it('no alias contains "." (would corrupt path splitting)', () => {
Object.values(FIELD_ALIASES).forEach((alias) => {
expect(alias).not.toContain('.');
});
});
it('FIELD_REVERSE is the exact inverse of FIELD_ALIASES', () => {
expect(FIELD_REVERSE).toStrictEqual(
Object.fromEntries(
Object.entries(FIELD_ALIASES).map(([key, value]) => [value, key]),
),
);
expect(FIELD_REVERSE).toMatchSnapshot('all-reverse');
});
});
describe('PREFIX_PATTERNS — every prefix round-trips', () => {
it.each(PREFIX_PATTERNS)(
'$prefix ⇄ [$match] via transformPath / expandPath',
({ match, prefix }) => {
const fullPath = [...match, 0, 'someField'];
const transformed = transformPath(fullPath);
const expanded = expandPath(`${prefix}0.someField`);
expect(transformed).toStrictEqual([`${prefix}0`, 'someField']);
expect(expanded).toStrictEqual([...match, 0, 'someField']);
expect({ prefix, match, transformed, expanded }).toMatchSnapshot(
`prefix-${prefix}`,
);
},
);
it('handles multi-digit indices', () => {
const { match, prefix } = PREFIX_PATTERNS[0];
const transformed = transformPath([...match, 12, 'x']);
const expanded = expandPath(`${prefix}12.x`);
expect(transformed).toStrictEqual([`${prefix}12`, 'x']);
expect(expanded).toStrictEqual([...match, 12, 'x']);
expect({ prefix, transformed, expanded }).toMatchSnapshot('multi-digit');
});
});
describe('PREFIX_REVERSE consistency', () => {
it('mirrors PREFIX_PATTERNS one-to-one', () => {
PREFIX_PATTERNS.forEach(({ match, prefix }) => {
expect(PREFIX_REVERSE[prefix]).toStrictEqual(match);
});
expect(Object.keys(PREFIX_REVERSE).sort()).toStrictEqual(
PREFIX_PATTERNS.map((pattern) => pattern.prefix).sort(),
);
expect(PREFIX_REVERSE).toMatchSnapshot('all-prefix-reverse');
});
});
describe('alias / expand passthrough', () => {
it('leaves numeric path segments untouched', () => {
expect(aliasField(0)).toBe(0);
expect(aliasField(7)).toBe(7);
expect({ zero: aliasField(0), seven: aliasField(7) }).toMatchSnapshot(
'numeric-passthrough',
);
});
it('leaves unknown field names untouched', () => {
expect(aliasField('unknownField')).toBe('unknownField');
expect(expandField('zz')).toBe('zz');
expect({
aliasUnknown: aliasField('unknownField'),
expandUnknown: expandField('zz'),
}).toMatchSnapshot('unknown-passthrough');
});
it('leaves numeric-string segments untouched in expandField', () => {
expect(expandField('0')).toBe('0');
expect(expandField('42')).toBe('42');
expect({
zero: expandField('0'),
fortyTwo: expandField('42'),
}).toMatchSnapshot('numeric-string-passthrough');
});
});
describe('isOwnedKey', () => {
it('matches the tag key', () => {
expect(isOwnedKey('_t')).toBe(true);
expect({ _t: isOwnedKey('_t') }).toMatchSnapshot('tag-key');
});
it.each(PREFIX_PATTERNS.map((p) => p.prefix))(
'matches %s prefix with index',
(prefix) => {
expect(isOwnedKey(`${prefix}0`)).toBe(true);
expect(isOwnedKey(`${prefix}0.field`)).toBe(true);
expect(isOwnedKey(`${prefix}12.nested.path`)).toBe(true);
expect({
[`${prefix}0`]: isOwnedKey(`${prefix}0`),
[`${prefix}0.field`]: isOwnedKey(`${prefix}0.field`),
[`${prefix}12.nested.path`]: isOwnedKey(`${prefix}12.nested.path`),
}).toMatchSnapshot(`owned-${prefix}`);
},
);
it('matches delete-prefixed keys', () => {
expect(isOwnedKey('-query0.field')).toBe(true);
expect(isOwnedKey('-formula0')).toBe(true);
expect({
'-query0.field': isOwnedKey('-query0.field'),
'-formula0': isOwnedKey('-formula0'),
}).toMatchSnapshot('delete-prefixed');
});
it('matches top-level query keys', () => {
expect(isOwnedKey('id')).toBe(true);
expect(isOwnedKey('queryType')).toBe(true);
expect(isOwnedKey('qt')).toBe(true);
expect(isOwnedKey('unit')).toBe(true);
expect({
id: isOwnedKey('id'),
queryType: isOwnedKey('queryType'),
qt: isOwnedKey('qt'),
unit: isOwnedKey('unit'),
}).toMatchSnapshot('top-level-keys');
});
it('rejects foreign params', () => {
expect(isOwnedKey('panelTypes')).toBe(false);
expect(isOwnedKey('startTime')).toBe(false);
expect(isOwnedKey('endTime')).toBe(false);
expect(isOwnedKey('compositeQuery')).toBe(false);
expect({
panelTypes: isOwnedKey('panelTypes'),
startTime: isOwnedKey('startTime'),
endTime: isOwnedKey('endTime'),
compositeQuery: isOwnedKey('compositeQuery'),
}).toMatchSnapshot('foreign-params');
});
it('rejects prefix without index', () => {
expect(isOwnedKey('query')).toBe(false);
expect(isOwnedKey('formula')).toBe(false);
expect({
query: isOwnedKey('query'),
formula: isOwnedKey('formula'),
}).toMatchSnapshot('prefix-without-index');
});
});
});

View File

@@ -1,364 +0,0 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
} from 'constants/queryBuilder';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
const makeSecondBuilderQuery = (name: string): IBuilderQuery => ({
...clone(initialQueryBuilderFormValuesMap.metrics),
queryName: name,
aggregateOperator: 'avg',
legend: `${name} legend`,
});
const makeFormula = (name: string, expression: string): IBuilderFormula => ({
queryName: name,
expression,
disabled: false,
legend: `${name} result`,
});
describe('qsAliasAdapter multi-queryData', () => {
describe('multiple builder queries', () => {
it('round-trips two queryData entries (A + B)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three queryData entries (A + B + C)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('formula queries', () => {
it('round-trips single formula F1 = A/B', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips multiple formulas F1 + F2', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
query.builder.queryFormulas.push(makeFormula('F2', 'A*100/C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips formula with complex expression', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', '(A - B) / B * 100'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple clickhouse queries', () => {
it('round-trips two clickhouse_sql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query =
'SELECT count() FROM logs WHERE severity > 0';
query.clickhouse_sql.push({
name: 'B',
legend: 'total',
disabled: false,
query: 'SELECT count() FROM logs',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three clickhouse_sql entries with mixed disabled states', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT 1';
query.clickhouse_sql.push({
name: 'B',
legend: 'second',
disabled: true,
query: 'SELECT 2',
});
query.clickhouse_sql.push({
name: 'C',
legend: '',
disabled: false,
query: 'SELECT 3',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple promql queries', () => {
it('round-trips two promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
query.promql.push({
name: 'B',
legend: 'errors',
disabled: false,
query: 'rate(http_errors_total[5m])',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'metric_a';
query.promql.push({
name: 'B',
legend: 'b-legend',
disabled: false,
query: 'metric_b',
});
query.promql.push({
name: 'C',
legend: '',
disabled: true,
query: 'metric_c',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('mixed data sources within builder', () => {
it('round-trips logs queryData with formulas', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.logs),
queryName: 'B',
aggregateOperator: 'count_distinct',
});
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips traces queryData with formulas', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.traces),
queryName: 'B',
aggregateOperator: 'p99',
});
query.builder.queryFormulas.push(makeFormula('F1', 'B - A'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('wire format verification', () => {
it('encodes multiple queryData with indexed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.');
expect(wire).toContain('query1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes formulas with formula-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(query.builder.queryFormulas).toHaveLength(1);
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
expect(wire).toContain('formula0.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes clickhouse with chsql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql.push({
name: 'B',
legend: '',
disabled: false,
query: 'SELECT 1',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('chsql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes promql with promql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql.push({
name: 'B',
legend: '',
disabled: false,
query: 'metric_b',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('promql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('template diffing optimization', () => {
it('added queryData only emits changed fields vs baseline[0]', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
legend: 'B query',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const query1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('query1.'),
);
// Should have ~4-5 params (qn, aggOp, legend, source), not ~25
expect(query1Params.length).toBeLessThan(10);
// Should NOT have unchanged fields
expect(wire).not.toContain('query1.filters.op');
expect(wire).not.toContain('query1.groupBy');
expect(wire).not.toContain('query1.having');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('decoder correctly reconstructs from template-diffed wire', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
// Wire should be compact
expect(wire).not.toContain('query1.filters.op');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('works for queryFormulas with template inheritance', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryFormulas.push(makeFormula('F1', 'A'));
query.builder.queryFormulas.push({
...makeFormula('F2', 'B'),
disabled: true,
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const f1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula0.'),
);
const f2Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula1.'),
);
// F2 should be smaller or equal (diffs against F1)
expect(f2Params.length).toBeLessThanOrEqual(f1Params.length);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -1,52 +0,0 @@
import { isEqual } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../../testing/scenarios';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
describe('qsAliasAdapter round-trip', () => {
describe('scenarios', () => {
it.each(roundTripScenarios)(
'$name survives encode → decode',
({ query, name }) => {
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`${name}-url`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`${name}-decoded`);
},
);
});
it('decoded query keeps exactly the source top-level keys', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
expect(Object.keys(decoded).sort()).toStrictEqual(
Object.keys(initialQueriesMap.metrics).sort(),
);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('is lodash isEqual to the source (ignoring volatile id)', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
const { id: _sourceId, ...source } = initialQueriesMap.metrics;
const { id: _decodedId, ...result } = decoded;
expect(isEqual(source, result)).toBe(true);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});

View File

@@ -1,92 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { decodeQsAlias, encodeQsAlias, qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const tagOf = (params: URLSearchParams): string => params.get('_t') ?? '';
describe('qsAliasAdapter tagging', () => {
describe('encode tags by dataSource', () => {
it('metrics → QAm', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.metrics);
expect(tagOf(encoded)).toBe('QAm');
expect(encodeQsAlias(initialQueriesMap.metrics).tag).toBe('QAm');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('logs → QAl', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.logs);
expect(tagOf(encoded)).toBe('QAl');
expect(encodeQsAlias(initialQueriesMap.logs).tag).toBe('QAl');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('traces → QAt', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.traces);
expect(tagOf(encoded)).toBe('QAt');
expect(encodeQsAlias(initialQueriesMap.traces).tag).toBe('QAt');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('matches', () => {
it('matches its own QAm/QAl/QAt tags', () => {
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.metrics)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.logs)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.traces)),
).toBe(true);
});
it('rejects another serializer tag', () => {
const params = new URLSearchParams();
params.set('_t', 'FVm~');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects the legacy compositeQuery param', () => {
const params = new URLSearchParams();
params.set('compositeQuery', '{"queryType":"builder"}');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects empty params', () => {
expect(qsAliasAdapter.matches(new URLSearchParams())).toBe(false);
});
});
describe('tag-only decode returns the baseline', () => {
it.each([
['QAm', 'metrics'],
['QAl', 'logs'],
['QAt', 'traces'],
] as const)('%s decodes to the %s baseline', (tag, dataSource) => {
const params = new URLSearchParams();
params.set('_t', tag);
const decoded = decodeQsAlias(params);
expect(decoded.queryType).toBe('builder');
expect(decoded.builder.queryData[0].dataSource).toBe(dataSource);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${tag}`);
});
it('round-trips the baseline with no extra params', () => {
const { params, tag } = encodeQsAlias(initialQueriesMap.logs);
expect(tag).toBe('QAl');
expect(normalizeUrl(params.toString())).toMatchSnapshot('url');
const decoded = decodeQsAlias(params);
expect(decoded).toStrictEqual(initialQueriesMap.logs);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -1,302 +0,0 @@
/**
* qsAlias codec: content-aware URL serialization with prefix substitution
* and field aliasing for readable, compact URLs.
*
* Wire format: multiple query params with aliased paths
* _t=QAm&query0.ds=traces&query0.aa.key=http.status_code&query0.fl.it.0.key.key=service.name
*
* Prefix substitution: builder.queryData.0 → query0
* Field aliasing: aggregateAttribute → aa, filters → fl, etc.
*/
import set from 'lodash-es/set';
import qs from 'qs';
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { computeDiff, DiffCode } from './diff/diff';
import { isLeaf, Json, PathSeg } from './diff/predicates';
import { decodeLeaf, encodeLeaf } from './leaf';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from './maps';
const TAG_KEY = '_t';
const DEL_PREFIX = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
function matchesPrefix(path: PathSeg[], match: string[]): boolean {
for (let i = 0; i < match.length; i++) {
if (path[i] !== match[i]) {
return false;
}
}
return true;
}
// Path/alias helpers below are exported for direct unit testing; the adapter's
// public surface (index.ts) still exposes only encode/decode.
export function aliasField(seg: PathSeg): PathSeg {
if (typeof seg === 'number') {
return seg;
}
return FIELD_ALIASES[seg] ?? seg;
}
export function expandField(seg: string): string {
if (isIndex(seg)) {
return seg;
}
return FIELD_REVERSE[seg] ?? seg;
}
export function transformPath(path: PathSeg[]): PathSeg[] {
for (const { match, prefix } of PREFIX_PATTERNS) {
if (path.length > match.length && matchesPrefix(path, match)) {
const idx = path[match.length];
if (typeof idx === 'number') {
const rest = path.slice(match.length + 1).map(aliasField);
return [`${prefix}${idx}`, ...rest];
}
}
}
return path.map(aliasField);
}
export function expandPath(pathStr: string): PathSeg[] {
const segs = pathStr.split('.');
const first = segs[0];
for (const [prefixName, originalPath] of Object.entries(PREFIX_REVERSE)) {
const match = first.match(new RegExp(`^${prefixName}(\\d+)$`));
if (match) {
const idx = parseInt(match[1], 10);
const rest = segs.slice(1).map(expandField);
return [...originalPath, idx, ...rest];
}
}
return segs.map((s) => (isIndex(s) ? parseInt(s, 10) : expandField(s)));
}
function flattenValue(
target: Record<string, string>,
prefix: string,
value: Json,
): void {
if (value === null || typeof value !== 'object') {
target[prefix] = encodeLeaf(value);
return;
}
if (Array.isArray(value)) {
if (value.length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (let i = 0; i < value.length; i++) {
flattenValue(target, `${prefix}.${i}`, value[i]);
}
return;
}
const obj = value as Record<string, Json>;
if (Object.keys(obj).length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (const [k, v] of Object.entries(obj)) {
flattenValue(target, `${prefix}.${aliasField(k)}`, v);
}
}
function diffToFlatObject(
baseline: Query,
query: Query,
): Record<string, string> {
const ops = computeDiff(baseline, query);
const obj: Record<string, string> = {};
for (const [code, path, value] of ops) {
const key = transformPath(path).join('.');
if (code === DiffCode.Delete) {
obj[`${DEL_PREFIX}${key}`] = '';
} else if (typeof value === 'object' && value !== null) {
flattenValue(obj, key, value);
} else {
obj[key] = encodeLeaf(value);
}
}
return obj;
}
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
const segs = path.split('.').map((s) => (isIndex(s) ? parseInt(s, 10) : s));
set(root, segs, value);
});
return root;
}
/**
* Clone baseline[0] paths to a higher index for template-based array diffing.
* When encoder emits `query1.aggOp=avg`, decoder needs `builder.queryData.1.*`
* to exist first (cloned from index 0) before applying the patch.
*/
function ensureArrayIndexFromTemplate(
baseMap: Record<string, Json>,
arrayPrefix: string,
targetIndex: number,
): void {
const sourcePrefix = `${arrayPrefix}.0.`;
const targetPrefix = `${arrayPrefix}.${targetIndex}.`;
// Skip if target already has entries (already cloned or from baseline)
const hasTarget = Object.keys(baseMap).some((k) => k.startsWith(targetPrefix));
if (hasTarget) {
return;
}
// Clone all index-0 paths to target index
for (const [path, value] of Object.entries(baseMap)) {
if (path.startsWith(sourcePrefix)) {
const suffix = path.slice(sourcePrefix.length);
baseMap[`${targetPrefix}${suffix}`] = value;
}
}
}
export function encode(query: Query): { params: URLSearchParams; tag: string } {
const { baseline, tag } = pickBaseline(query);
const obj = diffToFlatObject(baseline, query);
// `encodeValuesOnly` percent-encodes values (so `&`, `=`, `%`, … survive)
// while leaving the readable dotted keys untouched.
const queryString = qs.stringify(
{ [TAG_KEY]: `QA${tag}`, ...obj },
{
encodeValuesOnly: true,
sort: (a, b) => a.localeCompare(b),
},
);
return { params: new URLSearchParams(queryString), tag: `QA${tag}` };
}
/**
* When a nested path like `a.b.0.c` is set, any ancestor empty-container entry
* (`a.b` = `[]`) must be removed or `rebuildFromLeaves` order may clobber it.
*/
function deleteAncestorEmptyContainers(
map: Record<string, Json>,
fullPath: string,
): void {
const segs = fullPath.split('.');
for (let i = 1; i < segs.length; i += 1) {
const ancestor = segs.slice(0, i).join('.');
const value = map[ancestor];
if (
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' &&
value !== null &&
Object.keys(value).length === 0)
) {
delete map[ancestor];
}
}
}
/**
* Check if expanded path refers to an array element beyond index 0.
* Returns [arrayPrefix, index] if so, null otherwise.
*/
function detectArrayGrowth(expandedPath: PathSeg[]): [string, number] | null {
for (const { match } of PREFIX_PATTERNS) {
if (expandedPath.length > match.length) {
const matchesPattern = match.every((seg, i) => expandedPath[i] === seg);
if (matchesPattern) {
const idx = expandedPath[match.length];
if (typeof idx === 'number' && idx > 0) {
return [match.join('.'), idx];
}
}
}
}
return null;
}
export function decode(params: URLSearchParams): Query {
const parsed = qs.parse(params.toString()) as Record<string, unknown>;
const tagValue = (parsed[TAG_KEY] as string) ?? '';
const baselineTag = tagValue.slice(2) as BaselineTag;
const baseline = getBaselineByTag(baselineTag);
const baseMap = leafMap(baseline);
const clonedIndices = new Set<string>();
for (const [key, value] of Object.entries(parsed)) {
if (key === TAG_KEY) {
continue;
}
// Skip foreign params (e.g. panelTypes, startTime) that qs.parse included.
if (!isOwnedKey(key)) {
continue;
}
if (key.startsWith(DEL_PREFIX)) {
const expandedPath = expandPath(key.slice(1));
const shortPath = expandedPath.join('.');
for (const basePath of Object.keys(baseMap)) {
if (basePath === shortPath || basePath.startsWith(`${shortPath}.`)) {
delete baseMap[basePath];
}
}
continue;
}
const expandedPath = expandPath(key);
// For paths like builder.queryData.1.*, clone from index 0 first
const growth = detectArrayGrowth(expandedPath);
if (growth) {
const [arrayPrefix, idx] = growth;
const cacheKey = `${arrayPrefix}.${idx}`;
if (!clonedIndices.has(cacheKey)) {
ensureArrayIndexFromTemplate(baseMap, arrayPrefix, idx);
clonedIndices.add(cacheKey);
}
}
const fullPath = expandedPath.join('.');
deleteAncestorEmptyContainers(baseMap, fullPath);
baseMap[fullPath] = typeof value === 'string' ? decodeLeaf(value) : value;
}
return rebuildFromLeaves(baseMap) as unknown as Query;
}

View File

@@ -1,342 +0,0 @@
import {
computeDiff,
DiffCode,
DiffOp,
diffArrays,
diffNodes,
diffObjects,
} from '../diff';
const noop = (): void => undefined;
const paths = (ops: DiffOp[]): string[] =>
ops.map(([, path]) => path.join('.'));
describe('qsAlias/diff', () => {
describe('DiffCode', () => {
it('has stable wire-significant numeric codes', () => {
// These leak onto the URL via the codec, so they must not drift.
expect(DiffCode.Set).toBe(1);
expect(DiffCode.Delete).toBe(2);
});
});
describe('computeDiff on leaves', () => {
it('returns no ops when scalars are equal', () => {
expect(computeDiff('a', 'a')).toStrictEqual([]);
expect(computeDiff(1, 1)).toStrictEqual([]);
expect(computeDiff(true, true)).toStrictEqual([]);
expect(computeDiff(null, null)).toStrictEqual([]);
});
it('emits a single Set rooted at [] when scalars differ', () => {
expect(computeDiff(1, 2)).toStrictEqual([[DiffCode.Set, [], 2]]);
expect(computeDiff('a', 'b')).toStrictEqual([[DiffCode.Set, [], 'b']]);
expect(computeDiff(true, false)).toStrictEqual([[DiffCode.Set, [], false]]);
});
it('distinguishes null, false, 0 and empty string', () => {
expect(computeDiff(null, false)).toStrictEqual([[DiffCode.Set, [], false]]);
expect(computeDiff(0, '')).toStrictEqual([[DiffCode.Set, [], '']]);
expect(computeDiff(0, null)).toStrictEqual([[DiffCode.Set, [], null]]);
});
});
describe('computeDiff on objects', () => {
it('returns no ops for deep-equal objects', () => {
expect(
computeDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }),
).toStrictEqual([]);
});
it('emits Set for an added key', () => {
expect(computeDiff({ a: 1 }, { a: 1, b: 2 })).toStrictEqual([
[DiffCode.Set, ['b'], 2],
]);
});
it('emits Delete (undefined value) for a removed key', () => {
expect(computeDiff({ a: 1, b: 2 }, { a: 1 })).toStrictEqual([
[DiffCode.Delete, ['b'], undefined],
]);
});
it('emits Set at the nested path for a changed deep value', () => {
expect(
computeDiff({ a: { b: { c: 1 } } }, { a: { b: { c: 9 } } }),
).toStrictEqual([[DiffCode.Set, ['a', 'b', 'c'], 9]]);
});
it('produces deterministic op order following base-then-query keys', () => {
const base = { ds: 'logs', ag: [{ mn: 'x', ao: 'noop' }], gb: [] };
const query = {
ds: 'traces',
ag: [{ mn: 'x', ao: 'sum' }, { mn: 'y' }],
gb: [],
};
// Generic arrays use wholesale SET for added elements.
// Template diffing only applies to known query builder arrays.
expect(computeDiff(base, query)).toStrictEqual([
[DiffCode.Set, ['ds'], 'traces'],
[DiffCode.Set, ['ag', 0, 'ao'], 'sum'],
[DiffCode.Set, ['ag', 1], { mn: 'y' }],
]);
});
});
describe('diffArrays', () => {
it('defaults the path to [] and diffs element-wise', () => {
expect(diffArrays([1, 2], [1, 9])).toStrictEqual([[DiffCode.Set, [1], 9]]);
});
it('Sets appended elements at their new index', () => {
expect(diffArrays([1], [1, 2, 3])).toStrictEqual([
[DiffCode.Set, [1], 2],
[DiffCode.Set, [2], 3],
]);
});
it('Deletes trailing elements removed from the query', () => {
expect(diffArrays([1, 2, 3], [1])).toStrictEqual([
[DiffCode.Delete, [1], undefined],
[DiffCode.Delete, [2], undefined],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffArrays([1], [2], ['items'])).toStrictEqual([
[DiffCode.Set, ['items', 0], 2],
]);
});
});
describe('template diffing for query builder arrays', () => {
const baseQuery = { qn: 'A', aggOp: 'count', ds: 'metrics' };
it('uses template for builder.queryData path', () => {
const base = [baseQuery];
const query = [baseQuery, { qn: 'B', aggOp: 'avg', ds: 'metrics' }];
const ops = diffArrays(base, query, ['builder', 'queryData']);
// Should diff query[1] against query[0], not wholesale SET
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryData', 1, 'qn'], 'B'],
[DiffCode.Set, ['builder', 'queryData', 1, 'aggOp'], 'avg'],
]);
});
it('uses template for builder.queryFormulas path', () => {
const baseFormula = { qn: 'F1', expression: 'A', disabled: false };
const base = [baseFormula];
const query = [
baseFormula,
{ qn: 'F2', expression: 'A+B', disabled: false },
];
const ops = diffArrays(base, query, ['builder', 'queryFormulas']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'qn'], 'F2'],
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'expression'], 'A+B'],
]);
});
it('uses template for promql path', () => {
const baseProm = { name: 'A', query: 'up', legend: '', disabled: false };
const base = [baseProm];
const query = [
baseProm,
{ name: 'B', query: 'down', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['promql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['promql', 1, 'name'], 'B'],
[DiffCode.Set, ['promql', 1, 'query'], 'down'],
]);
});
it('uses template for clickhouse_sql path', () => {
const baseCh = { name: 'A', query: 'SELECT 1', legend: '', disabled: false };
const base = [baseCh];
const query = [
baseCh,
{ name: 'B', query: 'SELECT 2', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['clickhouse_sql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['clickhouse_sql', 1, 'name'], 'B'],
[DiffCode.Set, ['clickhouse_sql', 1, 'query'], 'SELECT 2'],
]);
});
it('does NOT use template for unknown paths', () => {
const base = [{ a: 1 }];
const query = [{ a: 1 }, { a: 2 }];
const ops = diffArrays(base, query, ['unknown', 'path']);
// Should emit wholesale SET, not field-level diff
expect(ops).toStrictEqual([
[DiffCode.Set, ['unknown', 'path', 1], { a: 2 }],
]);
});
it('emits DELETE for fields removed vs template', () => {
const base = [{ qn: 'A', aggOp: 'count', extra: 'field' }];
const query = [base[0], { qn: 'B', aggOp: 'avg' }]; // no 'extra'
const ops = diffArrays(base, query, ['builder', 'queryData']);
expect(ops).toContainEqual([
DiffCode.Delete,
['builder', 'queryData', 1, 'extra'],
undefined,
]);
});
});
describe('diffObjects', () => {
it('defaults the path to [] and diffs by own keys', () => {
expect(diffObjects({ a: 1 }, { a: 2 })).toStrictEqual([
[DiffCode.Set, ['a'], 2],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffObjects({ a: 1 }, { a: 2 }, ['root'])).toStrictEqual([
[DiffCode.Set, ['root', 'a'], 2],
]);
});
});
describe('diffNodes shape transitions', () => {
it('replaces a leaf with a container wholesale', () => {
expect(diffNodes('a', { b: 1 })).toStrictEqual([
[DiffCode.Set, [], { b: 1 }],
]);
});
it('replaces a container with a leaf wholesale', () => {
expect(diffNodes({ b: 1 }, 'a')).toStrictEqual([[DiffCode.Set, [], 'a']]);
});
it('walks empty-to-non-empty array element-wise (for prefix substitution)', () => {
expect(diffNodes([], [1])).toStrictEqual([[DiffCode.Set, [0], 1]]);
});
it('emits SET [] when clearing a non-empty array (preserves empty array)', () => {
expect(diffNodes([1], [])).toStrictEqual([[DiffCode.Set, [], []]]);
expect(diffNodes([1, 2, 3], [])).toStrictEqual([[DiffCode.Set, [], []]]);
});
it('diffs array-vs-object key-wise (indices become string keys)', () => {
expect(diffNodes([1, 2], { 0: 'a' })).toStrictEqual([
[DiffCode.Set, ['0'], 'a'],
[DiffCode.Delete, ['1'], undefined],
]);
});
});
describe('undefined data', () => {
it('does not diff undefined against undefined', () => {
expect(computeDiff(undefined, undefined)).toStrictEqual([]);
expect(computeDiff({ a: undefined }, { a: undefined })).toStrictEqual([]);
});
it('Sets a real value over a baseline undefined', () => {
expect(computeDiff({ a: undefined }, { a: 1 })).toStrictEqual([
[DiffCode.Set, ['a'], 1],
]);
});
it('Sets undefined over a baseline value', () => {
expect(computeDiff({ a: 1 }, { a: undefined })).toStrictEqual([
[DiffCode.Set, ['a'], undefined],
]);
});
it('never throws when either whole input is undefined', () => {
expect(() => computeDiff(undefined, { a: 1 })).not.toThrow();
expect(() => computeDiff({ a: 1 }, undefined)).not.toThrow();
expect(computeDiff(undefined, { a: 1 })).toStrictEqual([
[DiffCode.Set, [], { a: 1 }],
]);
});
});
describe('unsupported / non-JSON values', () => {
it('treats functions as leaves and never throws', () => {
expect(() => computeDiff({ fn: noop }, { fn: noop })).not.toThrow();
// Two functions both serialize to `undefined`, so they look equal.
expect(computeDiff({ fn: noop }, { fn: noop })).toStrictEqual([]);
});
it('Sets a function over a scalar (treated as a differing leaf)', () => {
const ops = computeDiff({ a: 1 }, { a: noop });
expect(ops).toHaveLength(1);
expect(ops[0][0]).toBe(DiffCode.Set);
expect(ops[0][1]).toStrictEqual(['a']);
});
it('does not throw on NaN / Infinity leaves', () => {
expect(() => computeDiff({ a: NaN }, { a: Infinity })).not.toThrow();
// Both stringify to "null", so the diff cannot tell them apart.
expect(computeDiff({ a: NaN }, { a: Infinity })).toStrictEqual([]);
});
});
describe('prototype-pollution hardening', () => {
afterEach(() => {
// Guard against the test itself leaking pollution into later suites.
delete (Object.prototype as Record<string, unknown>).polluted;
});
it('skips a JSON-injected own __proto__ key (emits no op for it)', () => {
const malicious = JSON.parse(
'{"safe":2,"__proto__":{"polluted":true}}',
) as Record<string, unknown>;
// Base must be a non-empty object so both sides reach diffObjects;
// an empty `{}` is a leaf and would collapse to a wholesale Set.
const ops = computeDiff({ safe: 1 }, malicious);
expect(paths(ops)).toStrictEqual(['safe']);
expect(paths(ops)).not.toContain('__proto__');
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it('skips own constructor and prototype keys', () => {
const ops = diffObjects({}, {
constructor: 'x',
prototype: 'y',
safe: 1,
} as Record<string, unknown>);
expect(paths(ops)).toStrictEqual(['safe']);
});
it('emits no Delete op when the baseline carries a forbidden key', () => {
const ops = diffObjects({ constructor: 'x' } as Record<string, unknown>, {});
expect(ops).toStrictEqual([]);
});
it('skips a nested __proto__ key reached via recursion', () => {
const malicious = JSON.parse(
'{"a":{"keep":1,"__proto__":{"polluted":true}}}',
) as Record<string, unknown>;
const ops = computeDiff({ a: { keep: 1 } }, malicious);
expect(ops).toStrictEqual([]);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
describe('op-list invariants', () => {
it('produces a unique path per op (order-independent list)', () => {
const base = { a: 1, b: [1, 2, 3], c: { d: 4 } };
const query = { a: 9, b: [1], c: { d: 4, e: 5 }, f: 6 };
const list = paths(computeDiff(base, query));
expect(new Set(list).size).toBe(list.length);
});
});
});

View File

@@ -1,89 +0,0 @@
import { isContainer, isEmptyContainer, isLeaf } from '../predicates';
const noop = (): void => undefined;
describe('qsAlias/diff predicates', () => {
describe('isContainer', () => {
it('is true for plain objects and arrays', () => {
expect(isContainer({})).toBe(true);
expect(isContainer({ a: 1 })).toBe(true);
expect(isContainer([])).toBe(true);
expect(isContainer([1, 2])).toBe(true);
});
it('is false for null and undefined', () => {
expect(isContainer(null)).toBe(false);
expect(isContainer(undefined)).toBe(false);
});
it('is false for scalars', () => {
expect(isContainer('')).toBe(false);
expect(isContainer('str')).toBe(false);
expect(isContainer(0)).toBe(false);
expect(isContainer(42)).toBe(false);
expect(isContainer(NaN)).toBe(false);
expect(isContainer(true)).toBe(false);
expect(isContainer(false)).toBe(false);
});
it('is false for functions and symbols', () => {
expect(isContainer(noop)).toBe(false);
expect(isContainer(Symbol('x'))).toBe(false);
});
it('is true for exotic objects like Date (typeof object)', () => {
expect(isContainer(new Date(0))).toBe(true);
});
});
describe('isEmptyContainer', () => {
it('is true only for [] and {}', () => {
expect(isEmptyContainer([])).toBe(true);
expect(isEmptyContainer({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isEmptyContainer([1])).toBe(false);
expect(isEmptyContainer({ a: 1 })).toBe(false);
});
it('is false for scalars, null and undefined', () => {
expect(isEmptyContainer(null)).toBe(false);
expect(isEmptyContainer(undefined)).toBe(false);
expect(isEmptyContainer('')).toBe(false);
expect(isEmptyContainer(0)).toBe(false);
});
it('treats objects with only non-enumerable keys (Date) as empty', () => {
// Date has no own *enumerable* keys, so Object.keys() is empty.
expect(isEmptyContainer(new Date(0))).toBe(true);
});
});
describe('isLeaf', () => {
it('is true for every scalar', () => {
['', 'str', 0, 1, -1, 3.14, true, false].forEach((value) => {
expect(isLeaf(value)).toBe(true);
});
});
it('is true for null and undefined', () => {
expect(isLeaf(null)).toBe(true);
expect(isLeaf(undefined)).toBe(true);
});
it('is true for empty containers', () => {
expect(isLeaf([])).toBe(true);
expect(isLeaf({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isLeaf([1])).toBe(false);
expect(isLeaf({ a: 1 })).toBe(false);
});
it('counts a key whose value is undefined as non-empty (not a leaf)', () => {
expect(isLeaf({ a: undefined })).toBe(false);
});
});
});

View File

@@ -1,178 +0,0 @@
import { Json, PathSeg } from './predicates';
export const DiffCode = {
Set: 1,
Delete: 2,
} as const;
export type DiffCodeValue = (typeof DiffCode)[keyof typeof DiffCode];
/**
* A single diff operation: `[code, path, value]`. `value` is `undefined` for deletes.
*/
export type DiffOp = [code: DiffCodeValue, path: PathSeg[], value: Json];
/**
* Keys that must never reach a downstream `set`/rebuild step. Walking these
* would let a crafted query poison `Object.prototype`. They are skipped on both
* sides of the diff, so neither a SET nor a DELETE op is ever produced for them.
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* Array paths that use template-based diffing (added elements diff against [0]).
* These are query builder arrays where added items are structurally similar.
*/
const TEMPLATE_ARRAY_PATHS = [
['builder', 'queryData'],
['builder', 'queryFormulas'],
['builder', 'queryTraceOperator'],
['promql'],
['clickhouse_sql'],
];
function isTemplateArrayPath(path: PathSeg[]): boolean {
return TEMPLATE_ARRAY_PATHS.some(
(pattern) =>
pattern.length === path.length && pattern.every((seg, i) => seg === path[i]),
);
}
const hasOwn = (obj: object, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const leavesEqual = (a: Json, b: Json): boolean =>
JSON.stringify(a) === JSON.stringify(b);
/**
* Diff two arrays element-wise.
* Extra query items are SET; missing ones DELETE.
* Special case: if query is empty but baseline isn't, emit a single SET of `[]`
* rather than individual DELETEs, so the empty array survives the round-trip.
*
* For known query builder arrays (queryData, queryFormulas, etc.), added elements
* diff against baseArr[0] as template to minimize output size.
*/
export function diffArrays(
baseArr: Json[],
queryArr: Json[],
path: PathSeg[] = [],
): DiffOp[] {
// If query is empty but baseline has elements, emit SET of [] to preserve it.
if (queryArr.length === 0 && baseArr.length > 0) {
return [[DiffCode.Set, path, []]];
}
// Use template diffing for known query builder arrays
const useTemplate = isTemplateArrayPath(path) && baseArr.length > 0;
const template = useTemplate ? baseArr[0] : undefined;
const ops: DiffOp[] = [];
const maxLen = Math.max(baseArr.length, queryArr.length);
for (let i = 0; i < maxLen; i += 1) {
const segPath = [...path, i];
if (i >= queryArr.length) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (i >= baseArr.length) {
// Use template diffing if available, otherwise wholesale SET
if (template !== undefined) {
ops.push(...diffNodes(template, queryArr[i], segPath));
} else {
ops.push([DiffCode.Set, segPath, queryArr[i]]);
}
} else {
ops.push(...diffNodes(baseArr[i], queryArr[i], segPath));
}
}
return ops;
}
/**
* Diff two plain objects by own keys. Forbidden keys are skipped entirely.
* Special case: if query is empty but baseline isn't, emit a single SET of `{}`
* rather than individual DELETEs, so the empty object survives the round-trip.
*/
export function diffObjects(
baseObj: Record<string, Json>,
queryObj: Record<string, Json>,
path: PathSeg[] = [],
): DiffOp[] {
const baseKeys = Object.keys(baseObj).filter((k) => !FORBIDDEN_KEYS.has(k));
const queryKeys = Object.keys(queryObj).filter((k) => !FORBIDDEN_KEYS.has(k));
// If query is empty but baseline has keys, emit SET of {} to preserve it.
if (queryKeys.length === 0 && baseKeys.length > 0) {
return [[DiffCode.Set, path, {}]];
}
const ops: DiffOp[] = [];
const allKeys = new Set([...baseKeys, ...queryKeys]);
for (const key of allKeys) {
const segPath = [...path, key];
if (!hasOwn(queryObj, key)) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (!hasOwn(baseObj, key)) {
ops.push([DiffCode.Set, segPath, queryObj[key]]);
} else {
ops.push(...diffNodes(baseObj[key], queryObj[key], segPath));
}
}
return ops;
}
/**
* Diff any two nodes, dispatching on their shape.
*/
export function diffNodes(
baseline: Json,
query: Json,
path: PathSeg[] = [],
): DiffOp[] {
const baseIsArray = Array.isArray(baseline);
const queryIsArray = Array.isArray(query);
const baseIsObj =
typeof baseline === 'object' && baseline !== null && !baseIsArray;
const queryIsObj =
typeof query === 'object' && query !== null && !queryIsArray;
// Both arrays: walk element-wise even if one is empty. This ensures paths
// like `['builder', 'queryFormulas', 0, ...]` are emitted (not a wholesale
// SET on the array itself), which is required for prefix substitution.
if (baseIsArray && queryIsArray) {
return diffArrays(baseline, query, path);
}
// Both plain objects (including empty ones): walk key-wise.
if (baseIsObj && queryIsObj) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// Both scalars (non-containers): emit a SET only when they differ.
if (!baseIsArray && !baseIsObj && !queryIsArray && !queryIsObj) {
return leavesEqual(baseline, query) ? [] : [[DiffCode.Set, path, query]];
}
// Mixed container types (array-vs-object): walk key-wise, treating array
// indices as string keys. This is an edge case but preserves intent.
if ((baseIsArray || baseIsObj) && (queryIsArray || queryIsObj)) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// True shape mismatch: scalar vs container → replace wholesale.
return [[DiffCode.Set, path, query]];
}
/**
* Entry point: diff a baseline against a query, rooted at the empty path.
*/
export function computeDiff(baseline: Json, query: Json): DiffOp[] {
return diffNodes(baseline, query, []);
}

View File

@@ -1,21 +0,0 @@
/**
* Value-shape predicates shared by the diff algorithm and the codec's leaf
* walker. A "leaf" is anything the serializer emits as a single token: a
* scalar (string/number/boolean/null/undefined), or an *empty* container
* (`[]` / `{}`). Non-empty containers are walked recursively.
*/
export type Json = unknown;
export type PathSeg = string | number;
export const isContainer = (
value: Json,
): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
export const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
export const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);

View File

@@ -1,33 +0,0 @@
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
import { decode, encode } from './codec';
const TAG_KEY = '_t';
const TAG_PREFIX = 'QA';
/**
* qsAlias (QA~): readable URL serialization with prefix substitution
* and field aliasing. Outputs multiple query params instead of single
* compositeQuery param.
*
* Format: _t=QAm&query0.ds=traces&query0.aa.key=http.status_code...
*
* Tags: QAm (metrics), QAl (logs), QAt (traces)
*/
export const qsAliasAdapter: CompositeQueryAdapter = {
name: 'qs-alias',
encode: (query) => {
const { params } = encode(query);
return params;
},
matches: (params) => {
const tag = params.get(TAG_KEY) ?? '';
return (
tag === `${TAG_PREFIX}m` ||
tag === `${TAG_PREFIX}l` ||
tag === `${TAG_PREFIX}t`
);
},
decode: (params) => decode(params),
};
export { encode as encodeQsAlias, decode as decodeQsAlias } from './codec';

View File

@@ -1,51 +0,0 @@
/**
* Leaf value codec: lossless, readable scalar encoding for the qsAlias wire.
*
* The wire is untyped text, so a string `"123"` and a number `123` would
* otherwise be indistinguishable after a round-trip. To disambiguate without
* hurting readability:
*
* - Strings are emitted verbatim (`traces`, `service.name`, …) — readable.
* - Every non-string scalar and empty container is type-tagged with a leading
* `_` followed by its JSON form (`_123`, `_true`, `_null`, `_[]`, `_{}`).
* - A string that itself begins with `_` is escaped by doubling the leading
* `_`, so it round-trips as a string instead of being read as a tag.
* - `undefined` has no URL representation and is normalized to `null`.
*
* `_` is used as the tag because it is left unescaped by both qs
* (`encodeValuesOnly`) and `URLSearchParams`, keeping tagged values readable.
* Wire-special characters (`&`, `=`, `%`, …) are NOT handled here — the caller
* percent-encodes values via qs `encodeValuesOnly`.
*/
import { Json } from './diff/predicates';
const TYPE_TAG = '_';
/** Encode a single leaf value into its wire token. */
export function encodeLeaf(value: Json): string {
if (value === undefined) {
return `${TYPE_TAG}null`;
}
if (typeof value === 'string') {
// Double the leading tag so a literal string survives as a string.
return value.startsWith(TYPE_TAG) ? `${TYPE_TAG}${value}` : value;
}
return `${TYPE_TAG}${JSON.stringify(value)}`;
}
/** Decode a wire token back into its leaf value. */
export function decodeLeaf(token: string): Json {
if (!token.startsWith(TYPE_TAG)) {
return token;
}
// `__…` is an escaped string — strip exactly one tag.
if (token[TYPE_TAG.length] === TYPE_TAG) {
return token.slice(TYPE_TAG.length);
}
try {
return JSON.parse(token.slice(TYPE_TAG.length));
} catch {
// Hand-crafted / corrupted token — fall back to raw text, never throw.
return token;
}
}

View File

@@ -1,70 +0,0 @@
/**
* Path and field alias maps for qsAlias encoder.
*
* PREFIX SUBSTITUTION:
* builder.queryData.0.field → query0.field
* builder.queryFormulas.0.field → formula0.field
* builder.queryTraceOperator.0.field → traceOp0.field
* promql.0.field → promql0.field
* clickhouse_sql.0.field → chsql0.field
*
* FIELD ALIASES (long → short):
* aggregateAttribute → aggAttr
* timeAggregation → timeAgg
* spaceAggregation → spaceAgg
*/
interface PrefixPattern {
match: string[];
prefix: string;
}
export const PREFIX_PATTERNS: PrefixPattern[] = [
{ match: ['builder', 'queryData'], prefix: 'query' },
{ match: ['builder', 'queryFormulas'], prefix: 'formula' },
{ match: ['builder', 'queryTraceOperator'], prefix: 'traceOp' },
{ match: ['promql'], prefix: 'promql' },
{ match: ['clickhouse_sql'], prefix: 'chsql' },
];
export const PREFIX_REVERSE: Record<string, string[]> = {
query: ['builder', 'queryData'],
formula: ['builder', 'queryFormulas'],
traceOp: ['builder', 'queryTraceOperator'],
promql: ['promql'],
chsql: ['clickhouse_sql'],
};
export const FIELD_ALIASES: Record<string, string> = {
aggregateAttribute: 'aggAttr',
aggregateOperator: 'aggOp',
timeAggregation: 'timeAgg',
spaceAggregation: 'spaceAgg',
stepInterval: 'stepIn',
dataSource: 'ds',
queryName: 'qn',
dataType: 'dt',
isColumn: 'ic',
isJSON: 'ij',
metricName: 'mn',
temporality: 'tp',
queryType: 'qt',
};
export const FIELD_REVERSE: Record<string, string> = Object.fromEntries(
Object.entries(FIELD_ALIASES).map(([k, v]) => [v, k]),
);
/**
* Keys that belong to the qsAlias format. Anything else is a foreign param.
* Derived from PREFIX_PATTERNS + known top-level Query keys (and their aliases).
*/
const OWNED_PREFIXES = PREFIX_PATTERNS.map((p) => p.prefix).join('|');
const OWNED_TOP_LEVEL = ['id', 'queryType', 'qt', 'unit'];
const OWNED_KEY_PATTERN = new RegExp(
`^(?:_t|-?(?:${OWNED_PREFIXES})\\d+|${OWNED_TOP_LEVEL.join('|')})(?:\\.|$)`,
);
export function isOwnedKey(key: string): boolean {
return OWNED_KEY_PATTERN.test(key);
}

View File

@@ -1,85 +0,0 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export interface RoundTripScenario {
name: string;
query: Query;
}
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const makePromqlQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
return query;
};
const makeClickhouseQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT count() FROM signoz_logs';
return query;
};
const makeModifiedBuilderQuery = (): Query => {
const query = clone(initialQueriesMap.logs);
const qd = query.builder.queryData[0];
qd.aggregateOperator = 'p95';
qd.disabled = true;
qd.stepInterval = 60;
qd.legend = 'error rate';
qd.filter = { expression: "severity_text = 'ERROR'" };
qd.filters = {
op: 'AND',
items: [
{
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
id: 'item-1',
op: '=',
value: 'ERROR',
},
],
};
qd.orderBy = [{ columnName: 'timestamp', order: 'desc' }];
return query;
};
const makeQueryWithCustomId = (): Query => ({
...initialQueriesMap.metrics,
id: 'test-query-uuid-123',
});
const makeQueryWithEnumLikeLegend = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].legend = 'sum';
query.id = 'my-query-id';
return query;
};
const makeQueryWithWireDelimiters = (): Query => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_a*b_*c';
query.builder.queryData[0].filter = { expression: '!weird = "x_y*z"' };
return query;
};
export const roundTripScenarios: RoundTripScenario[] = [
{ name: 'metrics baseline', query: initialQueriesMap.metrics },
{ name: 'logs baseline', query: initialQueriesMap.logs },
{ name: 'traces baseline', query: initialQueriesMap.traces },
{ name: 'promql query', query: makePromqlQuery() },
{ name: 'clickhouse query', query: makeClickhouseQuery() },
{ name: 'modified builder query', query: makeModifiedBuilderQuery() },
{ name: 'custom id', query: makeQueryWithCustomId() },
{ name: 'enum-like legend preserved', query: makeQueryWithEnumLikeLegend() },
{ name: 'wire delimiters in values', query: makeQueryWithWireDelimiters() },
];

View File

@@ -1,57 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for logs/traces queries — uses expression-style aggregations.
*/
export const LOGS_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -1,65 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for metrics queries — uses metric-style aggregations object.
*/
export const METRICS_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'metrics',
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: 'avg',
},
],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -1,57 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for traces queries — same as logs but with dataSource: traces.
*/
export const TRACES_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'traces',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -1,38 +0,0 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { LOGS_BASELINE_V1 } from 'lib/compositeQuery/baseline.logs';
import { TRACES_BASELINE_V1 } from 'lib/compositeQuery/baseline.traces';
import { METRICS_BASELINE_V1 } from 'lib/compositeQuery/baseline.metrics';
/**
* Baseline tag indicators for URL encoding.
*/
export type BaselineTag = 'm' | 'l' | 't';
/**
* Pick optimal baseline based on query's primary dataSource.
*/
export function pickBaseline(query: Query): {
baseline: Query;
tag: BaselineTag;
} {
const ds = query.builder?.queryData?.[0]?.dataSource;
if (ds === 'logs') {
return { baseline: LOGS_BASELINE_V1, tag: 'l' };
}
if (ds === 'traces') {
return { baseline: TRACES_BASELINE_V1, tag: 't' };
}
return { baseline: METRICS_BASELINE_V1, tag: 'm' };
}
function getBaselineByTag(tag: BaselineTag): Query {
if (tag === 'l') {
return LOGS_BASELINE_V1;
}
if (tag === 't') {
return TRACES_BASELINE_V1;
}
return METRICS_BASELINE_V1;
}
export default getBaselineByTag;

View File

@@ -1,80 +0,0 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import {
COMPOSITE_QUERY_KEY,
CompositeQueryAdapter,
} from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from 'lib/compositeQuery/adapters/qsAlias';
// Order matters for decode: most-specific (tagged) adapters first
const ADAPTERS: CompositeQueryAdapter[] = [qsAliasAdapter, jsonAdapter];
// Pick the adapter that owns a given URL. json's `matches` is always true, so
// it serves as the final fallback when no tagged adapter claims the params.
function adapterFor(params: URLSearchParams): CompositeQueryAdapter {
return ADAPTERS.find((adapter) => adapter.matches(params)) ?? jsonAdapter;
}
/**
* Encode a query to the shortest available URLSearchParams.
*/
export function serialize(query: Query): URLSearchParams {
return ADAPTERS[0].encode(query);
}
/**
* Decode URLSearchParams back to a Query. Total: returns null on any failure.
*/
export function deserialize(params: URLSearchParams): Query | null {
const hasParams = Array.from(params.keys()).length > 0;
if (!hasParams) {
return null;
}
try {
return adapterFor(params).decode(params);
} catch {
return null;
}
}
/**
* Apply all params from source into target URLSearchParams.
*/
export function applySerializedParams(
source: URLSearchParams,
target: URLSearchParams,
): void {
source.forEach((value, key) => target.set(key, value));
}
/**
* Remove every serialized-query param from target URLSearchParams. Use instead
* of `target.delete('compositeQuery')` so a stale query is fully purged even
* for adapters that explode a query into many content-dependent keys (e.g.
* `query0.ds`, `query0.fl.it.0.key.key`) which can't be listed statically.
*
* Keys are discovered by round-trip: decode the current params with their
* owning adapter, re-encode, then delete exactly the keys encoding produces.
* If the params don't decode (absent/corrupt), fall back to dropping the legacy
* single key so a stale `compositeQuery` is still cleared.
*/
export function clearSerializedParams(target: URLSearchParams): void {
const adapter = adapterFor(target);
try {
adapter.encode(adapter.decode(target)).forEach((_value, key) => {
target.delete(key);
});
} catch {
target.delete(COMPOSITE_QUERY_KEY);
}
}
/**
* Serialize a query to a plain record of all URL params it produces. Use when
* building a query-param object manually (e.g. for `createQueryParams`), so the
* call site carries every param the adapter emits — not just `compositeQuery`.
* Spread it: `{ ...serializeToParams(query), startTime, endTime }`.
*/
export function serializeToParams(query: Query): Record<string, string> {
return Object.fromEntries(serialize(query));
}

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